296 lines
11 KiB
TypeScript
296 lines
11 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useState, useCallback } from "react";
|
||
import Link from "next/link";
|
||
import Confetti from "./Confetti";
|
||
import { loadStats, recordSolve, GameStats } from "@/lib/stats";
|
||
import { GAME_META, GameId } from "@/lib/levels";
|
||
import { todayISO } from "@/lib/rng";
|
||
import { getNextSessionGame } from "@/lib/session";
|
||
|
||
interface Props {
|
||
game: string;
|
||
date: string;
|
||
elapsed: number;
|
||
}
|
||
|
||
function fmt(s: number) {
|
||
return `${String(Math.floor(s / 60)).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}`;
|
||
}
|
||
|
||
/** Add one calendar day to a YYYY-MM-DD string. */
|
||
function addOneDay(dateStr: string): string {
|
||
const [y, m, d] = dateStr.split("-").map(Number);
|
||
const next = new Date(y, m - 1, d + 1);
|
||
return `${next.getFullYear()}-${String(next.getMonth() + 1).padStart(2, "0")}-${String(next.getDate()).padStart(2, "0")}`;
|
||
}
|
||
|
||
/** Subtract one calendar day from a YYYY-MM-DD string. */
|
||
function subOneDay(dateStr: string): string {
|
||
const [y, m, d] = dateStr.split("-").map(Number);
|
||
const prev = new Date(y, m - 1, d - 1);
|
||
return `${prev.getFullYear()}-${String(prev.getMonth() + 1).padStart(2, "0")}-${String(prev.getDate()).padStart(2, "0")}`;
|
||
}
|
||
|
||
/** Haptic feedback (mobile) */
|
||
function haptic(pattern: number | number[] = 10) {
|
||
try { navigator.vibrate?.(pattern); } catch { /* ignore */ }
|
||
}
|
||
|
||
/** Build Wordle-style share text */
|
||
function buildShareText(game: string, date: string, elapsed: number, streak: number): string {
|
||
const gameMeta = (GAME_META as Record<string, { name: string; symbol: string }>)[game];
|
||
const name = gameMeta?.name ?? game;
|
||
const symbol = gameMeta?.symbol ?? "🧩";
|
||
const dateLabel = new Date(date + "T00:00:00").toLocaleDateString("fr-FR", {
|
||
day: "numeric", month: "long",
|
||
});
|
||
const streakLine = streak > 1 ? ` 🔥${streak}` : "";
|
||
return `Puzzle Trainer – ${dateLabel}\n${name} ${symbol} ✓ ${fmt(elapsed)}${streakLine}\nhttps://puzzles.reverdin.eu`;
|
||
}
|
||
|
||
function ShareButton({ game, date, elapsed, streak }: { game: string; date: string; elapsed: number; streak: number }) {
|
||
const [copied, setCopied] = useState(false);
|
||
|
||
const handleShare = useCallback(async () => {
|
||
haptic(10);
|
||
const text = buildShareText(game, date, elapsed, streak);
|
||
|
||
if (navigator.share) {
|
||
try {
|
||
await navigator.share({ text });
|
||
return;
|
||
} catch {
|
||
// user cancelled or not supported
|
||
}
|
||
}
|
||
try {
|
||
await navigator.clipboard.writeText(text);
|
||
setCopied(true);
|
||
setTimeout(() => setCopied(false), 2000);
|
||
} catch { /* ignore */ }
|
||
}, [game, date, elapsed, streak]);
|
||
|
||
return (
|
||
<button
|
||
onClick={handleShare}
|
||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold border border-green-300 text-green-700 hover:bg-green-100 transition-colors"
|
||
>
|
||
{copied ? (
|
||
<>
|
||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
|
||
<polyline points="20 6 9 17 4 12"/>
|
||
</svg>
|
||
Copié !
|
||
</>
|
||
) : (
|
||
<>
|
||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||
<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/>
|
||
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>
|
||
</svg>
|
||
Partager
|
||
</>
|
||
)}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function StatCard({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
|
||
return (
|
||
<div className="flex flex-col items-center gap-0.5 px-4 py-2.5 bg-white rounded-xl border border-green-100 min-w-[72px]">
|
||
<div className="text-green-600">{icon}</div>
|
||
<span className="text-[15px] font-bold text-gray-900 tabular-nums leading-none">{value}</span>
|
||
<span className="text-[10px] text-gray-400 uppercase tracking-wide">{label}</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// First-win congratulation messages
|
||
const FIRST_WIN_MESSAGES = [
|
||
"Excellent premier puzzle ! 🎉",
|
||
"Tu as le coup d'œil ! ✨",
|
||
"Impressionnant pour un début !",
|
||
"Premier de beaucoup d'autres ! 🌟",
|
||
];
|
||
|
||
export default function WinBanner({ game, date, elapsed }: Props) {
|
||
const [stats, setStats] = useState<GameStats | null>(null);
|
||
const [isPersonalRecord, setIsPersonalRecord] = useState(false);
|
||
const [isFirstWin, setIsFirstWin] = useState(false);
|
||
const [nextSessionGame, setNextSessionGame] = useState<GameId | null>(null);
|
||
const [firstWinMsg] = useState(() =>
|
||
FIRST_WIN_MESSAGES[Math.floor(Math.random() * FIRST_WIN_MESSAGES.length)]
|
||
);
|
||
|
||
const isDailyDate = /^\d{4}-\d{2}-\d{2}$/.test(date);
|
||
const levelMatch = date.match(/^level-\w+-(\d+)$/);
|
||
const currentLevel = levelMatch ? parseInt(levelMatch[1]) : null;
|
||
|
||
useEffect(() => {
|
||
if (!isDailyDate) return;
|
||
const prevStats = loadStats(game);
|
||
const isFirst = prevStats.total === 0;
|
||
setIsFirstWin(isFirst);
|
||
|
||
const s = recordSolve(game, date, elapsed);
|
||
setStats(s);
|
||
|
||
// Personal record: had a previous best, new time is better
|
||
if (!isFirst && prevStats.bestTime > 0 && elapsed < prevStats.bestTime) {
|
||
setIsPersonalRecord(true);
|
||
}
|
||
|
||
// Haptic celebration
|
||
haptic([30, 50, 30]);
|
||
|
||
// Session mode: find the next game
|
||
if (isDailyDate) {
|
||
setNextSessionGame(getNextSessionGame(game, date));
|
||
}
|
||
}, [game, date, elapsed, isDailyDate]);
|
||
|
||
const displayStats = stats ?? loadStats(game);
|
||
|
||
const nextHref = isDailyDate
|
||
? `/${game}/${addOneDay(date)}`
|
||
: currentLevel !== null
|
||
? `/${game}/level/${currentLevel + 1}`
|
||
: null;
|
||
const nextLabel = isDailyDate ? "Grille suivante" : `Niveau ${(currentLevel ?? 0) + 1}`;
|
||
|
||
const prevHref = isDailyDate
|
||
? `/${game}/${subOneDay(date)}`
|
||
: currentLevel !== null && currentLevel > 1
|
||
? `/${game}/level/${currentLevel - 1}`
|
||
: null;
|
||
const prevLabel = isDailyDate ? "Hier" : currentLevel !== null ? `Niveau ${currentLevel - 1}` : "";
|
||
|
||
const levelsHref = `/${game}/levels`;
|
||
const today = todayISO();
|
||
const isToday = date === today;
|
||
|
||
return (
|
||
<>
|
||
<Confetti />
|
||
|
||
{/* Main banner */}
|
||
<div className="win-banner w-full max-w-sm bg-gradient-to-b from-green-50 to-white border border-green-200 rounded-2xl px-6 py-5 flex flex-col items-center gap-3 shadow-[0_4px_16px_0_rgb(22_163_74/0.10)]">
|
||
|
||
{/* Trophy + title */}
|
||
<div className="flex items-center gap-2">
|
||
<svg width="22" height="22" viewBox="0 0 24 24" fill="#16a34a" className="shrink-0">
|
||
<path d="M19 3H5v2h14V3zM6 5v8a6 6 0 0012 0V5H6zm6 11a4 4 0 01-4-4V7h8v5a4 4 0 01-4 4zm-6 2h12v2H6v-2z"/>
|
||
</svg>
|
||
<span className="text-green-800 font-bold text-lg tracking-tight">Résolu !</span>
|
||
{isPersonalRecord && (
|
||
<span className="text-[10px] font-bold bg-amber-400 text-white px-1.5 py-0.5 rounded-full uppercase tracking-wide">
|
||
PR
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{/* First-win message */}
|
||
{isFirstWin && (
|
||
<p className="text-sm text-green-700 font-medium -mt-1">{firstWinMsg}</p>
|
||
)}
|
||
|
||
{/* Time */}
|
||
<div className="text-4xl font-black text-green-700 tabular-nums tracking-tight timer-mono">
|
||
{fmt(elapsed)}
|
||
</div>
|
||
|
||
{/* Stats row (daily only) */}
|
||
{isDailyDate && (
|
||
<div className="flex gap-2 mt-1">
|
||
<StatCard
|
||
icon={
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z" fill="#dcfce7" stroke="#16a34a"/>
|
||
<polyline points="12 6 12 12 16 14" stroke="#16a34a"/>
|
||
</svg>
|
||
}
|
||
label="Meilleur"
|
||
value={displayStats.bestTime > 0 ? fmt(displayStats.bestTime) : "--:--"}
|
||
/>
|
||
<StatCard
|
||
icon={
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#16a34a" strokeWidth={2} strokeLinecap="round">
|
||
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/>
|
||
</svg>
|
||
}
|
||
label="Série"
|
||
value={`${displayStats.streak}j`}
|
||
/>
|
||
<StatCard
|
||
icon={
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#16a34a" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||
<polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/>
|
||
</svg>
|
||
}
|
||
label="Total"
|
||
value={String(displayStats.total)}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* Share button (daily + today only) */}
|
||
{isDailyDate && isToday && (
|
||
<ShareButton game={game} date={date} elapsed={elapsed} streak={displayStats.streak} />
|
||
)}
|
||
</div>
|
||
|
||
{/* Navigation */}
|
||
<div className="flex items-center gap-3 flex-wrap justify-center">
|
||
|
||
{/* Session mode: next game button takes priority */}
|
||
{nextSessionGame ? (
|
||
<>
|
||
<Link
|
||
href={`/${nextSessionGame}`}
|
||
className="flex items-center gap-2 px-6 py-2.5 rounded-full text-white text-sm font-bold hover:opacity-90 transition-opacity"
|
||
style={{ background: GAME_META[nextSessionGame].accent }}
|
||
>
|
||
{GAME_META[nextSessionGame].symbol} {GAME_META[nextSessionGame].name}
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round"><polyline points="9 18 15 12 9 6"/></svg>
|
||
</Link>
|
||
<Link href="/" className="px-4 py-2 rounded-full border border-gray-200 text-gray-500 text-sm hover:border-gray-300 hover:text-gray-700 transition-colors">
|
||
Accueil
|
||
</Link>
|
||
</>
|
||
) : (
|
||
<>
|
||
{prevHref && (
|
||
<Link
|
||
href={prevHref}
|
||
className="flex items-center gap-1 px-4 py-2 rounded-full border border-gray-200 text-gray-500 text-sm hover:border-gray-300 hover:text-gray-700 transition-colors"
|
||
>
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round"><polyline points="15 18 9 12 15 6"/></svg>
|
||
{prevLabel}
|
||
</Link>
|
||
)}
|
||
|
||
{nextHref && (
|
||
<Link
|
||
href={nextHref}
|
||
className="flex items-center gap-1 px-5 py-2 rounded-full bg-gray-900 text-white text-sm font-semibold hover:bg-gray-700 transition-colors"
|
||
>
|
||
{nextLabel}
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round"><polyline points="9 18 15 12 9 6"/></svg>
|
||
</Link>
|
||
)}
|
||
|
||
{isDailyDate && (
|
||
<Link
|
||
href={levelsHref}
|
||
className="px-4 py-2 rounded-full border border-gray-200 text-gray-500 text-sm hover:border-gray-300 hover:text-gray-700 transition-colors"
|
||
>
|
||
Entraînement
|
||
</Link>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
</>
|
||
);
|
||
}
|