TypeScript 97.6%
SQL 1.4%
JavaScript 0.5%
1"use client";23import { useMemo, useState, useEffect } from "react";4import { useRouter, useSearchParams } from "next/navigation";5import { Search, X, Heart } from "lucide-react";6import type { GameCard as GameCardData } from "@spinza/shared";7import { useSession } from "@/lib/store";8import { cn } from "@/lib/utils";9import { Button, Empty } from "@/components/ui";10import { GameCard } from "./game-card";1112const FILTERS = [13 { key: "all", label: "All" },14 { key: "featured", label: "Featured" },15 { key: "new", label: "New" },16 { key: "high", label: "High volatility" },17 { key: "relaxed", label: "Relaxed" },18 { key: "jackpot", label: "Jackpot" },19 { key: "risk", label: "Risk · Cash out" },20 { key: "originals", label: "Beyond Slots" },21 { key: "favourites", label: "Favourites" },22] as const;23type FilterKey = (typeof FILTERS)[number]["key"];2425const isFilter = (v: string | null): v is FilterKey => !!v && FILTERS.some((f) => f.key === v);2627export function GamesBrowser({ games }: { games: GameCardData[] }) {28 const params = useSearchParams();29 const router = useRouter();30 const status = useSession((s) => s.status);31 const [filter, setFilter] = useState<FilterKey>(isFilter(params.get("filter")) ? (params.get("filter") as FilterKey) : "all");32 const [query, setQuery] = useState(params.get("q") ?? "");33 const [favs, setFavs] = useState<Record<string, boolean>>(() => Object.fromEntries(games.map((g) => [g.slug, !!g.favorite])));3435 useEffect(() => {36 const p = new URLSearchParams();37 if (filter !== "all") p.set("filter", filter);38 if (query.trim()) p.set("q", query.trim());39 const qs = p.toString();40 router.replace(qs ? `/games?${qs}` : "/games", { scroll: false });41 }, [filter, query, router]);4243 const list = useMemo(() => {44 const q = query.trim().toLowerCase();45 return games46 .filter((g) => {47 switch (filter) {48 case "featured":49 return g.isFeatured;50 case "new":51 return g.isNew;52 case "high":53 return g.volatility === "high" || g.volatility === "extreme";54 case "relaxed":55 return g.volatility === "low" || g.volatility === "medium";56 case "jackpot":57 return g.isJackpot;58 case "risk":59 return g.kind === "crash";60 case "originals":61 return g.kind === "arcade";62 case "favourites":63 return favs[g.slug];64 default:65 return true;66 }67 })68 .filter((g) => !q || g.name.toLowerCase().includes(q) || g.tagline.toLowerCase().includes(q) || g.theme.toLowerCase().includes(q) || g.tags.some((t) => t.includes(q)) || g.features.some((f) => f.toLowerCase().includes(q)));69 }, [games, filter, query, favs]);7071 const counts = useMemo<Record<FilterKey, number>>(72 () => ({73 all: games.length,74 featured: games.filter((g) => g.isFeatured).length,75 new: games.filter((g) => g.isNew).length,76 high: games.filter((g) => g.volatility === "high" || g.volatility === "extreme").length,77 relaxed: games.filter((g) => g.volatility === "low" || g.volatility === "medium").length,78 jackpot: games.filter((g) => g.isJackpot).length,79 risk: games.filter((g) => g.kind === "crash").length,80 originals: games.filter((g) => g.kind === "arcade").length,81 favourites: Object.values(favs).filter(Boolean).length,82 }),83 [games, favs],84 );8586 return (87 <div className="space-y-6">88 <div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">89 <div className="snap-row -mx-4 px-4 sm:mx-0 sm:px-0 sm:flex-wrap" role="tablist" aria-label="Filter games">90 {FILTERS.map((f) => (91 <button key={f.key} role="tab" aria-selected={filter === f.key} onClick={() => setFilter(f.key)} className={cn("tap inline-flex h-10 min-w-0 items-center gap-1.5 whitespace-nowrap rounded-full border px-4 text-[13px] font-semibold transition-colors focus-ring", filter === f.key ? "border-accent/50 bg-accent-soft text-accent-2" : "border-line text-fg-3 hover:border-line-2 hover:text-fg")}>92 {f.key === "favourites" ? <Heart className={cn("h-3.5 w-3.5", filter === f.key && "fill-current")} /> : null}93 {f.label}94 <span className="tabular text-[11px] opacity-70">{counts[f.key]}</span>95 </button>96 ))}97 </div>98 <label className="relative block lg:w-72">99 <Search className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-fg-3" />100 <input type="search" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search games, features…" aria-label="Search games" className="h-11 w-full rounded-full border border-line-2 bg-bg-1 pl-10 pr-10 text-[15px] placeholder:text-fg-4 focus-ring focus:border-accent/60" />101 {query ? (102 <button type="button" onClick={() => setQuery("")} className="absolute right-2 top-1/2 grid h-8 w-8 -translate-y-1/2 place-items-center rounded-full text-fg-3 hover:bg-surface-2 hover:text-fg" aria-label="Clear search">103 <X className="h-4 w-4" />104 </button>105 ) : null}106 </label>107 </div>108109 {filter === "favourites" && status !== "authenticated" ? (110 <Empty title="Sign in to keep favourites" description="Tap the heart on any game to pin it here. Favourites sync across your devices." icon={<Heart className="h-5 w-5" />} action={<Button href="/login?next=%2Fgames%3Ffilter%3Dfavourites">Sign in</Button>} />111 ) : list.length ? (112 <div className="grid grid-cols-2 gap-x-3 gap-y-6 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">113 {list.map((g) => (114 <GameCard key={g.slug} game={{ ...g, favorite: favs[g.slug] }} className="w-full sm:w-full" onFavoriteChange={(slug, fav) => setFavs((f) => ({ ...f, [slug]: fav }))} />115 ))}116 </div>117 ) : (118 <Empty119 title={filter === "favourites" ? "No favourites yet" : query ? `No games match “${query}”` : "Nothing here yet"}120 description={filter === "favourites" ? "Tap the heart on any game to pin it here." : query ? "Try a feature name like “cascades”, “jackpot” or “free spins”." : "New titles are being certified and will appear here soon."}121 icon={filter === "favourites" ? <Heart className="h-5 w-5" /> : <Search className="h-5 w-5" />}122 action={123 <Button124 variant="secondary"125 onClick={() => {126 setFilter("all");127 setQuery("");128 }}129 >130 Show all games131 </Button>132 }133 />134 )}135 </div>136 );137}138