"use client"; import { useCallback, useEffect, useMemo, useState, } from "react"; import Link from "next/link"; type BuildClass = "Rifle" | "Pistol" | "NFA"; type Caliber = string; type BuildCard = { id: string; title: string; slug: string; creator: string; caliber: Caliber; buildClass: BuildClass; price: number; votes: number; tags: string[]; coverImageUrl?: string | null; }; const CLASS_FILTERS: (BuildClass | "all")[] = ["all", "Rifle", "Pistol", "NFA"]; const PRICE_FILTERS = [ { id: "all", label: "All", min: 0, max: Infinity }, { id: "sub1k", label: "< $1k", min: 0, max: 1000_00 }, { id: "1to2k", label: "$1k–$2k", min: 1000_00, max: 2000_00 }, { id: "2to3k", label: "$2k–$3k", min: 2000_00, max: 3000_00 }, { id: "3kplus", label: "$3k+", min: 3000_00, max: Infinity }, ]; const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? ""; interface BuildsClientProps { initialBuilds: BuildCard[]; initialError: string | null; } /** * Client-side component that keeps all the previous interactivity: * - filters * - optimistic votes * * It receives the builds from the server but can optionally re-fetch on mount * if you want truly live data (kept here as an example but can be removed). */ export default function BuildsClient({ initialBuilds, initialError, }: BuildsClientProps) { // --- Remote data state (initialized from server) --- const [loading, setLoading] = useState(false); const [error, setError] = useState(initialError); const [builds, setBuilds] = useState(initialBuilds); // --- UI state (filters/votes) --- const [caliberFilter, setCaliberFilter] = useState("all"); const [classFilter, setClassFilter] = useState("all"); const [priceFilterId, setPriceFilterId] = useState("all"); const [votes, setVotes] = useState>(() => Object.fromEntries(initialBuilds.map((b) => [b.id, b.votes])), ); const activePriceFilter = PRICE_FILTERS.find((p) => p.id === priceFilterId) ?? PRICE_FILTERS[0]; // Optional: client re-fetch to keep data fresh (can be removed if not needed) useEffect(() => { // If you don't want client refetching, just return early here. if (!API_BASE_URL) return; let cancelled = false; async function run() { try { setLoading(true); setError(null); const res = await fetch(`${API_BASE_URL}/api/v1/builds?limit=50`, { method: "GET", credentials: "include", headers: { "Content-Type": "application/json" }, }); if (!res.ok) { const txt = await res.text().catch(() => ""); throw new Error(txt || `Failed to load builds (${res.status})`); } const data = (await res.json()) as BuildCard[]; if (cancelled) return; setBuilds(data); setVotes(Object.fromEntries(data.map((b) => [b.id, b.votes]))); } catch (e: any) { if (!cancelled) setError(e?.message || "Failed to load builds feed"); } finally { if (!cancelled) setLoading(false); } } // Comment this out if you want *only* server-side fetching: // run(); return () => { cancelled = true; }; }, []); const CALIBER_FILTERS = useMemo<(Caliber | "all")[]>(() => { const uniq = new Set(); for (const b of builds) { const c = (b.caliber ?? "").trim(); if (c && c !== "—") uniq.add(c); } return ["all", ...Array.from(uniq).sort()]; }, [builds]); const filteredBuilds = useMemo(() => { return builds.filter((build) => { const matchesCaliber = caliberFilter === "all" || build.caliber === caliberFilter; const matchesClass = classFilter === "all" || build.buildClass === classFilter; const matchesPrice = build.price >= activePriceFilter.min && build.price < activePriceFilter.max; return matchesCaliber && matchesClass && matchesPrice; }); }, [builds, caliberFilter, classFilter, activePriceFilter]); const handleVote = useCallback((id: string, delta: 1 | -1) => { setVotes((prev) => ({ ...prev, [id]: (prev[id] ?? 0) + delta, })); }, []); return ( <> {/* Loading / Error (uses same UI as before) */} {loading && (
Loading community builds…
)} {error && !loading && (
{error}
Dev tip: confirm backend route{" "} GET /api/v1/builds is implemented + CORS is configured.
)} {/* Filters */}

Filter Builds

Filter by caliber, class, and price range. (Client-side for MVP.)

Showing{" "} {filteredBuilds.length} {" "} of{" "} {builds.length}{" "} builds
{/* Caliber filter */}
Caliber
{CALIBER_FILTERS.map((caliber) => ( ))}
{/* Class filter */}
Class
{CLASS_FILTERS.map((cls) => ( ))}
{/* Price filter */}
Price Range
{PRICE_FILTERS.map((range) => ( ))}
{/* Build list */}
{filteredBuilds.map((build) => (
{/* Votes column */}
{votes[build.id] ?? build.votes}
{/* Thumbnail */}
{/* eslint-disable-next-line @next/next/no-img-element */} {`${build.title}
{/* Main content */}
{build.buildClass} · {build.caliber}

{build.title}

Posted by{" "} b/{build.creator}{" "} · Build detail page will show the parts list + comments.

Est. Build Cost
{build.price > 0 ? ( {`$${Math.floor(build.price / 100).toLocaleString()}`} ) : ( "—" )}
{build.tags.map((tag) => ( {tag} ))} Comments + save coming soon.
))} {!loading && !error && filteredBuilds.length === 0 && (
No builds match those filters yet.
)}
); }