535 lines
20 KiB
TypeScript
535 lines
20 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useMemo, useState } from "react";
|
||
import ClueList from "@/components/ClueList";
|
||
import Header from "@/components/Header";
|
||
import OnboardingModal from "@/components/OnboardingModal";
|
||
import Pitch from "@/components/Pitch";
|
||
import PlayerSearch from "@/components/PlayerSearch";
|
||
import FeedbackModal from "@/components/FeedbackModal";
|
||
import ShareResult from "@/components/ShareResult";
|
||
import SponsorSlot from "@/components/SponsorSlot";
|
||
import StatsPanel from "@/components/StatsPanel";
|
||
import { trackEvent } from "@/lib/analytics-events";
|
||
import { buildGlobalStatsShareText } from "@/lib/share";
|
||
import type { SponsorConfig } from "@/lib/sponsor";
|
||
import {
|
||
DIFFICULTIES,
|
||
MAX_ATTEMPTS,
|
||
POSITIONS,
|
||
getDailyPuzzles,
|
||
getFixedGuess,
|
||
getPuzzleNumber,
|
||
getTodayKey,
|
||
isAttemptComplete,
|
||
isSolved,
|
||
players,
|
||
validateGuess,
|
||
} from "@/lib/game-engine";
|
||
import { SUPPORTED_LOCALES, detectLocale, getDictionary, type Locale } from "@/lib/i18n";
|
||
import { quoteForDay } from "@/lib/quotes";
|
||
import {
|
||
createInitialProgress,
|
||
createInitialStats,
|
||
loadProgress,
|
||
loadStats,
|
||
nextDifficulty,
|
||
recordDailyStats,
|
||
saveProgress,
|
||
} from "@/lib/storage";
|
||
import type { DailyProgress, Difficulty, GlobalStats, Position } from "@/lib/types";
|
||
|
||
type HomeProps = {
|
||
sponsor: SponsorConfig;
|
||
};
|
||
|
||
export default function Home({ sponsor }: HomeProps) {
|
||
const dateKey = useMemo(() => getTodayKey(), []);
|
||
const dailyPuzzles = useMemo(() => getDailyPuzzles(dateKey), [dateKey]);
|
||
const [locale, setLocale] = useState<Locale>("en");
|
||
const [progress, setProgress] = useState<DailyProgress>(() => createInitialProgress());
|
||
const [activeDifficulty, setActiveDifficulty] = useState<Difficulty>("easy");
|
||
const [cluesCollapsed, setCluesCollapsed] = useState(false);
|
||
const [fieldCollapsed, setFieldCollapsed] = useState(false);
|
||
const [shareOpen, setShareOpen] = useState(false);
|
||
const [onboardingOpen, setOnboardingOpen] = useState(false);
|
||
const [started, setStarted] = useState(false);
|
||
const [feedbackOpen, setFeedbackOpen] = useState(false);
|
||
const [statsOpen, setStatsOpen] = useState(false);
|
||
const [revealedSolutions, setRevealedSolutions] = useState<Partial<Record<Difficulty, boolean>>>({});
|
||
const [selectedPosition, setSelectedPosition] = useState<Position>("DEF");
|
||
const [stats, setStats] = useState<GlobalStats>(() => createInitialStats());
|
||
const [draft, setDraft] = useState<Record<Position, string | null>>(() => getFixedGuess(dailyPuzzles[0]));
|
||
const [message, setMessage] = useState("");
|
||
|
||
const activePuzzle = dailyPuzzles.find((puzzle) => puzzle.difficulty === activeDifficulty) ?? dailyPuzzles[0];
|
||
const activeProgress = progress[activeDifficulty];
|
||
const isLocked = activeProgress.locked;
|
||
const isFinished = activeProgress.solved || activeProgress.attempts.length >= MAX_ATTEMPTS;
|
||
const hasFailed = !activeProgress.solved && activeProgress.attempts.length >= MAX_ATTEMPTS;
|
||
const solutionRevealed = revealedSolutions[activeDifficulty] === true;
|
||
const pitchDraft = solutionRevealed ? activePuzzle.solution : draft;
|
||
const t = useMemo(() => getDictionary(locale), [locale]);
|
||
const quote = useMemo(() => quoteForDay(dateKey, locale), [dateKey, locale]);
|
||
const globalStatsShareText = useMemo(() => buildGlobalStatsShareText(stats, t), [stats, t]);
|
||
const totalAttempts = DIFFICULTIES.reduce((sum, difficulty) => sum + progress[difficulty].attempts.length, 0);
|
||
|
||
useEffect(() => {
|
||
const savedLocale = window.localStorage.getItem("hidden11-locale") as Locale | null;
|
||
setLocale(savedLocale ?? detectLocale(window.navigator.language));
|
||
const loaded = loadProgress(dateKey);
|
||
setProgress(loaded);
|
||
setStats(loadStats());
|
||
const firstPlayable = DIFFICULTIES.find((difficulty) => !loaded[difficulty].locked) ?? "easy";
|
||
setActiveDifficulty(firstPlayable);
|
||
const seenOnboarding = window.localStorage.getItem("hidden11-onboarding-seen") === "true";
|
||
setOnboardingOpen(!seenOnboarding && DIFFICULTIES.every((difficulty) => loaded[difficulty].attempts.length === 0));
|
||
}, [dateKey]);
|
||
|
||
useEffect(() => {
|
||
setDraft(getFixedGuess(activePuzzle));
|
||
const firstEmpty = POSITIONS.find((position) => position !== activePuzzle.fixedPosition) ?? "GK";
|
||
setSelectedPosition(firstEmpty);
|
||
setMessage("");
|
||
}, [activePuzzle]);
|
||
|
||
useEffect(() => {
|
||
if (isLocked || isFinished) return;
|
||
|
||
const interval = window.setInterval(() => {
|
||
setProgress((current) => {
|
||
const next = structuredClone(current);
|
||
next[activeDifficulty].elapsedSeconds += 1;
|
||
saveProgress(dateKey, next);
|
||
return next;
|
||
});
|
||
}, 1000);
|
||
|
||
return () => window.clearInterval(interval);
|
||
}, [activeDifficulty, dateKey, isFinished, isLocked]);
|
||
|
||
useEffect(() => {
|
||
if (!isFinished) return;
|
||
const nextStats = recordDailyStats(dateKey, progress);
|
||
setStats(nextStats);
|
||
setShareOpen(true);
|
||
if (DIFFICULTIES.every((difficulty) => progress[difficulty].solved)) {
|
||
const completionKey = `hidden11-daily-completed-event-${dateKey}`;
|
||
if (window.localStorage.getItem(completionKey) !== "true") {
|
||
trackEvent("daily_completed", {
|
||
puzzle_number: getPuzzleNumber(dateKey),
|
||
total_seconds: DIFFICULTIES.reduce((sum, difficulty) => sum + progress[difficulty].elapsedSeconds, 0),
|
||
});
|
||
window.localStorage.setItem(completionKey, "true");
|
||
}
|
||
}
|
||
}, [activeDifficulty, dateKey, isFinished, progress]);
|
||
|
||
function updateProgress(next: DailyProgress) {
|
||
setProgress(next);
|
||
saveProgress(dateKey, next);
|
||
}
|
||
|
||
function startGame() {
|
||
setStarted(true);
|
||
window.localStorage.setItem("hidden11-onboarding-seen", "true");
|
||
setOnboardingOpen(false);
|
||
trackEvent("game_started", { puzzle_number: getPuzzleNumber(dateKey) });
|
||
window.requestAnimationFrame(() => document.getElementById("player-search")?.focus());
|
||
}
|
||
|
||
function selectDifficulty(difficulty: Difficulty) {
|
||
if (progress[difficulty].locked) return;
|
||
setActiveDifficulty(difficulty);
|
||
}
|
||
|
||
function placePlayer(playerId: string) {
|
||
if (isLocked || isFinished || selectedPosition === activePuzzle.fixedPosition) return;
|
||
if (!started && totalAttempts === 0) {
|
||
setStarted(true);
|
||
trackEvent("game_started", { puzzle_number: getPuzzleNumber(dateKey) });
|
||
}
|
||
setDraft((current) => ({ ...current, [selectedPosition]: playerId }));
|
||
}
|
||
|
||
function submitGuess() {
|
||
if (isLocked || isFinished) return;
|
||
if (!isAttemptComplete(draft)) {
|
||
setMessage(t.completeLineup);
|
||
return;
|
||
}
|
||
|
||
const guess = validateGuess(activePuzzle, draft);
|
||
const solved = isSolved(guess);
|
||
const next = structuredClone(progress);
|
||
next[activeDifficulty].attempts.push(guess);
|
||
next[activeDifficulty].solved = solved;
|
||
trackEvent("guess_submitted", {
|
||
attempts: next[activeDifficulty].attempts.length,
|
||
difficulty: activeDifficulty,
|
||
puzzle_number: getPuzzleNumber(dateKey),
|
||
solved,
|
||
});
|
||
|
||
if (solved) {
|
||
const unlocked = nextDifficulty(activeDifficulty);
|
||
if (unlocked) next[unlocked].locked = false;
|
||
if (unlocked) {
|
||
trackEvent("difficulty_unlocked", { difficulty: unlocked, puzzle_number: getPuzzleNumber(dateKey) });
|
||
}
|
||
setMessage(unlocked ? t.unlocked(t.difficulty[unlocked]) : t.allCompleted);
|
||
if (unlocked) {
|
||
setActiveDifficulty(unlocked);
|
||
}
|
||
} else if (next[activeDifficulty].attempts.length >= MAX_ATTEMPTS) {
|
||
setMessage(t.noAttemptsLeft);
|
||
} else {
|
||
setMessage(t.attemptsRemaining(MAX_ATTEMPTS - next[activeDifficulty].attempts.length));
|
||
}
|
||
|
||
updateProgress(next);
|
||
}
|
||
|
||
function resetDraft() {
|
||
setDraft(getFixedGuess(activePuzzle));
|
||
setSelectedPosition(POSITIONS.find((position) => position !== activePuzzle.fixedPosition) ?? "GK");
|
||
}
|
||
|
||
function revealHint() {
|
||
if (isLocked || isFinished) return;
|
||
|
||
const positionToReveal = POSITIONS.find(
|
||
(position) => position !== activePuzzle.fixedPosition && draft[position] !== activePuzzle.solution[position],
|
||
);
|
||
|
||
if (!positionToReveal) {
|
||
setMessage(t.noHintAvailable);
|
||
return;
|
||
}
|
||
|
||
setDraft((current) => ({
|
||
...current,
|
||
[positionToReveal]: activePuzzle.solution[positionToReveal],
|
||
}));
|
||
setSelectedPosition(positionToReveal);
|
||
setMessage(t.hintRevealed(positionToReveal));
|
||
}
|
||
|
||
function revealSolution() {
|
||
if (!hasFailed) return;
|
||
|
||
setRevealedSolutions((current) => ({ ...current, [activeDifficulty]: true }));
|
||
setDraft(activePuzzle.solution);
|
||
setMessage(t.solutionRevealed);
|
||
trackEvent("solution_revealed", {
|
||
difficulty: activeDifficulty,
|
||
puzzle_number: getPuzzleNumber(dateKey),
|
||
});
|
||
}
|
||
|
||
function changeLocale(nextLocale: Locale) {
|
||
setLocale(nextLocale);
|
||
window.localStorage.setItem("hidden11-locale", nextLocale);
|
||
}
|
||
|
||
async function shareGlobalStats() {
|
||
const homeUrl = typeof window === "undefined" ? "https://hidden11.app" : window.location.origin.replace(/\/$/, "");
|
||
|
||
trackEvent("share_clicked", { source: "global_stats", games_played: stats.gamesPlayed });
|
||
|
||
if (navigator.share) {
|
||
await navigator.share({
|
||
title: "hidden11",
|
||
text: globalStatsShareText,
|
||
url: homeUrl,
|
||
});
|
||
return;
|
||
}
|
||
|
||
window.open(
|
||
`https://twitter.com/intent/tweet?text=${encodeURIComponent(globalStatsShareText)}&url=${encodeURIComponent(homeUrl)}`,
|
||
"_blank",
|
||
"noopener,noreferrer",
|
||
);
|
||
}
|
||
|
||
const actionButtons = (
|
||
<div className="flex items-center gap-1.5">
|
||
{hasFailed && !solutionRevealed ? (
|
||
<button
|
||
type="button"
|
||
onClick={revealSolution}
|
||
className="h-8 rounded-full border border-emerald-300/40 bg-emerald-300/15 px-3 text-[10px] font-black uppercase text-emerald-100 transition hover:bg-emerald-300/25"
|
||
>
|
||
{t.showSolution}
|
||
</button>
|
||
) : null}
|
||
<button
|
||
type="button"
|
||
onClick={revealHint}
|
||
disabled={isLocked || isFinished}
|
||
className="flex h-8 w-8 items-center justify-center rounded-full border border-yellow-300/40 bg-yellow-300/15 text-sm font-black text-yellow-100 transition hover:bg-yellow-300/25 disabled:cursor-not-allowed disabled:opacity-40"
|
||
aria-label={t.hint}
|
||
title={t.hint}
|
||
>
|
||
?
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={submitGuess}
|
||
disabled={isLocked || isFinished}
|
||
className="flex h-9 w-9 items-center justify-center rounded-full bg-white text-base font-black text-pitch-950 shadow-lg transition hover:bg-emerald-100 disabled:cursor-not-allowed disabled:opacity-40"
|
||
aria-label={t.validate}
|
||
title={t.validate}
|
||
>
|
||
✓
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={resetDraft}
|
||
disabled={isLocked || isFinished}
|
||
className="flex h-8 w-8 items-center justify-center rounded-full border border-white/15 bg-white/[0.06] text-sm font-black text-white transition hover:bg-white/10 disabled:cursor-not-allowed disabled:opacity-40"
|
||
aria-label={t.reset}
|
||
title={t.reset}
|
||
>
|
||
↺
|
||
</button>
|
||
</div>
|
||
);
|
||
|
||
return (
|
||
<main className="min-h-screen bg-[radial-gradient(circle_at_top,#115c39_0,#061b12_45%,#04110c_100%)]">
|
||
<div className="mx-auto flex min-h-screen w-full max-w-5xl flex-col px-4 py-5 sm:px-6 lg:px-8">
|
||
<Header
|
||
attemptsUsed={activeProgress.attempts.length}
|
||
dateKey={dateKey}
|
||
elapsedSeconds={activeProgress.elapsedSeconds}
|
||
solved={activeProgress.solved}
|
||
t={t}
|
||
/>
|
||
|
||
<div className="mt-3 flex justify-end">
|
||
<button
|
||
type="button"
|
||
onClick={() => setStatsOpen(true)}
|
||
className="rounded-full border border-white/10 bg-white/[0.06] px-3 py-1.5 text-xs font-black uppercase text-white/65 transition hover:bg-white/10 hover:text-white"
|
||
>
|
||
{t.stats}
|
||
</button>
|
||
</div>
|
||
|
||
{totalAttempts === 0 && !started ? (
|
||
<section className="mt-3 rounded-lg border border-emerald-300/25 bg-emerald-300/10 p-3">
|
||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||
<div>
|
||
<p className="text-sm font-black text-white">{t.firstMoveTitle}</p>
|
||
<p className="mt-1 text-xs font-semibold leading-5 text-emerald-50/75">{t.firstMoveLead}</p>
|
||
<div className="mt-2 flex gap-1">
|
||
<span className="h-4 w-4 rounded bg-emerald-400" title={t.exact} />
|
||
<span className="h-4 w-4 rounded bg-yellow-400" title={t.close} />
|
||
<span className="h-4 w-4 rounded bg-slate-500" title={t.miss} />
|
||
</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={startGame}
|
||
className="h-10 rounded-lg bg-emerald-400 px-4 text-xs font-black uppercase text-pitch-950 transition hover:bg-emerald-300"
|
||
>
|
||
{t.playToday}
|
||
</button>
|
||
</div>
|
||
</section>
|
||
) : null}
|
||
|
||
<div className="mt-3 rounded-lg border border-white/10 bg-white/[0.04] px-3 py-2">
|
||
<p className="truncate text-xs font-semibold text-white/65">
|
||
“{quote.text[locale]}” <span className="text-white/35">— {quote.author}</span>
|
||
</p>
|
||
</div>
|
||
|
||
<div className="mt-3">
|
||
<SponsorSlot placement="home" sponsor={sponsor} />
|
||
</div>
|
||
|
||
<section
|
||
className="mt-3 overflow-hidden rounded-lg border border-white/10 bg-white/[0.06]"
|
||
aria-label={t.puzzle}
|
||
>
|
||
<div className="flex items-stretch">
|
||
{DIFFICULTIES.map((difficulty) => (
|
||
<button
|
||
key={difficulty}
|
||
type="button"
|
||
onClick={() => selectDifficulty(difficulty)}
|
||
className={`relative flex min-h-10 flex-1 flex-col items-center justify-center px-3 py-1.5 text-center transition after:absolute after:right-[-8px] after:top-0 after:z-10 after:h-full after:w-4 after:skew-x-[-18deg] after:border-r after:border-white/10 after:content-[''] last:after:hidden ${
|
||
activeDifficulty === difficulty
|
||
? "bg-white text-pitch-950 after:bg-white"
|
||
: "text-white hover:bg-white/10 after:bg-white/[0.06]"
|
||
} ${progress[difficulty].locked ? "cursor-not-allowed opacity-45" : ""}`}
|
||
title={
|
||
progress[difficulty].locked
|
||
? `${t.difficulty[difficulty]} ${t.locked}`
|
||
: progress[difficulty].solved
|
||
? `${t.difficulty[difficulty]} ${t.solved}`
|
||
: `${t.difficulty[difficulty]} ${t.available}`
|
||
}
|
||
>
|
||
<span className="flex items-center justify-center gap-1 text-xs font-black uppercase sm:text-sm">
|
||
<span>{t.difficulty[difficulty]}</span>
|
||
{progress[difficulty].locked
|
||
? "🔒"
|
||
: progress[difficulty].solved
|
||
? "✓"
|
||
: null}
|
||
</span>
|
||
<span className="mt-0.5 block text-[10px] font-bold opacity-65 sm:text-xs">
|
||
{progress[difficulty].locked
|
||
? t.locked
|
||
: progress[difficulty].solved
|
||
? `${progress[difficulty].attempts.length}/${MAX_ATTEMPTS}`
|
||
: t.left(MAX_ATTEMPTS - progress[difficulty].attempts.length)}
|
||
</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{message ? <p className="border-t border-white/10 px-2 py-1.5 text-xs font-semibold text-emerald-200">{message}</p> : null}
|
||
</section>
|
||
|
||
<div className="mt-4">
|
||
<PlayerSearch
|
||
disabled={isLocked || isFinished}
|
||
difficulty={activeDifficulty}
|
||
fixedPosition={activePuzzle.fixedPosition}
|
||
players={players}
|
||
selectedPosition={selectedPosition}
|
||
t={t}
|
||
onSelectPosition={setSelectedPosition}
|
||
onSelect={placePlayer}
|
||
/>
|
||
</div>
|
||
|
||
<div className="mt-5 grid flex-1 gap-5 lg:grid-cols-[minmax(0,1.1fr)_minmax(320px,0.9fr)]">
|
||
<section className="min-w-0 lg:order-1">
|
||
<div className="mb-4 lg:hidden">
|
||
<ClueList
|
||
clues={activePuzzle.clues}
|
||
collapsed={cluesCollapsed}
|
||
onToggleCollapsed={() => setCluesCollapsed((current) => !current)}
|
||
t={t}
|
||
/>
|
||
</div>
|
||
|
||
<Pitch
|
||
actions={actionButtons}
|
||
collapsed={fieldCollapsed}
|
||
draft={pitchDraft}
|
||
fixedPosition={activePuzzle.fixedPosition}
|
||
lastGuess={activeProgress.attempts[activeProgress.attempts.length - 1]}
|
||
onToggleCollapsed={() => setFieldCollapsed((current) => !current)}
|
||
selectedPosition={selectedPosition}
|
||
solutionRevealed={solutionRevealed}
|
||
t={t}
|
||
onSelectPosition={setSelectedPosition}
|
||
/>
|
||
</section>
|
||
|
||
<aside className="flex min-w-0 flex-col gap-4 lg:order-2">
|
||
<div className="hidden lg:block">
|
||
<ClueList
|
||
clues={activePuzzle.clues}
|
||
collapsed={cluesCollapsed}
|
||
onToggleCollapsed={() => setCluesCollapsed((current) => !current)}
|
||
t={t}
|
||
/>
|
||
</div>
|
||
|
||
</aside>
|
||
</div>
|
||
|
||
{isFinished && shareOpen ? (
|
||
<ShareResult
|
||
dateKey={dateKey}
|
||
locale={locale}
|
||
onClose={() => setShareOpen(false)}
|
||
progress={progress}
|
||
quote={quote}
|
||
stats={stats}
|
||
t={t}
|
||
onOpenStats={() => setStatsOpen(true)}
|
||
sponsor={sponsor}
|
||
/>
|
||
) : null}
|
||
|
||
{statsOpen ? (
|
||
<div className="fixed inset-0 z-50 flex items-end bg-black/70 p-3 backdrop-blur-sm sm:items-center sm:justify-center">
|
||
<div className="w-full rounded-lg border border-white/10 bg-pitch-950 p-4 shadow-2xl sm:max-w-lg">
|
||
<div className="mb-3 flex items-center justify-between">
|
||
<h2 className="text-lg font-black text-white">{t.stats}</h2>
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={shareGlobalStats}
|
||
className="h-9 rounded-full bg-emerald-400 px-4 text-xs font-black uppercase tracking-wide text-pitch-950 transition hover:bg-emerald-300"
|
||
>
|
||
{t.shareNow}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setStatsOpen(false)}
|
||
className="h-9 w-9 rounded-full border border-white/15 text-lg font-black text-white transition hover:bg-white/10"
|
||
aria-label={t.closeModal}
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<StatsPanel showLead={false} stats={stats} t={t} />
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{feedbackOpen ? (
|
||
<FeedbackModal dateKey={dateKey} onClose={() => setFeedbackOpen(false)} t={t} />
|
||
) : null}
|
||
|
||
{onboardingOpen ? (
|
||
<OnboardingModal
|
||
onClose={() => {
|
||
window.localStorage.setItem("hidden11-onboarding-seen", "true");
|
||
setOnboardingOpen(false);
|
||
}}
|
||
onStart={startGame}
|
||
t={t}
|
||
/>
|
||
) : null}
|
||
|
||
<footer className="mt-8 flex flex-wrap items-center justify-center gap-2 pb-4 text-xs text-white/45">
|
||
<span className="mr-1 font-semibold uppercase">{t.language}</span>
|
||
{SUPPORTED_LOCALES.map((item) => (
|
||
<button
|
||
key={item.code}
|
||
type="button"
|
||
onClick={() => changeLocale(item.code)}
|
||
className={`rounded px-2 py-1 font-bold transition ${
|
||
locale === item.code
|
||
? "bg-white text-pitch-950"
|
||
: "border border-white/10 text-white/60 hover:bg-white/10 hover:text-white"
|
||
}`}
|
||
>
|
||
{item.label}
|
||
</button>
|
||
))}
|
||
<button
|
||
type="button"
|
||
onClick={() => setFeedbackOpen(true)}
|
||
className="rounded px-2 py-1 font-bold transition border border-white/10 text-white/60 hover:bg-white/10 hover:text-white"
|
||
>
|
||
{t.feedback}
|
||
</button>
|
||
</footer>
|
||
</div>
|
||
</main>
|
||
);
|
||
}
|