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