Files
alexandrev-tibco f784c9c722
Build and push image / build (push) Successful in 1m19s
Reveal failed puzzles and improve clue coverage
2026-05-07 11:53:14 +02:00

535 lines
20 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}