Vote lock 5min + email autocomplete + detail-page UI tweaks
parent
c77d014a01
commit
4a0188a443
|
|
@ -25,6 +25,9 @@ venv/
|
|||
# docker volume (로컬)
|
||||
pgdata/
|
||||
|
||||
# 로컬 검증 전용 compose (서버에 가면 안 됨)
|
||||
docker-compose.local.yml
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
|
|
|||
|
|
@ -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) 경과 시 발송
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
# 스케줄링 서버 (워커)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
|
|
|
|||
|
|
@ -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 →",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in New Issue