puzzle-trainer/components/WinBanner.tsx
2026-05-23 01:05:21 +00:00

296 lines
11 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, 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>
</>
);
}