Vote lock 5min + email autocomplete + detail-page UI tweaks
parent
c77d014a01
commit
4a0188a443
|
|
@ -25,6 +25,9 @@ venv/
|
||||||
# docker volume (로컬)
|
# docker volume (로컬)
|
||||||
pgdata/
|
pgdata/
|
||||||
|
|
||||||
|
# 로컬 검증 전용 compose (서버에 가면 안 됨)
|
||||||
|
docker-compose.local.yml
|
||||||
|
|
||||||
# misc
|
# misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.pem
|
*.pem
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ frontend/src/
|
||||||
|
|
||||||
## 시간/스케줄 규칙 (워커가 자동 처리)
|
## 시간/스케줄 규칙 (워커가 자동 처리)
|
||||||
- 투표 오픈 = 킥오프 − 120h(D-5, `VOTE_OPEN_HOURS_BEFORE`)
|
- 투표 오픈 = 킥오프 − 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` 주기)
|
- 상태 전이: scheduled → open → locked → finished (`STATUS_TICK_SECONDS` 주기)
|
||||||
- AI 예측 생성: 매일 KST `AI_GENERATE_HOUR:MINUTE` (실 LLM API 호출)
|
- AI 예측 생성: 매일 KST `AI_GENERATE_HOUR:MINUTE` (실 LLM API 호출)
|
||||||
- 결과 메일: 경기 종료 후 `RESULT_EMAIL_DELAY_MINUTES`(기본 180=3h) 경과 시 발송
|
- 결과 메일: 경기 종료 후 `RESULT_EMAIL_DELAY_MINUTES`(기본 180=3h) 경과 시 발송
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ AI/이메일 키가 없어도 전체 플로우(예측·제출·채점·리더보
|
||||||
| 작업 | 시점 | 설정 |
|
| 작업 | 시점 | 설정 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 투표 오픈 | 킥오프 − 48h (D-2) | `VOTE_OPEN_HOURS_BEFORE` |
|
| 투표 오픈 | 킥오프 − 48h (D-2) | `VOTE_OPEN_HOURS_BEFORE` |
|
||||||
| 투표 마감 | 킥오프 정각 | `VOTE_LOCK_MINUTES_BEFORE` |
|
| 투표 마감 | 킥오프 − 5분 | `VOTE_LOCK_MINUTES_BEFORE` |
|
||||||
| 상태 전이 (scheduled→open→locked→finished) | 주기 틱 | `STATUS_TICK_SECONDS` (기본 60s) |
|
| 상태 전이 (scheduled→open→locked→finished) | 주기 틱 | `STATUS_TICK_SECONDS` (기본 60s) |
|
||||||
| AI 예측 생성 (3모델 실 API 호출) | 매일 KST 00:05 | `AI_GENERATE_HOUR` / `_MINUTE` |
|
| AI 예측 생성 (3모델 실 API 호출) | 매일 KST 00:05 | `AI_GENERATE_HOUR` / `_MINUTE` |
|
||||||
| 결과 메일 발송 (구독자) | 경기 종료 +3h | `RESULT_EMAIL_DELAY_MINUTES` |
|
| 결과 메일 발송 (구독자) | 경기 종료 +3h | `RESULT_EMAIL_DELAY_MINUTES` |
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ PUBLIC_ORIGIN=http://localhost:8080
|
||||||
|
|
||||||
# 투표 윈도우 (도메인 규칙)
|
# 투표 윈도우 (도메인 규칙)
|
||||||
VOTE_OPEN_HOURS_BEFORE=48 # 오픈 = 킥오프 D-2
|
VOTE_OPEN_HOURS_BEFORE=48 # 오픈 = 킥오프 D-2
|
||||||
VOTE_LOCK_MINUTES_BEFORE=60 # 마감 = 킥오프 1시간 전
|
VOTE_LOCK_MINUTES_BEFORE=5 # 마감 = 킥오프 5분 전
|
||||||
DEMO_FORCE_OPEN=true # 운영 배포 시 false
|
DEMO_FORCE_OPEN=true # 운영 배포 시 false
|
||||||
|
|
||||||
# 스케줄링 서버 (워커)
|
# 스케줄링 서버 (워커)
|
||||||
|
|
|
||||||
|
|
@ -52,8 +52,8 @@ class Settings(BaseSettings):
|
||||||
# ── 투표 윈도우 (도메인 규칙) ─────────────────────────────
|
# ── 투표 윈도우 (도메인 규칙) ─────────────────────────────
|
||||||
# 오픈 = 킥오프 - VOTE_OPEN_HOURS_BEFORE (D-5 = 120h)
|
# 오픈 = 킥오프 - VOTE_OPEN_HOURS_BEFORE (D-5 = 120h)
|
||||||
vote_open_hours_before: int = 120
|
vote_open_hours_before: int = 120
|
||||||
# 마감 = 킥오프 1시간 전 (경기 시작 1시간 전까지 투표). lock 오프셋(분).
|
# 마감 = 킥오프 5분 전 (경기 시작 5분 전까지 투표). lock 오프셋(분).
|
||||||
vote_lock_minutes_before: int = 60
|
vote_lock_minutes_before: int = 5
|
||||||
# 데모: True 면 오픈 게이트 무시(항상 투표 가능). 운영 배포 시 False.
|
# 데모: True 면 오픈 게이트 무시(항상 투표 가능). 운영 배포 시 False.
|
||||||
demo_force_open: bool = True
|
demo_force_open: bool = True
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import type {
|
||||||
AIPrediction,
|
AIPrediction,
|
||||||
Team,
|
Team,
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
import { ApiError, getDeviceId, submitPrediction } from "@/lib/api";
|
import { ApiError, getDeviceId, getRecentEmails, rememberEmail, submitPrediction } from "@/lib/api";
|
||||||
import Countdown from "./Countdown";
|
import Countdown from "./Countdown";
|
||||||
import TeamFlag from "./TeamFlag";
|
import TeamFlag from "./TeamFlag";
|
||||||
|
|
||||||
|
|
@ -43,7 +43,8 @@ export default function Arena({
|
||||||
const [scoreA, setScoreA] = useState(0);
|
const [scoreA, setScoreA] = useState(0);
|
||||||
const [scoreB, setScoreB] = useState(0);
|
const [scoreB, setScoreB] = useState(0);
|
||||||
const [step, setStep] = useState<Step>("pick");
|
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 [notify, setNotify] = useState(true);
|
||||||
const [crowd, setCrowd] = useState<CrowdStats>(initialCrowd);
|
const [crowd, setCrowd] = useState<CrowdStats>(initialCrowd);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
@ -98,6 +99,8 @@ export default function Arena({
|
||||||
notify,
|
notify,
|
||||||
});
|
});
|
||||||
setCrowd(res.crowd); // 서버 권위 분포로 갱신
|
setCrowd(res.crowd); // 서버 권위 분포로 갱신
|
||||||
|
rememberEmail(email.trim()); // 다음 투표 자동완성용으로 기억
|
||||||
|
setRecentEmails(getRecentEmails());
|
||||||
setStep("done");
|
setStep("done");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg =
|
const msg =
|
||||||
|
|
@ -299,11 +302,20 @@ export default function Arena({
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
inputMode="email"
|
inputMode="email"
|
||||||
|
list="tp-email-suggestions"
|
||||||
|
autoComplete="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
placeholder={t.emailPh}
|
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)]"
|
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)]">
|
<label className="flex items-start gap-2 text-[13px] text-[var(--ink-muted)]">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ export default function Hero({
|
||||||
<div className="mb-3 flex items-center justify-between">
|
<div className="mb-3 flex items-center justify-between">
|
||||||
<Link
|
<Link
|
||||||
to={home}
|
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}
|
{t.back}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ export default function ShareButton({
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={onShare}
|
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}
|
{copied ? copiedLabel : label}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,27 @@ export function getDeviceId(): string {
|
||||||
return id;
|
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 {
|
export function matchUrl(matchId: string): string {
|
||||||
return `${window.location.origin}/match/${matchId}`;
|
return `${window.location.origin}/match/${matchId}`;
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,7 @@ type Dict = {
|
||||||
footerDisc2: string;
|
footerDisc2: string;
|
||||||
footerDisc3: string;
|
footerDisc3: string;
|
||||||
hook: (a: string, b: string) => string;
|
hook: (a: string, b: string) => string;
|
||||||
|
moreMatches: string;
|
||||||
adLabel: string;
|
adLabel: string;
|
||||||
ado2Tagline: string;
|
ado2Tagline: string;
|
||||||
ado2Sub: string;
|
ado2Sub: string;
|
||||||
|
|
@ -169,6 +170,7 @@ export const DICT: Record<Lang, Dict> = {
|
||||||
footerDisc2: "스포츠 분석·엔터테인먼트 목적의 예측 게임이며 베팅·도박을 권유하지 않습니다. AI 예측은 실제 결과를 보장하지 않습니다.",
|
footerDisc2: "스포츠 분석·엔터테인먼트 목적의 예측 게임이며 베팅·도박을 권유하지 않습니다. AI 예측은 실제 결과를 보장하지 않습니다.",
|
||||||
footerDisc3: "100만 원 이벤트는 무료 참여형 챌린지입니다. 지급·동점 처리 조건은 별도 약관에 따릅니다. 이메일은 결과·이벤트 알림 목적으로만 사용됩니다.",
|
footerDisc3: "100만 원 이벤트는 무료 참여형 챌린지입니다. 지급·동점 처리 조건은 별도 약관에 따릅니다. 이메일은 결과·이벤트 알림 목적으로만 사용됩니다.",
|
||||||
hook: (a, b) => `${a} vs ${b}, AI의 선택은 갈렸다`,
|
hook: (a, b) => `${a} vs ${b}, AI의 선택은 갈렸다`,
|
||||||
|
moreMatches: "다른 경기 투표하기",
|
||||||
adLabel: "광고",
|
adLabel: "광고",
|
||||||
ado2Tagline: "AI 마케팅 자동화 플랫폼",
|
ado2Tagline: "AI 마케팅 자동화 플랫폼",
|
||||||
ado2Sub: "AI Marketing Automation Platform →",
|
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.",
|
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.",
|
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}`,
|
hook: (a, b) => `AI is split on ${a} vs ${b}`,
|
||||||
|
moreMatches: "Vote on another match",
|
||||||
adLabel: "AD",
|
adLabel: "AD",
|
||||||
ado2Tagline: "AI Marketing Automation Platform",
|
ado2Tagline: "AI Marketing Automation Platform",
|
||||||
ado2Sub: "Visit ado2.o2osolution.ai →",
|
ado2Sub: "Visit ado2.o2osolution.ai →",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { Link, useParams } from "react-router-dom";
|
||||||
import Hero from "@/components/Hero";
|
import Hero from "@/components/Hero";
|
||||||
import MatchupHUD from "@/components/MatchupHUD";
|
import MatchupHUD from "@/components/MatchupHUD";
|
||||||
import Arena from "@/components/Arena";
|
import Arena from "@/components/Arena";
|
||||||
|
|
@ -97,6 +97,17 @@ export default function MatchDetail() {
|
||||||
shareUrl={url}
|
shareUrl={url}
|
||||||
lang={lang}
|
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} />
|
<Footer lang={lang} />
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue