"use client"; /** * app/builds/page.tsx * Community Builds feed (public) * * Dev Notes: * - We are moving to Option B: * Build (core) + BuildProfile (meta) + Votes/Comments/Media as separate tables. * - This page expects a lightweight "feed card" DTO from the backend, NOT full build items. * - Keep UI/filters here client-side for MVP; move to server-side query params later if needed. * * Backend (target contract): * GET /api/v1/builds?limit=50 -> BuildFeedCardDto[] * (Optional later) POST /api/v1/builds/{uuid}/vote { delta: 1 | -1 } */ import { useEffect, useMemo, useState, useCallback } from "react"; import Link from "next/link"; type BuildClass = "Rifle" | "Pistol" | "NFA"; /** * Keep this loose. Backend will control the allowed calibers via BuildProfile. * We can tighten once the controlled vocab is finalized. */ type Caliber = string; /** * This matches what the UI needs (card format). * It maps cleanly to: builds + build_profiles + aggregated votes (+ optional price). */ type BuildCard = { id: string; // we use uuid here (public identifier) title: string; slug: string; // can be uuid for now; keep property so UI does not change creator: string; // placeholder until user profiles are real caliber: Caliber; buildClass: BuildClass; price: number; // optional server-side later; for now allow 0 fallback votes: number; tags: string[]; coverImageUrl?: string | null; }; type BuildFeedCardDto = { uuid: string; title?: string | null; slug?: string | null; // optional (we can generate from title later) creator?: string | null; // optional caliber?: string | null; buildClass?: BuildClass | null; price?: number | null; votes?: number | null; tags?: string[] | null; coverImageUrl?: string | null; }; const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? ""; // --- UI Filter Sets (keep; we’ll generate options from data too) --- 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 }, ]; function safeArray(v: unknown): string[] { return Array.isArray(v) ? v.filter(Boolean).map(String) : []; } function fallbackSlug(dto: BuildFeedCardDto) { // MVP: stable route id is uuid. Keep a "slug" field for UI continuity. return dto.slug?.trim() || dto.uuid; } function normalizeCard(dto: BuildFeedCardDto): BuildCard { const title = (dto.title ?? "Untitled Build").trim() || "Untitled Build"; return { id: dto.uuid, title, slug: fallbackSlug(dto), // Until we wire real users: keep a placeholder creator string. creator: (dto.creator ?? "anonymous").trim() || "anonymous", caliber: (dto.caliber ?? "—").trim() || "—", // Default to Rifle for display if missing; backend should set this via profile. buildClass: (dto.buildClass ?? "Rifle") as BuildClass, // Price is optional (we can compute later from BuildItems + offers). price: typeof dto.price === "number" ? dto.price : 0, votes: typeof dto.votes === "number" ? dto.votes : 0, tags: safeArray(dto.tags), coverImageUrl: dto.coverImageUrl ?? null, }; } export default function BuildsPage() { // --- Remote data state --- const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [builds, setBuilds] = useState([]); // --- UI state (filters/votes) --- const [caliberFilter, setCaliberFilter] = useState("all"); const [classFilter, setClassFilter] = useState("all"); const [priceFilterId, setPriceFilterId] = useState("all"); // Local vote state for optimistic UI const [votes, setVotes] = useState>({}); const activePriceFilter = PRICE_FILTERS.find((p) => p.id === priceFilterId) ?? PRICE_FILTERS[0]; // --- Fetch public builds feed --- useEffect(() => { 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 BuildFeedCardDto[]; const cards = (Array.isArray(data) ? data : []).map(normalizeCard); if (cancelled) return; setBuilds(cards); // Initialize vote UI from payload totals setVotes(Object.fromEntries(cards.map((b) => [b.id, b.votes]))); } catch (e: any) { if (!cancelled) setError(e?.message || "Failed to load builds feed"); } finally { if (!cancelled) setLoading(false); } } run(); return () => { cancelled = true; }; }, []); // Build the caliber filters dynamically from data, but keep "all" first. 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) => { // MVP: optimistic local vote only. // Later: POST /api/v1/builds/{uuid}/vote { delta } and reconcile. setVotes((prev) => ({ ...prev, [id]: (prev[id] ?? 0) + delta, })); }, []); return (
{/* Header */}

Battl Builder · Build Book

Community Builds

Browse community builds, vote them up or down, and steal good ideas without shame. This feed is backed by public builds (isPublic=true).

Open Builder {/* Later: route to /vault + choose build to submit, or direct /builder submit flow */} + Submit Build
{/* Loading / Error */} {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) => { const dollars = Math.floor((build.price ?? 0) / 100); return (
{/* 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.
)}
); }