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%
9.1 KB · 195 lines tsx
Raw Blame History
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