237 lines
9 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|