SPB Git forge

spb/spinza

Public
8commits 1branches 0releases
1.6 MBsize
maindefault branch
16 days agolast push
TypeScript 97.6% SQL 1.4% JavaScript 0.5%
5.8 KB · 104 lines tsx
Raw Blame History
1"use client";23import { useEffect, useState } from "react";4import Link from "next/link";5import { useRouter, useSearchParams } from "next/navigation";6import { AnimatePresence, animate, motion } from "framer-motion";7import { X, Play } from "lucide-react";8import type { GameCard as GameCardData } from "@spinza/shared";9import { STARTING_BALANCE } from "@spinza/shared";10import { useSession } from "@/lib/store";11import { Button } from "@/components/ui";12import { GameArt } from "./game-art";13import { VolatilityMeter } from "./game-card";1415/** First-time experience shown after registration (`/?welcome=1`). */16export function WelcomeOverlay({ picks }: { picks: GameCardData[] }) {17  const params = useSearchParams();18  const router = useRouter();19  const user = useSession((s) => s.user);20  const reduceMotion = useSession((s) => s.settings?.reduceMotion) ?? false;21  const [open, setOpen] = useState(params.get("welcome") === "1");22  const [phase, setPhase] = useState<"credits" | "choose">("credits");23  const [count, setCount] = useState(0);2425  useEffect(() => {26    if (!open) return;27    document.body.style.overflow = "hidden";28    const ctrl = animate(0, STARTING_BALANCE, { duration: reduceMotion ? 0.2 : 1.8, ease: [0.16, 1, 0.3, 1], onUpdate: (v) => setCount(Math.round(v)) });29    const t = setTimeout(() => setPhase("choose"), reduceMotion ? 600 : 2600);30    return () => {31      ctrl.stop();32      clearTimeout(t);33      document.body.style.overflow = "";34    };35  }, [open, reduceMotion]);3637  const dismiss = () => {38    setOpen(false);39    router.replace("/", { scroll: false });40  };4142  return (43    <AnimatePresence>44      {open ? (45        <motion.div className="fixed inset-0 flex flex-col overflow-y-auto bg-bg/95 backdrop-blur-xl" style={{ zIndex: "var(--z-modal)" }} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} role="dialog" aria-modal aria-labelledby="welcome-title">46          <div className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_50%_30%,rgba(139,92,246,0.22),transparent_55%)]" />47          <button onClick={dismiss} className="tap absolute right-4 top-4 grid place-items-center rounded-full glass text-fg-2 hover:text-fg focus-ring" style={{ marginTop: "var(--safe-top)" }} aria-label="Skip">48            <X className="h-5 w-5" />49          </button>50          <div className="page relative flex flex-1 flex-col items-center justify-center py-16 text-center">51            <motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1] }}>52              <div className="eyebrow mb-3 text-accent">{user ? `@${user.username}` : "New player"}</div>53              <h1 id="welcome-title" className="text-4xl font-semibold tracking-[-0.04em] sm:text-6xl">54                Welcome to Spinza.55              </h1>56            </motion.div>57            <motion.div className="mt-8" initial={{ opacity: 0, scale: 0.9 }} animate={{ opacity: 1, scale: 1 }} transition={{ delay: 0.3, duration: 0.7, ease: [0.16, 1, 0.3, 1] }}>58              <div className="text-[64px] font-semibold leading-none tracking-[-0.05em] text-credit tabular sm:text-[96px]" aria-live="polite">59                +{count.toLocaleString("en-US")}60                <span className="ml-2 text-[0.35em] font-bold tracking-wider text-credit/70">SC</span>61              </div>62              <p className="mt-3 text-sm text-fg-3">Your starting Spinza Credits. Fictional, free, and refilled every day you come back.</p>63            </motion.div>6465            <AnimatePresence>66              {phase === "choose" ? (67                <motion.div className="mt-12 w-full max-w-3xl" initial={{ opacity: 0, y: 24 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.7, ease: [0.16, 1, 0.3, 1] }}>68                  <h2 className="text-xl font-semibold tracking-tight sm:text-2xl">Choose your first game</h2>69                  <p className="mt-1 text-sm text-fg-3">Three picks to start with. You can always come back to the lobby.</p>70                  <div className="mt-6 grid grid-cols-3 gap-2 sm:gap-3">71                    {picks.map((g, i) => (72                      <motion.div key={g.slug} initial={{ opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.1 + i * 0.1, duration: 0.5, ease: [0.16, 1, 0.3, 1] }}>73                        <Link href={`/games/${g.slug}`} onClick={() => setOpen(false)} className="group block overflow-hidden rounded-lg border border-line bg-bg-1 text-left shadow-card transition-transform hover:-translate-y-1 focus-ring" aria-label={`Play ${g.name}`}>74                          <div className="aspect-[3/4]">75                            <GameArt slug={g.slug} name={g.name} palette={g.palette} variant="card" />76                          </div>77                          <div className="flex items-center justify-between gap-2 p-2 sm:p-3">78                            <div className="min-w-0">79                              <div className="hidden truncate text-[12px] text-fg-3 sm:block">{g.tagline}</div>80                              <VolatilityMeter volatility={g.volatility} className="sm:mt-1" />81                            </div>82                            <span className="hidden h-9 w-9 shrink-0 place-items-center rounded-full bg-fg text-bg sm:grid">83                              <Play className="ml-0.5 h-4 w-4 fill-current" />84                            </span>85                          </div>86                        </Link>87                      </motion.div>88                    ))}89                  </div>90                  <div className="mt-8">91                    <Button variant="ghost" onClick={dismiss}>92                      Browse the whole lobby instead93                    </Button>94                  </div>95                </motion.div>96              ) : null}97            </AnimatePresence>98          </div>99        </motion.div>100      ) : null}101    </AnimatePresence>102  );103}104