TypeScript 97.6%
SQL 1.4%
JavaScript 0.5%
1"use client";23import * as React from "react";4import Link from "next/link";5import { useRouter } from "next/navigation";6import { Heart, Play, ChevronRight } from "lucide-react";7import type { GameCard as GameCardData } from "@spinza/shared";8import { api } from "@/lib/api";9import { toast, useSession } from "@/lib/store";10import { cn, VOLATILITY_BARS, VOLATILITY_LABEL } from "@/lib/utils";11import { Badge, Skeleton } from "@/components/ui";12import { GameArt } from "./game-art";1314/* ------------------------------------------------------------- favourites */1516function useFavorite(game: GameCardData, onChange?: (slug: string, fav: boolean) => void) {17 const status = useSession((s) => s.status);18 const router = useRouter();19 const [fav, setFav] = React.useState<boolean>(!!game.favorite);20 const [busy, setBusy] = React.useState(false);21 // Keep in sync when the parent re-renders with fresh server data (adjust-state-from-props pattern).22 const [seen, setSeen] = React.useState(!!game.favorite);23 if (!!game.favorite !== seen) {24 setSeen(!!game.favorite);25 setFav(!!game.favorite);26 }2728 const toggle = async (e: React.MouseEvent) => {29 e.preventDefault();30 e.stopPropagation();31 if (status !== "authenticated") {32 router.push(`/login?next=${encodeURIComponent(window.location.pathname)}`);33 return;34 }35 if (busy) return;36 const next = !fav;37 setFav(next);38 onChange?.(game.slug, next);39 setBusy(true);40 try {41 const res = await api<{ favorite: boolean }>(`/api/games/${game.slug}/favorite`, { method: "POST" });42 setFav(res.favorite);43 onChange?.(game.slug, res.favorite);44 } catch (err) {45 setFav(!next);46 onChange?.(game.slug, !next);47 toast({ title: "Could not update favourites", description: err instanceof Error ? err.message : undefined, tone: "danger" });48 } finally {49 setBusy(false);50 }51 };52 return { fav, toggle };53}5455function HeartButton({ fav, onClick, className }: { fav: boolean; onClick: (e: React.MouseEvent) => void; className?: string }) {56 return (57 <button type="button" onClick={onClick} aria-pressed={fav} aria-label={fav ? "Remove from favourites" : "Add to favourites"} className={cn("tap grid place-items-center rounded-full glass text-fg-2 transition-all hover:text-fg active:scale-90 focus-ring", fav && "text-[#ff5c7a]", className)}>58 <Heart className={cn("h-[18px] w-[18px] transition-transform", fav && "fill-current scale-110")} />59 </button>60 );61}6263/* -------------------------------------------------------------- volatility */6465export function VolatilityMeter({ volatility, className, showLabel = true }: { volatility: string; className?: string; showLabel?: boolean }) {66 const bars = VOLATILITY_BARS[volatility] ?? 2;67 return (68 <span className={cn("inline-flex items-center gap-1.5 text-[11px] font-medium text-fg-3", className)} title={`${VOLATILITY_LABEL[volatility] ?? volatility} volatility`}>69 <span className="flex items-end gap-[2px]" aria-hidden>70 {[1, 2, 3, 4].map((i) => (71 <span key={i} className={cn("w-[3px] rounded-[1px]", i <= bars ? "bg-accent" : "bg-fg-4/60")} style={{ height: 4 + i * 2 }} />72 ))}73 </span>74 {showLabel ? <span>{VOLATILITY_LABEL[volatility] ?? volatility}</span> : null}75 </span>76 );77}7879/* ---------------------------------------------------------------- GameCard */8081export interface GameCardProps {82 game: GameCardData;83 className?: string;84 onFavoriteChange?: (slug: string, fav: boolean) => void;85 priority?: boolean;86}8788export function GameCard({ game, className, onFavoriteChange }: GameCardProps) {89 const { fav, toggle } = useFavorite(game, onFavoriteChange);90 return (91 <article className={cn("group relative flex w-[164px] flex-col sm:w-[200px]", className)}>92 <Link href={`/games/${game.slug}`} className="relative block overflow-hidden rounded-lg border border-line bg-bg-1 shadow-card transition-transform duration-300 ease-out-expo focus-ring group-hover:-translate-y-1 group-hover:border-line-2" aria-label={`Play ${game.name}`}>93 <div className="aspect-[3/4]">94 <GameArt slug={game.slug} name={game.name} palette={game.palette} variant="card" />95 </div>96 <div className="absolute inset-x-2 top-2 flex items-start justify-between gap-2">97 <div className="flex flex-wrap gap-1">98 {game.isNew ? <Badge tone="new">New</Badge> : null}99 {game.isJackpot ? <Badge tone="accent">Jackpot</Badge> : null}100 </div>101 </div>102 <div className="pointer-events-none absolute inset-0 hidden items-center justify-center bg-black/35 opacity-0 transition-opacity duration-300 group-hover:opacity-100 sm:flex">103 <span className="grid h-14 w-14 place-items-center rounded-full bg-fg text-bg shadow-glow">104 <Play className="ml-0.5 h-6 w-6 fill-current" />105 </span>106 </div>107 </Link>108 <HeartButton fav={fav} onClick={toggle} className="absolute right-2 top-2 h-9 w-9 min-h-0 min-w-0" />109 <div className="mt-2.5 flex items-start justify-between gap-2 px-0.5">110 <div className="min-w-0">111 <Link href={`/games/${game.slug}`} className="block truncate text-[15px] font-semibold tracking-tight hover:text-accent-2">112 {game.name}113 </Link>114 <p className="truncate text-[12px] text-fg-3">{game.tagline}</p>115 </div>116 </div>117 <div className="mt-1.5 flex items-center justify-between px-0.5">118 <VolatilityMeter volatility={game.volatility} />119 <Link href={`/games/${game.slug}`} className="tap -mr-2 inline-flex h-9 min-h-0 items-center gap-1 rounded-sm px-2 text-[13px] font-semibold text-fg-2 hover:text-fg focus-ring">120 Play <Play className="h-3.5 w-3.5 fill-current" />121 </Link>122 </div>123 </article>124 );125}126127export function GameCardSkeleton({ className }: { className?: string }) {128 return (129 <div className={cn("w-[164px] sm:w-[200px]", className)} aria-hidden>130 <Skeleton className="aspect-[3/4] w-full rounded-lg" />131 <Skeleton className="mt-3 h-4 w-3/4" />132 <Skeleton className="mt-2 h-3 w-1/2" />133 </div>134 );135}136137/* ----------------------------------------------------------------- GameRow */138139export function GameRow({ title, eyebrow, games, href, className, id }: { title: string; eyebrow?: string; games: GameCardData[]; href?: string; className?: string; id?: string }) {140 if (!games.length) return null;141 return (142 <section className={cn("relative", className)} id={id} aria-labelledby={id ? `${id}-title` : undefined}>143 <div className="mb-3 flex items-end justify-between gap-4">144 <div>145 {eyebrow ? <div className="eyebrow mb-1">{eyebrow}</div> : null}146 <h2 id={id ? `${id}-title` : undefined} className="text-xl font-semibold tracking-tight sm:text-2xl">147 {title}148 </h2>149 </div>150 {href ? (151 <Link href={href} className="tap inline-flex h-9 min-h-0 items-center gap-0.5 text-sm font-medium text-fg-3 hover:text-fg">152 See all <ChevronRight className="h-4 w-4" />153 </Link>154 ) : null}155 </div>156 <div className="snap-row -mx-4 px-4 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8">157 {games.map((g) => (158 <GameCard key={g.slug} game={g} />159 ))}160 </div>161 </section>162 );163}164165/* ---------------------------------------------------------------- GameTile */166167/** Compact row tile for dense lists (continue playing, search results). */168export function GameTile({ game, meta, className, onFavoriteChange }: { game: GameCardData; meta?: React.ReactNode; className?: string; onFavoriteChange?: (slug: string, fav: boolean) => void }) {169 const { fav, toggle } = useFavorite(game, onFavoriteChange);170 return (171 <div className={cn("surface relative flex items-center gap-3 rounded-md p-2 pr-3 transition-colors hover:bg-surface-2", className)}>172 <Link href={`/games/${game.slug}`} className="flex min-w-0 flex-1 items-center gap-3 focus-ring rounded-sm" aria-label={`Play ${game.name}`}>173 <div className="h-14 w-14 shrink-0 overflow-hidden rounded-sm border border-line">174 <GameArt slug={game.slug} name={game.name} palette={game.palette} variant="tile" showName={false} />175 </div>176 <div className="min-w-0 flex-1">177 <div className="flex items-center gap-2">178 <span className="truncate text-[15px] font-semibold tracking-tight">{game.name}</span>179 {game.isNew ? <Badge tone="new">New</Badge> : null}180 {game.isJackpot ? <Badge tone="accent">Jackpot</Badge> : null}181 </div>182 <div className="mt-0.5 flex items-center gap-2 text-[12px] text-fg-3">183 <VolatilityMeter volatility={game.volatility} />184 {meta ? <span className="truncate">· {meta}</span> : null}185 </div>186 </div>187 </Link>188 <HeartButton fav={fav} onClick={toggle} className="h-10 w-10 min-h-0 min-w-0" />189 <Link href={`/games/${game.slug}`} className="tap grid h-10 w-10 min-h-0 min-w-0 place-items-center rounded-full bg-fg text-bg focus-ring" aria-label={`Play ${game.name}`}>190 <Play className="ml-0.5 h-4 w-4 fill-current" />191 </Link>192 </div>193 );194}195