Vote lock 5min + email autocomplete + detail-page UI tweaks

develop
jwkim 2026-06-15 11:28:41 +09:00
parent c77d014a01
commit 4a0188a443
11 changed files with 60 additions and 10 deletions

3
.gitignore vendored
View File

@ -25,6 +25,9 @@ venv/
# docker volume (로컬)
pgdata/
# 로컬 검증 전용 compose (서버에 가면 안 됨)
docker-compose.local.yml
# misc
.DS_Store
*.pem

View File

@ -31,7 +31,7 @@ frontend/src/
## 시간/스케줄 규칙 (워커가 자동 처리)
- 투표 오픈 = 킥오프 120h(D-5, `VOTE_OPEN_HOURS_BEFORE`)
- 투표 마감 = 킥오프 정각(`VOTE_LOCK_MINUTES_BEFORE=0`)
- 투표 마감 = 킥오프 5분 전(`VOTE_LOCK_MINUTES_BEFORE=5`)
- 상태 전이: scheduled → open → locked → finished (`STATUS_TICK_SECONDS` 주기)
- AI 예측 생성: 매일 KST `AI_GENERATE_HOUR:MINUTE` (실 LLM API 호출)
- 결과 메일: 경기 종료 후 `RESULT_EMAIL_DELAY_MINUTES`(기본 180=3h) 경과 시 발송

View File

@ -41,7 +41,7 @@ AI/이메일 키가 없어도 전체 플로우(예측·제출·채점·리더보
| 작업 | 시점 | 설정 |
|---|---|---|
| 투표 오픈 | 킥오프 48h (D-2) | `VOTE_OPEN_HOURS_BEFORE` |
| 투표 마감 | 킥오프 정각 | `VOTE_LOCK_MINUTES_BEFORE` |
| 투표 마감 | 킥오프 5분 | `VOTE_LOCK_MINUTES_BEFORE` |
| 상태 전이 (scheduled→open→locked→finished) | 주기 틱 | `STATUS_TICK_SECONDS` (기본 60s) |
| AI 예측 생성 (3모델 실 API 호출) | 매일 KST 00:05 | `AI_GENERATE_HOUR` / `_MINUTE` |
| 결과 메일 발송 (구독자) | 경기 종료 +3h | `RESULT_EMAIL_DELAY_MINUTES` |

View File

@ -21,7 +21,7 @@ PUBLIC_ORIGIN=http://localhost:8080
# 투표 윈도우 (도메인 규칙)
VOTE_OPEN_HOURS_BEFORE=48 # 오픈 = 킥오프 D-2
VOTE_LOCK_MINUTES_BEFORE=60 # 마감 = 킥오프 1시간
VOTE_LOCK_MINUTES_BEFORE=5 # 마감 = 킥오프 5분
DEMO_FORCE_OPEN=true # 운영 배포 시 false
# 스케줄링 서버 (워커)

View File

@ -52,8 +52,8 @@ class Settings(BaseSettings):
# ── 투표 윈도우 (도메인 규칙) ─────────────────────────────
# 오픈 = 킥오프 - VOTE_OPEN_HOURS_BEFORE (D-5 = 120h)
vote_open_hours_before: int = 120
# 마감 = 킥오프 1시간 전 (경기 시작 1시간 전까지 투표). lock 오프셋(분).
vote_lock_minutes_before: int = 60
# 마감 = 킥오프 5분 전 (경기 시작 5분 전까지 투표). lock 오프셋(분).
vote_lock_minutes_before: int = 5
# 데모: True 면 오픈 게이트 무시(항상 투표 가능). 운영 배포 시 False.
demo_force_open: bool = True

View File

@ -9,7 +9,7 @@ import type {
AIPrediction,
Team,
} from "@/lib/types";
import { ApiError, getDeviceId, submitPrediction } from "@/lib/api";
import { ApiError, getDeviceId, getRecentEmails, rememberEmail, submitPrediction } from "@/lib/api";
import Countdown from "./Countdown";
import TeamFlag from "./TeamFlag";
@ -43,7 +43,8 @@ export default function Arena({
const [scoreA, setScoreA] = useState(0);
const [scoreB, setScoreB] = useState(0);
const [step, setStep] = useState<Step>("pick");
const [email, setEmail] = useState("");
const [recentEmails, setRecentEmails] = useState<string[]>(getRecentEmails);
const [email, setEmail] = useState(() => recentEmails[0] ?? "");
const [notify, setNotify] = useState(true);
const [crowd, setCrowd] = useState<CrowdStats>(initialCrowd);
const [copied, setCopied] = useState(false);
@ -98,6 +99,8 @@ export default function Arena({
notify,
});
setCrowd(res.crowd); // 서버 권위 분포로 갱신
rememberEmail(email.trim()); // 다음 투표 자동완성용으로 기억
setRecentEmails(getRecentEmails());
setStep("done");
} catch (e) {
const msg =
@ -299,11 +302,20 @@ export default function Arena({
<input
type="email"
inputMode="email"
list="tp-email-suggestions"
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={t.emailPh}
className="w-full rounded-lg border border-[var(--line-d)] bg-[#0f1217] px-3 py-3 text-[15px] text-white outline-none focus:border-[var(--green)]"
/>
{recentEmails.length > 0 && (
<datalist id="tp-email-suggestions">
{recentEmails.map((e) => (
<option key={e} value={e} />
))}
</datalist>
)}
<label className="flex items-start gap-2 text-[13px] text-[var(--ink-muted)]">
<input
type="checkbox"

View File

@ -23,7 +23,7 @@ export default function Hero({
<div className="mb-3 flex items-center justify-between">
<Link
to={home}
className="flex items-center gap-1 rounded-full border border-white/12 bg-white/8 px-3 py-1.5 text-[12px] font-bold text-white/85 active:scale-95"
className="flex items-center gap-1.5 rounded-full border border-white/12 bg-white/8 px-[18px] py-[9px] text-[18px] font-bold text-white/85 active:scale-95"
>
{t.back}
</Link>

View File

@ -37,7 +37,7 @@ export default function ShareButton({
return (
<button
onClick={onShare}
className="flex items-center rounded-full border border-[var(--share)]/55 bg-[var(--share)]/12 px-3 py-1.5 text-[12px] font-bold text-[var(--share)] transition active:scale-95"
className="flex items-center rounded-full border border-[var(--share)]/55 bg-[var(--share)]/12 px-[18px] py-[9px] text-[18px] font-bold text-[var(--share)] transition active:scale-95"
>
{copied ? copiedLabel : label}
</button>

View File

@ -88,6 +88,27 @@ export function getDeviceId(): string {
return id;
}
// ── 이메일 기억: 투표 시 입력한 이메일을 자동완성용으로 보관 (최근순 최대 5개) ──
const EMAILS_KEY = "tp_recent_emails";
export function getRecentEmails(): string[] {
try {
const raw = localStorage.getItem(EMAILS_KEY);
return raw ? (JSON.parse(raw) as string[]).filter((e) => typeof e === "string") : [];
} catch {
return [];
}
}
export function rememberEmail(email: string): void {
const e = email.trim();
if (!e) return;
const next = [e, ...getRecentEmails().filter((x) => x.toLowerCase() !== e.toLowerCase())].slice(0, 5);
try {
localStorage.setItem(EMAILS_KEY, JSON.stringify(next));
} catch {
/* localStorage 불가 환경(시크릿 등)은 조용히 무시 */
}
}
// 경기별 공유 딥링크
export function matchUrl(matchId: string): string {
return `${window.location.origin}/match/${matchId}`;

View File

@ -98,6 +98,7 @@ type Dict = {
footerDisc2: string;
footerDisc3: string;
hook: (a: string, b: string) => string;
moreMatches: string;
adLabel: string;
ado2Tagline: string;
ado2Sub: string;
@ -169,6 +170,7 @@ export const DICT: Record<Lang, Dict> = {
footerDisc2: "스포츠 분석·엔터테인먼트 목적의 예측 게임이며 베팅·도박을 권유하지 않습니다. AI 예측은 실제 결과를 보장하지 않습니다.",
footerDisc3: "100만 원 이벤트는 무료 참여형 챌린지입니다. 지급·동점 처리 조건은 별도 약관에 따릅니다. 이메일은 결과·이벤트 알림 목적으로만 사용됩니다.",
hook: (a, b) => `${a} vs ${b}, AI의 선택은 갈렸다`,
moreMatches: "다른 경기 투표하기",
adLabel: "광고",
ado2Tagline: "AI 마케팅 자동화 플랫폼",
ado2Sub: "AI Marketing Automation Platform →",
@ -238,6 +240,7 @@ export const DICT: Record<Lang, Dict> = {
footerDisc2: "A sports-analysis & entertainment prediction game. No betting or gambling. AI predictions do not guarantee real outcomes.",
footerDisc3: "The ₩1,000,000 event is a free-to-enter challenge. Payout & tie-break terms follow separate rules. Email is used only for result & event alerts.",
hook: (a, b) => `AI is split on ${a} vs ${b}`,
moreMatches: "Vote on another match",
adLabel: "AD",
ado2Tagline: "AI Marketing Automation Platform",
ado2Sub: "Visit ado2.o2osolution.ai →",

View File

@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { Link, useParams } from "react-router-dom";
import Hero from "@/components/Hero";
import MatchupHUD from "@/components/MatchupHUD";
import Arena from "@/components/Arena";
@ -97,6 +97,17 @@ export default function MatchDetail() {
shareUrl={url}
lang={lang}
/>
{/* 하단 CTA — 전체 일정으로(다른 경기 투표). ADO2 버튼과 동일 사양(퍼플 #A65EFF) */}
<Link
to={lang === "en" ? "/?lang=en" : "/"}
className="mt-7 flex items-center justify-center gap-1.5 rounded-full bg-[#A65EFF] px-6 py-3 text-[15px] font-extrabold text-white transition active:scale-[0.99]"
style={{ boxShadow: "0 10px 28px rgba(166,94,255,0.40)" }}
>
{t.moreMatches}
<span aria-hidden></span>
</Link>
<Footer lang={lang} />
</main>
);