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

237 lines
9 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";
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, using local-timezone arithmetic. */
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")}`;
}
/** Find the first unsolved date after fromDate, skipping already-solved ones. */
function nextUnsolvedDate(game: string, fromDate: string): string {
const { solvedDates } = loadStats(game);
const solved = new Set(solvedDates ?? []);
let d = addOneDay(fromDate);
for (let i = 0; i < 365; i++) {
if (!solved.has(d)) return d;
d = addOneDay(d);
}
return d;
}
function ShareButton({ game, elapsed }: { game: string; elapsed: number }) {
const [copied, setCopied] = useState(false);
const handleShare = useCallback(async () => {
const text = `J'ai résolu le puzzle ${game} en ${fmt(elapsed)} sur Puzzle Trainer ! 🎉`;
const url = typeof window !== "undefined" ? window.location.href : "";
if (navigator.share) {
try {
await navigator.share({ text, url });
return;
} catch {
// user cancelled or not supported
}
}
// Fallback: copy to clipboard
try {
await navigator.clipboard.writeText(`${text}\n${url}`);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch { /* ignore */ }
}, [game, elapsed]);
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>
);
}
export default function WinBanner({ game, date, elapsed }: Props) {
const [stats, setStats] = useState<GameStats | null>(null);
const [isPersonalRecord, setIsPersonalRecord] = useState(false);
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 s = recordSolve(game, date, elapsed);
setStats(s);
// Personal record: had a previous best, and new time is better
if (prevStats.total > 0 && prevStats.bestTime > 0 && elapsed < prevStats.bestTime) {
setIsPersonalRecord(true);
}
}, [game, date, elapsed, isDailyDate]);
const displayStats = stats ?? loadStats(game);
const nextHref = isDailyDate
? `/${game}/${nextUnsolvedDate(game, 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`;
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>
{/* 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 */}
{isDailyDate && (
<ShareButton game={game} elapsed={elapsed} />
)}
</div>
{/* Navigation */}
<div className="flex items-center gap-3 flex-wrap justify-center">
{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>
</>
);
}