nerdware/apps/web/src/pages/home.tsx
AyoubBenziza e0ba4c2e23 feat(web): "What's new" patch notes modal
- curated, bilingual player-facing notes (lib/patch-notes.ts) — a public subset
  of CHANGELOG.md (which stays the technical source of truth)
- "Nouveautés" button on home with a dot badge when the app version changed
  since last seen (localStorage); opening marks the version seen
- app version injected at build via Vite define (__APP_VERSION__)
- add shadcn Dialog component (@workspace/ui, via radix-ui)

Verified: notes + version baked into the build; typecheck/lint/build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 09:17:52 +02:00

162 lines
5.5 KiB
TypeScript

import { useState } from "react"
import { useLocation } from "wouter"
import { motion } from "framer-motion"
import { LogIn, Sparkles } from "lucide-react"
import { Button } from "@workspace/ui/components/button"
import { useRoomStore } from "@/store/room"
import { useI18n } from "@/i18n/context"
import { errorMessage } from "@/i18n/error"
import { LanguageToggle } from "@/i18n/language-toggle"
import { ThemeToggle } from "@/components/theme-toggle"
import { WhatsNew } from "@/components/whats-new"
const inputClass =
"border-input bg-background ring-offset-background focus-visible:ring-ring h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50"
// Emojis geek qui flottent en fond.
const FLOATERS = [
{ emoji: "🎮", x: "8%", y: "18%", delay: 0 },
{ emoji: "🎧", x: "82%", y: "22%", delay: 0.6 },
{ emoji: "🧠", x: "15%", y: "72%", delay: 1.2 },
{ emoji: "👾", x: "78%", y: "70%", delay: 0.3 },
{ emoji: "🕹️", x: "45%", y: "12%", delay: 0.9 },
{ emoji: "🎬", x: "60%", y: "82%", delay: 1.5 },
]
export function HomePage() {
const [, navigate] = useLocation()
const { t } = useI18n()
const createRoom = useRoomStore((s) => s.createRoom)
const joinRoom = useRoomStore((s) => s.joinRoom)
const connected = useRoomStore((s) => s.connected)
const [code, setCode] = useState("")
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
async function handleCreate() {
setError(null)
setBusy(true)
try {
const { roomCode } = await createRoom()
navigate(`/room/${roomCode}`)
} catch (err) {
setError(errorMessage(t, err))
} finally {
setBusy(false)
}
}
async function handleJoin() {
setError(null)
setBusy(true)
try {
const { roomCode } = await joinRoom(code.trim().toUpperCase())
navigate(`/room/${roomCode}`)
} catch (err) {
setError(errorMessage(t, err))
} finally {
setBusy(false)
}
}
return (
<div className="relative flex min-h-svh items-center justify-center overflow-hidden p-6">
<div className="absolute top-4 right-4 z-10 flex items-center gap-2">
<WhatsNew />
<ThemeToggle />
<LanguageToggle />
</div>
{/* Halo animé */}
<motion.div
aria-hidden
className="pointer-events-none absolute -z-10 size-[120vmin] rounded-full bg-gradient-to-br from-fuchsia-500/20 via-purple-500/10 to-cyan-400/20 blur-3xl"
animate={{ scale: [1, 1.15, 1], rotate: [0, 30, 0] }}
transition={{ duration: 18, repeat: Infinity, ease: "easeInOut" }}
/>
{/* Emojis flottants */}
{FLOATERS.map((f) => (
<motion.span
key={f.emoji}
aria-hidden
className="pointer-events-none absolute -z-10 text-4xl opacity-40 select-none sm:text-5xl"
style={{ left: f.x, top: f.y }}
animate={{ y: [0, -18, 0], rotate: [-8, 8, -8] }}
transition={{
duration: 5,
repeat: Infinity,
ease: "easeInOut",
delay: f.delay,
}}
>
{f.emoji}
</motion.span>
))}
<motion.div
className="flex w-full max-w-sm flex-col gap-6"
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, ease: "easeOut" }}
>
<header className="text-center">
<motion.h1
className="font-heading bg-gradient-to-r from-fuchsia-500 via-purple-500 to-cyan-400 bg-clip-text text-5xl font-black tracking-tight text-transparent"
initial={{ scale: 0.6, rotate: -6, opacity: 0 }}
animate={{ scale: 1, rotate: 0, opacity: 1 }}
transition={{ type: "spring", stiffness: 260, damping: 14 }}
>
NerdWare
</motion.h1>
<p className="text-muted-foreground mt-1 flex items-center justify-center gap-1 text-sm">
<Sparkles className="size-3.5" /> {t.home.tagline}
</p>
</header>
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
<Button
size="lg"
className="w-full"
disabled={!connected || busy}
onClick={handleCreate}
>
<Sparkles /> {t.home.create}
</Button>
</motion.div>
<div className="flex items-center gap-2">
<div className="bg-border h-px flex-1" />
<span className="text-muted-foreground text-xs">{t.home.orJoin}</span>
<div className="bg-border h-px flex-1" />
</div>
<div className="flex gap-2">
<input
className={`${inputClass} text-center text-lg font-bold tracking-[0.3em] uppercase`}
placeholder={t.home.codePlaceholder}
value={code}
maxLength={4}
onChange={(e) => setCode(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && code.trim() && handleJoin()}
/>
<Button
variant="secondary"
disabled={!connected || busy || code.trim().length === 0}
onClick={handleJoin}
>
<LogIn /> {t.home.join}
</Button>
</div>
{!connected && (
<p className="text-muted-foreground text-center text-xs">
{t.home.connecting}
</p>
)}
{error && (
<p className="text-destructive text-center text-sm">{error}</p>
)}
</motion.div>
</div>
)
}