@@ -1,5 +1,6 @@
|
|||||||
import PartsBrowseClient from "@/components/parts/PartsBrowseClient";
|
import PartsBrowseClient from "@/components/parts/PartsBrowseClient";
|
||||||
|
|
||||||
export default function Page({ params }: { params: { platform: string; partRole: string } }) {
|
export default async function Page(props: { params: Promise<{ platform: string; partRole: string }> }) {
|
||||||
|
const params = await props.params;
|
||||||
return <PartsBrowseClient partRole={params.partRole} platform={params.platform} />;
|
return <PartsBrowseClient partRole={params.partRole} platform={params.platform} />;
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import MailingAddressComponent from "@/components/MailingAddressComponent";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "Privacy Policy | Battl Builder",
|
title: "Privacy Policy | Battl Builder",
|
||||||
@@ -7,6 +8,8 @@ export const metadata = {
|
|||||||
"Privacy Policy for Battl Builder. Learn how we collect, use, and protect your data.",
|
"Privacy Policy for Battl Builder. Learn how we collect, use, and protect your data.",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export default function PrivacyPolicy() {
|
export default function PrivacyPolicy() {
|
||||||
const lastUpdated = "January 9, 2026";
|
const lastUpdated = "January 9, 2026";
|
||||||
|
|
||||||
@@ -22,7 +25,8 @@ export default function PrivacyPolicy() {
|
|||||||
<section>
|
<section>
|
||||||
<h2 className="mb-4 text-xl font-semibold">1. Introduction</h2>
|
<h2 className="mb-4 text-xl font-semibold">1. Introduction</h2>
|
||||||
<p>
|
<p>
|
||||||
Battl Builder ("we," "us," "our," or "Company") respects your privacy
|
Battl Builder ("we," "us," "our," or "Company") respects
|
||||||
|
your privacy
|
||||||
and is committed to protecting it through this Privacy Policy. This
|
and is committed to protecting it through this Privacy Policy. This
|
||||||
policy explains our practices regarding the collection, use, and
|
policy explains our practices regarding the collection, use, and
|
||||||
protection of your personal information when you access our website
|
protection of your personal information when you access our website
|
||||||
@@ -412,15 +416,7 @@ export default function PrivacyPolicy() {
|
|||||||
<strong>Email:</strong>{" "}
|
<strong>Email:</strong>{" "}
|
||||||
<span className="text-amber-300">privacy@battlbuilder.com</span>
|
<span className="text-amber-300">privacy@battlbuilder.com</span>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<MailingAddressComponent/>
|
||||||
<strong>Mailing Address:</strong>
|
|
||||||
<br />
|
|
||||||
Battl Builder, LLC.
|
|
||||||
<br />
|
|
||||||
[Your Address]
|
|
||||||
<br />
|
|
||||||
[City, State, ZIP]
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-4 text-sm">
|
<p className="mt-4 text-sm">
|
||||||
We will respond to all privacy inquiries within 30 days of receipt.
|
We will respond to all privacy inquiries within 30 days of receipt.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import {JSX, useEffect, useRef, useState} from "react";
|
||||||
import { sendEmailAction, type ApiResponse, type EmailRequest } from "@/app/actions/sendEmail";
|
import { sendEmailAction, type ApiResponse, type EmailRequest } from "@/app/actions/sendEmail";
|
||||||
import { Button, Field, Input, Textarea } from "@/components/ui/form";
|
import { Button, Field, Input, Textarea } from "@/components/ui/form";
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import {JSX, useCallback, useMemo, useState} from "react";
|
||||||
import type { CaliberDto, PlatformDto } from "../_lib/types";
|
import type { CaliberDto, PlatformDto } from "../_lib/types";
|
||||||
import { RightDrawer } from "./RightDrawer";
|
import { RightDrawer } from "./RightDrawer";
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ const API_BASE_URL =
|
|||||||
|
|
||||||
// POST /api/admin/beta-invites?dryRun=true&limit=10&tokenMinutes=30
|
// POST /api/admin/beta-invites?dryRun=true&limit=10&tokenMinutes=30
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
const token = cookies().get("bb_access_token")?.value;
|
const token = (await cookies()).get("bb_access_token")?.value;
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return NextResponse.json({ error: "Missing admin token" }, { status: 401 });
|
return NextResponse.json({ error: "Missing admin token" }, { status: 401 });
|
||||||
|
|||||||
@@ -4,10 +4,8 @@ import type {
|
|||||||
MerchantAdminUpdateRequest,
|
MerchantAdminUpdateRequest,
|
||||||
} from "@/types/merchant-admin";
|
} from "@/types/merchant-admin";
|
||||||
|
|
||||||
export async function PUT(
|
export async function PUT(req: Request, props: { params: Promise<{ id: string }> }) {
|
||||||
req: Request,
|
const params = await props.params;
|
||||||
{ params }: { params: { id: string } }
|
|
||||||
) {
|
|
||||||
const body = (await req.json()) as MerchantAdminUpdateRequest;
|
const body = (await req.json()) as MerchantAdminUpdateRequest;
|
||||||
|
|
||||||
return springProxy<MerchantAdminResponse, MerchantAdminUpdateRequest>(
|
return springProxy<MerchantAdminResponse, MerchantAdminUpdateRequest>(
|
||||||
|
|||||||
@@ -0,0 +1,382 @@
|
|||||||
|
"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<string | null>(initialError);
|
||||||
|
const [builds, setBuilds] = useState<BuildCard[]>(initialBuilds);
|
||||||
|
|
||||||
|
// --- UI state (filters/votes) ---
|
||||||
|
const [caliberFilter, setCaliberFilter] = useState<Caliber | "all">("all");
|
||||||
|
const [classFilter, setClassFilter] = useState<BuildClass | "all">("all");
|
||||||
|
const [priceFilterId, setPriceFilterId] = useState<string>("all");
|
||||||
|
|
||||||
|
const [votes, setVotes] = useState<Record<string, number>>(() =>
|
||||||
|
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<string>();
|
||||||
|
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 && (
|
||||||
|
<div className="mb-6 rounded-lg border border-zinc-800 bg-zinc-950/70 p-4 text-sm text-zinc-400">
|
||||||
|
Loading community builds…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && !loading && (
|
||||||
|
<div className="mb-6 rounded-lg border border-red-500/30 bg-red-500/10 p-4 text-sm text-red-200">
|
||||||
|
{error}
|
||||||
|
<div className="mt-2 text-xs text-red-200/80">
|
||||||
|
Dev tip: confirm backend route{" "}
|
||||||
|
<span className="font-mono">GET /api/v1/builds</span> is
|
||||||
|
implemented + CORS is configured.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<section className="mb-6 rounded-lg border border-zinc-800 bg-zinc-950/70 p-4 space-y-4">
|
||||||
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xs font-semibold tracking-[0.16em] uppercase text-zinc-400">
|
||||||
|
Filter Builds
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-zinc-500 mt-1">
|
||||||
|
Filter by caliber, class, and price range. (Client-side for MVP.)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-zinc-500">
|
||||||
|
Showing{" "}
|
||||||
|
<span className="text-zinc-200 font-medium">
|
||||||
|
{filteredBuilds.length}
|
||||||
|
</span>{" "}
|
||||||
|
of{" "}
|
||||||
|
<span className="text-zinc-200 font-medium">{builds.length}</span>{" "}
|
||||||
|
builds
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 md:grid-cols-3">
|
||||||
|
{/* Caliber filter */}
|
||||||
|
<div>
|
||||||
|
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
|
||||||
|
Caliber
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{CALIBER_FILTERS.map((caliber) => (
|
||||||
|
<button
|
||||||
|
key={caliber}
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setCaliberFilter(caliber === "all" ? "all" : caliber)
|
||||||
|
}
|
||||||
|
className={`rounded-full border px-3 py-1 text-xs transition-colors ${
|
||||||
|
caliberFilter === caliber
|
||||||
|
? "border-amber-400/70 bg-amber-400/10 text-amber-200"
|
||||||
|
: "border-zinc-700 bg-zinc-900/60 text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{caliber === "all" ? "All" : caliber}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Class filter */}
|
||||||
|
<div>
|
||||||
|
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
|
||||||
|
Class
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{CLASS_FILTERS.map((cls) => (
|
||||||
|
<button
|
||||||
|
key={cls}
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setClassFilter(cls === "all" ? "all" : cls)
|
||||||
|
}
|
||||||
|
className={`rounded-full border px-3 py-1 text-xs transition-colors ${
|
||||||
|
classFilter === cls
|
||||||
|
? "border-amber-400/70 bg-amber-400/10 text-amber-200"
|
||||||
|
: "border-zinc-700 bg-zinc-900/60 text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{cls === "all" ? "All" : cls}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Price filter */}
|
||||||
|
<div>
|
||||||
|
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
|
||||||
|
Price Range
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{PRICE_FILTERS.map((range) => (
|
||||||
|
<button
|
||||||
|
key={range.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPriceFilterId(range.id)}
|
||||||
|
className={`rounded-full border px-3 py-1 text-xs transition-colors ${
|
||||||
|
priceFilterId === range.id
|
||||||
|
? "border-amber-400/70 bg-amber-400/10 text-amber-200"
|
||||||
|
: "border-zinc-700 bg-zinc-900/60 text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{range.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Build list */}
|
||||||
|
<section>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{filteredBuilds.map((build) => (
|
||||||
|
<article
|
||||||
|
key={build.id}
|
||||||
|
className="flex gap-4 rounded-lg border border-zinc-800 bg-zinc-950/70 p-4"
|
||||||
|
>
|
||||||
|
{/* Votes column */}
|
||||||
|
<div className="flex flex-col items-center justify-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleVote(build.id, 1)}
|
||||||
|
className="flex h-6 w-6 items-center justify-center rounded-full border border-zinc-700 bg-zinc-900/70 text-xs text-zinc-300 hover:bg-zinc-800 hover:border-zinc-500"
|
||||||
|
aria-label="Upvote"
|
||||||
|
title="Upvote"
|
||||||
|
>
|
||||||
|
▲
|
||||||
|
</button>
|
||||||
|
<div className="text-xs font-semibold text-amber-200">
|
||||||
|
{votes[build.id] ?? build.votes}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleVote(build.id, -1)}
|
||||||
|
className="flex h-6 w-6 items-center justify-center rounded-full border border-zinc-700 bg-zinc-900/70 text-xs text-zinc-300 hover:bg-zinc-800 hover:border-zinc-500"
|
||||||
|
aria-label="Downvote"
|
||||||
|
title="Downvote"
|
||||||
|
>
|
||||||
|
▼
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Thumbnail */}
|
||||||
|
<div className="hidden sm:block">
|
||||||
|
<div className="overflow-hidden rounded-md border border-zinc-800 bg-zinc-900/80 h-24 w-40">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={
|
||||||
|
build.coverImageUrl ||
|
||||||
|
"https://placehold.co/320x200/png?text=Build+Photo"
|
||||||
|
}
|
||||||
|
alt={`${build.title} cover`}
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
|
||||||
|
{build.buildClass} · {build.caliber}
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg md:text-xl font-semibold text-zinc-50">
|
||||||
|
<Link
|
||||||
|
href={`/builds/${build.slug}`}
|
||||||
|
className="hover:text-amber-200 transition-colors"
|
||||||
|
>
|
||||||
|
{build.title}
|
||||||
|
</Link>
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-zinc-500 mt-1">
|
||||||
|
Posted by{" "}
|
||||||
|
<span className="text-zinc-300">b/{build.creator}</span>{" "}
|
||||||
|
· Build detail page will show the parts list +
|
||||||
|
comments.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-right mt-1 md:mt-0">
|
||||||
|
<div className="text-xs uppercase tracking-[0.16em] text-zinc-500">
|
||||||
|
Est. Build Cost
|
||||||
|
</div>
|
||||||
|
<div className="text-lg font-semibold text-amber-300">
|
||||||
|
{build.price > 0 ? (
|
||||||
|
<span>
|
||||||
|
{`$${Math.floor(build.price / 100).toLocaleString()}`}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||||
|
{build.tags.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="rounded-full border border-zinc-700 bg-zinc-900/60 px-2 py-0.5 text-[0.7rem] text-zinc-400"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<span className="ml-auto text-[0.7rem] text-zinc-500">
|
||||||
|
Comments + save coming soon.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{!loading && !error && filteredBuilds.length === 0 && (
|
||||||
|
<div className="rounded-lg border border-zinc-800 bg-zinc-950/70 p-6 text-center text-sm text-zinc-500">
|
||||||
|
No builds match those filters yet.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
the page-use-client.tsx is the old client side page, this is a separation of the server side page and the client side
|
||||||
@@ -26,11 +26,12 @@ function timeAgo(iso?: string | null) {
|
|||||||
return `${days}d ago`;
|
return `${days}d ago`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function BuildBreakdownPage({
|
export default async function BuildBreakdownPage(
|
||||||
params,
|
props: {
|
||||||
}: {
|
params: Promise<{ buildId: string }>;
|
||||||
params: { buildId: string };
|
}
|
||||||
}) {
|
) {
|
||||||
|
const params = await props.params;
|
||||||
// Public detail endpoint
|
// Public detail endpoint
|
||||||
const res = await fetch(`${API_BASE_URL}/api/v1/builds/${params.buildId}`, {
|
const res = await fetch(`${API_BASE_URL}/api/v1/builds/${params.buildId}`, {
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
@@ -73,7 +74,6 @@ export default async function BuildBreakdownPage({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Layout */}
|
{/* Layout */}
|
||||||
<div className="mt-6 grid grid-cols-1 gap-6 lg:grid-cols-[1fr_320px]">
|
<div className="mt-6 grid grid-cols-1 gap-6 lg:grid-cols-[1fr_320px]">
|
||||||
{/* Post column */}
|
{/* Post column */}
|
||||||
@@ -168,11 +168,11 @@ export default async function BuildBreakdownPage({
|
|||||||
<div className="h-12 w-12 flex-none overflow-hidden rounded-md border border-zinc-800 bg-zinc-950">
|
<div className="h-12 w-12 flex-none overflow-hidden rounded-md border border-zinc-800 bg-zinc-950">
|
||||||
{it.productImageUrl ? (
|
{it.productImageUrl ? (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img
|
(<img
|
||||||
src={it.productImageUrl}
|
src={it.productImageUrl}
|
||||||
alt={it.productName ?? "Part image"}
|
alt={it.productName ?? "Part image"}
|
||||||
className="h-full w-full object-cover"
|
className="h-full w-full object-cover"
|
||||||
/>
|
/>)
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full w-full items-center justify-center text-[10px] text-zinc-600">
|
<div className="flex h-full w-full items-center justify-center text-[10px] text-zinc-600">
|
||||||
IMG
|
IMG
|
||||||
|
|||||||
@@ -0,0 +1,481 @@
|
|||||||
|
"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<string | null>(null);
|
||||||
|
const [builds, setBuilds] = useState<BuildCard[]>([]);
|
||||||
|
|
||||||
|
// --- UI state (filters/votes) ---
|
||||||
|
const [caliberFilter, setCaliberFilter] = useState<Caliber | "all">("all");
|
||||||
|
const [classFilter, setClassFilter] = useState<BuildClass | "all">("all");
|
||||||
|
const [priceFilterId, setPriceFilterId] = useState<string>("all");
|
||||||
|
|
||||||
|
// Local vote state for optimistic UI
|
||||||
|
const [votes, setVotes] = useState<Record<string, number>>({});
|
||||||
|
|
||||||
|
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<string>();
|
||||||
|
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 (
|
||||||
|
<main className="min-h-screen bg-black text-zinc-50">
|
||||||
|
<div className="mx-auto max-w-6xl px-4 py-6 lg:py-10">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="mb-6 flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
|
||||||
|
Battl Builder · Build Book
|
||||||
|
</p>
|
||||||
|
<h1
|
||||||
|
className="mt-2 text-3xl md:text-4xl font-extrabold tracking-tight
|
||||||
|
bg-gradient-to-r from-amber-400 via-amber-200 to-amber-400
|
||||||
|
text-transparent bg-clip-text drop-shadow-[0_2px_6px_rgba(255,193,7,0.25)]"
|
||||||
|
>
|
||||||
|
Community Builds
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-sm md:text-base text-zinc-400 max-w-xl">
|
||||||
|
Browse community builds, vote them up or down, and steal good
|
||||||
|
ideas without shame. This feed is backed by public builds
|
||||||
|
(isPublic=true).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 md:gap-3">
|
||||||
|
<Link
|
||||||
|
href="/builder"
|
||||||
|
className="inline-flex items-center justify-center rounded-md border border-zinc-700 bg-zinc-900/60 px-4 py-2 text-xs md:text-sm font-medium text-zinc-200 hover:bg-zinc-800 hover:border-zinc-600 transition-colors"
|
||||||
|
>
|
||||||
|
Open Builder
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Later: route to /vault + choose build to submit, or direct /builder submit flow */}
|
||||||
|
<Link
|
||||||
|
href="/vault"
|
||||||
|
className="inline-flex items-center justify-center rounded-md border border-amber-400/70 bg-amber-400/10 px-4 py-2 text-xs md:text-sm font-medium text-amber-200 hover:bg-amber-400/15 transition-colors"
|
||||||
|
>
|
||||||
|
+ Submit Build
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Loading / Error */}
|
||||||
|
{loading && (
|
||||||
|
<div className="mb-6 rounded-lg border border-zinc-800 bg-zinc-950/70 p-4 text-sm text-zinc-400">
|
||||||
|
Loading community builds…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && !loading && (
|
||||||
|
<div className="mb-6 rounded-lg border border-red-500/30 bg-red-500/10 p-4 text-sm text-red-200">
|
||||||
|
{error}
|
||||||
|
<div className="mt-2 text-xs text-red-200/80">
|
||||||
|
Dev tip: confirm backend route{" "}
|
||||||
|
<span className="font-mono">GET /api/v1/builds</span> is
|
||||||
|
implemented + CORS is configured.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<section className="mb-6 rounded-lg border border-zinc-800 bg-zinc-950/70 p-4 space-y-4">
|
||||||
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xs font-semibold tracking-[0.16em] uppercase text-zinc-400">
|
||||||
|
Filter Builds
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-zinc-500 mt-1">
|
||||||
|
Filter by caliber, class, and price range. (Client-side for
|
||||||
|
MVP.)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-zinc-500">
|
||||||
|
Showing{" "}
|
||||||
|
<span className="text-zinc-200 font-medium">
|
||||||
|
{filteredBuilds.length}
|
||||||
|
</span>{" "}
|
||||||
|
of{" "}
|
||||||
|
<span className="text-zinc-200 font-medium">{builds.length}</span>{" "}
|
||||||
|
builds
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 md:grid-cols-3">
|
||||||
|
{/* Caliber filter */}
|
||||||
|
<div>
|
||||||
|
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
|
||||||
|
Caliber
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{CALIBER_FILTERS.map((caliber) => (
|
||||||
|
<button
|
||||||
|
key={caliber}
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setCaliberFilter(caliber === "all" ? "all" : caliber)
|
||||||
|
}
|
||||||
|
className={`rounded-full border px-3 py-1 text-xs transition-colors ${
|
||||||
|
caliberFilter === caliber
|
||||||
|
? "border-amber-400/70 bg-amber-400/10 text-amber-200"
|
||||||
|
: "border-zinc-700 bg-zinc-900/60 text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{caliber === "all" ? "All" : caliber}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Class filter */}
|
||||||
|
<div>
|
||||||
|
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
|
||||||
|
Class
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{CLASS_FILTERS.map((cls) => (
|
||||||
|
<button
|
||||||
|
key={cls}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setClassFilter(cls === "all" ? "all" : cls)}
|
||||||
|
className={`rounded-full border px-3 py-1 text-xs transition-colors ${
|
||||||
|
classFilter === cls
|
||||||
|
? "border-amber-400/70 bg-amber-400/10 text-amber-200"
|
||||||
|
: "border-zinc-700 bg-zinc-900/60 text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{cls === "all" ? "All" : cls}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Price filter */}
|
||||||
|
<div>
|
||||||
|
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
|
||||||
|
Price Range
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{PRICE_FILTERS.map((range) => (
|
||||||
|
<button
|
||||||
|
key={range.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPriceFilterId(range.id)}
|
||||||
|
className={`rounded-full border px-3 py-1 text-xs transition-colors ${
|
||||||
|
priceFilterId === range.id
|
||||||
|
? "border-amber-400/70 bg-amber-400/10 text-amber-200"
|
||||||
|
: "border-zinc-700 bg-zinc-900/60 text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{range.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Build list */}
|
||||||
|
<section>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{filteredBuilds.map((build) => {
|
||||||
|
const dollars = Math.floor((build.price ?? 0) / 100);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
key={build.id}
|
||||||
|
className="flex gap-4 rounded-lg border border-zinc-800 bg-zinc-950/70 p-4"
|
||||||
|
>
|
||||||
|
{/* Votes column */}
|
||||||
|
<div className="flex flex-col items-center justify-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleVote(build.id, 1)}
|
||||||
|
className="flex h-6 w-6 items-center justify-center rounded-full border border-zinc-700 bg-zinc-900/70 text-xs text-zinc-300 hover:bg-zinc-800 hover:border-zinc-500"
|
||||||
|
aria-label="Upvote"
|
||||||
|
title="Upvote"
|
||||||
|
>
|
||||||
|
▲
|
||||||
|
</button>
|
||||||
|
<div className="text-xs font-semibold text-amber-200">
|
||||||
|
{votes[build.id] ?? build.votes}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleVote(build.id, -1)}
|
||||||
|
className="flex h-6 w-6 items-center justify-center rounded-full border border-zinc-700 bg-zinc-900/70 text-xs text-zinc-300 hover:bg-zinc-800 hover:border-zinc-500"
|
||||||
|
aria-label="Downvote"
|
||||||
|
title="Downvote"
|
||||||
|
>
|
||||||
|
▼
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Thumbnail */}
|
||||||
|
<div className="hidden sm:block">
|
||||||
|
<div className="overflow-hidden rounded-md border border-zinc-800 bg-zinc-900/80 h-24 w-40">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={
|
||||||
|
build.coverImageUrl ||
|
||||||
|
"https://placehold.co/320x200/png?text=Build+Photo"
|
||||||
|
}
|
||||||
|
alt={`${build.title} cover`}
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
|
||||||
|
{build.buildClass} · {build.caliber}
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg md:text-xl font-semibold text-zinc-50">
|
||||||
|
<Link
|
||||||
|
href={`/builds/${build.slug}`}
|
||||||
|
className="hover:text-amber-200 transition-colors"
|
||||||
|
>
|
||||||
|
{build.title}
|
||||||
|
</Link>
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-zinc-500 mt-1">
|
||||||
|
Posted by{" "}
|
||||||
|
<span className="text-zinc-300">
|
||||||
|
b/{build.creator}
|
||||||
|
</span>{" "}
|
||||||
|
· Build detail page will show the parts list +
|
||||||
|
comments.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-right mt-1 md:mt-0">
|
||||||
|
<div className="text-xs uppercase tracking-[0.16em] text-zinc-500">
|
||||||
|
Est. Build Cost
|
||||||
|
</div>
|
||||||
|
<div className="text-lg font-semibold text-amber-300">
|
||||||
|
{build.price > 0 ? (
|
||||||
|
<span>
|
||||||
|
${Math.floor(build.price / 100).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||||
|
{build.tags.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="rounded-full border border-zinc-700 bg-zinc-900/60 px-2 py-0.5 text-[0.7rem] text-zinc-400"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<span className="ml-auto text-[0.7rem] text-zinc-500">
|
||||||
|
Comments + save coming soon.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{!loading && !error && filteredBuilds.length === 0 && (
|
||||||
|
<div className="rounded-lg border border-zinc-800 bg-zinc-950/70 p-6 text-center text-sm text-zinc-500">
|
||||||
|
No builds match those filters yet.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
+31
-382
@@ -1,43 +1,17 @@
|
|||||||
"use client";
|
// app/builds/page.tsx
|
||||||
|
|
||||||
/**
|
|
||||||
* 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";
|
import Link from "next/link";
|
||||||
|
|
||||||
type BuildClass = "Rifle" | "Pistol" | "NFA";
|
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;
|
type Caliber = string;
|
||||||
|
|
||||||
/**
|
|
||||||
* This matches what the UI needs (card format).
|
|
||||||
* It maps cleanly to: builds + build_profiles + aggregated votes (+ optional price).
|
|
||||||
*/
|
|
||||||
type BuildCard = {
|
type BuildCard = {
|
||||||
id: string; // we use uuid here (public identifier)
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
slug: string; // can be uuid for now; keep property so UI does not change
|
slug: string;
|
||||||
creator: string; // placeholder until user profiles are real
|
creator: string;
|
||||||
caliber: Caliber;
|
caliber: Caliber;
|
||||||
buildClass: BuildClass;
|
buildClass: BuildClass;
|
||||||
price: number; // optional server-side later; for now allow 0 fallback
|
price: number;
|
||||||
votes: number;
|
votes: number;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
coverImageUrl?: string | null;
|
coverImageUrl?: string | null;
|
||||||
@@ -46,8 +20,8 @@ type BuildCard = {
|
|||||||
type BuildFeedCardDto = {
|
type BuildFeedCardDto = {
|
||||||
uuid: string;
|
uuid: string;
|
||||||
title?: string | null;
|
title?: string | null;
|
||||||
slug?: string | null; // optional (we can generate from title later)
|
slug?: string | null;
|
||||||
creator?: string | null; // optional
|
creator?: string | null;
|
||||||
caliber?: string | null;
|
caliber?: string | null;
|
||||||
buildClass?: BuildClass | null;
|
buildClass?: BuildClass | null;
|
||||||
price?: number | null;
|
price?: number | null;
|
||||||
@@ -56,26 +30,13 @@ type BuildFeedCardDto = {
|
|||||||
coverImageUrl?: string | null;
|
coverImageUrl?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const API_BASE_URL =
|
const API_BASE_URL = process.env.NEXT_PUBLIC_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[] {
|
function safeArray(v: unknown): string[] {
|
||||||
return Array.isArray(v) ? v.filter(Boolean).map(String) : [];
|
return Array.isArray(v) ? v.filter(Boolean).map(String) : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function fallbackSlug(dto: BuildFeedCardDto) {
|
function fallbackSlug(dto: BuildFeedCardDto) {
|
||||||
// MVP: stable route id is uuid. Keep a "slug" field for UI continuity.
|
|
||||||
return dto.slug?.trim() || dto.uuid;
|
return dto.slug?.trim() || dto.uuid;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,119 +47,41 @@ function normalizeCard(dto: BuildFeedCardDto): BuildCard {
|
|||||||
id: dto.uuid,
|
id: dto.uuid,
|
||||||
title,
|
title,
|
||||||
slug: fallbackSlug(dto),
|
slug: fallbackSlug(dto),
|
||||||
|
|
||||||
// Until we wire real users: keep a placeholder creator string.
|
|
||||||
creator: (dto.creator ?? "anonymous").trim() || "anonymous",
|
creator: (dto.creator ?? "anonymous").trim() || "anonymous",
|
||||||
|
|
||||||
caliber: (dto.caliber ?? "—").trim() || "—",
|
caliber: (dto.caliber ?? "—").trim() || "—",
|
||||||
|
|
||||||
// Default to Rifle for display if missing; backend should set this via profile.
|
|
||||||
buildClass: (dto.buildClass ?? "Rifle") as BuildClass,
|
buildClass: (dto.buildClass ?? "Rifle") as BuildClass,
|
||||||
|
|
||||||
// Price is optional (we can compute later from BuildItems + offers).
|
|
||||||
price: typeof dto.price === "number" ? dto.price : 0,
|
price: typeof dto.price === "number" ? dto.price : 0,
|
||||||
|
|
||||||
votes: typeof dto.votes === "number" ? dto.votes : 0,
|
votes: typeof dto.votes === "number" ? dto.votes : 0,
|
||||||
|
|
||||||
tags: safeArray(dto.tags),
|
tags: safeArray(dto.tags),
|
||||||
|
|
||||||
coverImageUrl: dto.coverImageUrl ?? null,
|
coverImageUrl: dto.coverImageUrl ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function BuildsPage() {
|
// Import the client component that will handle filters & voting
|
||||||
// --- Remote data state ---
|
import BuildsClient from "./BuildsClient";
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [builds, setBuilds] = useState<BuildCard[]>([]);
|
|
||||||
|
|
||||||
// --- UI state (filters/votes) ---
|
export default async function BuildsPage() {
|
||||||
const [caliberFilter, setCaliberFilter] = useState<Caliber | "all">("all");
|
let cards: BuildCard[] = [];
|
||||||
const [classFilter, setClassFilter] = useState<BuildClass | "all">("all");
|
let error: string | null = null;
|
||||||
const [priceFilterId, setPriceFilterId] = useState<string>("all");
|
|
||||||
|
|
||||||
// Local vote state for optimistic UI
|
try {
|
||||||
const [votes, setVotes] = useState<Record<string, number>>({});
|
const res = await fetch(`${API_BASE_URL}/api/v1/builds?limit=50`, {
|
||||||
|
method: "GET",
|
||||||
const activePriceFilter =
|
// Disable full-page static caching; you can adjust this per your needs:
|
||||||
PRICE_FILTERS.find((p) => p.id === priceFilterId) ?? PRICE_FILTERS[0];
|
cache: "no-store",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
// --- 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<string>();
|
|
||||||
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) => {
|
if (!res.ok) {
|
||||||
// MVP: optimistic local vote only.
|
const txt = await res.text().catch(() => "");
|
||||||
// Later: POST /api/v1/builds/{uuid}/vote { delta } and reconcile.
|
throw new Error(txt || `Failed to load builds (${res.status})`);
|
||||||
setVotes((prev) => ({
|
}
|
||||||
...prev,
|
|
||||||
[id]: (prev[id] ?? 0) + delta,
|
const data = (await res.json()) as BuildFeedCardDto[];
|
||||||
}));
|
cards = (Array.isArray(data) ? data : []).map(normalizeCard);
|
||||||
}, []);
|
} catch (e: any) {
|
||||||
|
error = e?.message || "Failed to load builds feed";
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-black text-zinc-50">
|
<main className="min-h-screen bg-black text-zinc-50">
|
||||||
@@ -230,8 +113,6 @@ export default function BuildsPage() {
|
|||||||
>
|
>
|
||||||
Open Builder
|
Open Builder
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Later: route to /vault + choose build to submit, or direct /builder submit flow */}
|
|
||||||
<Link
|
<Link
|
||||||
href="/vault"
|
href="/vault"
|
||||||
className="inline-flex items-center justify-center rounded-md border border-amber-400/70 bg-amber-400/10 px-4 py-2 text-xs md:text-sm font-medium text-amber-200 hover:bg-amber-400/15 transition-colors"
|
className="inline-flex items-center justify-center rounded-md border border-amber-400/70 bg-amber-400/10 px-4 py-2 text-xs md:text-sm font-medium text-amber-200 hover:bg-amber-400/15 transition-colors"
|
||||||
@@ -241,240 +122,8 @@ export default function BuildsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Loading / Error */}
|
{/* Pass initial data + any load error into the client component */}
|
||||||
{loading && (
|
<BuildsClient initialBuilds={cards} initialError={error} />
|
||||||
<div className="mb-6 rounded-lg border border-zinc-800 bg-zinc-950/70 p-4 text-sm text-zinc-400">
|
|
||||||
Loading community builds…
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && !loading && (
|
|
||||||
<div className="mb-6 rounded-lg border border-red-500/30 bg-red-500/10 p-4 text-sm text-red-200">
|
|
||||||
{error}
|
|
||||||
<div className="mt-2 text-xs text-red-200/80">
|
|
||||||
Dev tip: confirm backend route{" "}
|
|
||||||
<span className="font-mono">GET /api/v1/builds</span> is
|
|
||||||
implemented + CORS is configured.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Filters */}
|
|
||||||
<section className="mb-6 rounded-lg border border-zinc-800 bg-zinc-950/70 p-4 space-y-4">
|
|
||||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-xs font-semibold tracking-[0.16em] uppercase text-zinc-400">
|
|
||||||
Filter Builds
|
|
||||||
</h2>
|
|
||||||
<p className="text-xs text-zinc-500 mt-1">
|
|
||||||
Filter by caliber, class, and price range. (Client-side for
|
|
||||||
MVP.)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-xs text-zinc-500">
|
|
||||||
Showing{" "}
|
|
||||||
<span className="text-zinc-200 font-medium">
|
|
||||||
{filteredBuilds.length}
|
|
||||||
</span>{" "}
|
|
||||||
of{" "}
|
|
||||||
<span className="text-zinc-200 font-medium">{builds.length}</span>{" "}
|
|
||||||
builds
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-3 md:grid-cols-3">
|
|
||||||
{/* Caliber filter */}
|
|
||||||
<div>
|
|
||||||
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
|
|
||||||
Caliber
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{CALIBER_FILTERS.map((caliber) => (
|
|
||||||
<button
|
|
||||||
key={caliber}
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
setCaliberFilter(caliber === "all" ? "all" : caliber)
|
|
||||||
}
|
|
||||||
className={`rounded-full border px-3 py-1 text-xs transition-colors ${
|
|
||||||
caliberFilter === caliber
|
|
||||||
? "border-amber-400/70 bg-amber-400/10 text-amber-200"
|
|
||||||
: "border-zinc-700 bg-zinc-900/60 text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{caliber === "all" ? "All" : caliber}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Class filter */}
|
|
||||||
<div>
|
|
||||||
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
|
|
||||||
Class
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{CLASS_FILTERS.map((cls) => (
|
|
||||||
<button
|
|
||||||
key={cls}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setClassFilter(cls === "all" ? "all" : cls)}
|
|
||||||
className={`rounded-full border px-3 py-1 text-xs transition-colors ${
|
|
||||||
classFilter === cls
|
|
||||||
? "border-amber-400/70 bg-amber-400/10 text-amber-200"
|
|
||||||
: "border-zinc-700 bg-zinc-900/60 text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{cls === "all" ? "All" : cls}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Price filter */}
|
|
||||||
<div>
|
|
||||||
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
|
|
||||||
Price Range
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{PRICE_FILTERS.map((range) => (
|
|
||||||
<button
|
|
||||||
key={range.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setPriceFilterId(range.id)}
|
|
||||||
className={`rounded-full border px-3 py-1 text-xs transition-colors ${
|
|
||||||
priceFilterId === range.id
|
|
||||||
? "border-amber-400/70 bg-amber-400/10 text-amber-200"
|
|
||||||
: "border-zinc-700 bg-zinc-900/60 text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{range.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Build list */}
|
|
||||||
<section>
|
|
||||||
<div className="space-y-4">
|
|
||||||
{filteredBuilds.map((build) => {
|
|
||||||
const dollars = Math.floor((build.price ?? 0) / 100);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<article
|
|
||||||
key={build.id}
|
|
||||||
className="flex gap-4 rounded-lg border border-zinc-800 bg-zinc-950/70 p-4"
|
|
||||||
>
|
|
||||||
{/* Votes column */}
|
|
||||||
<div className="flex flex-col items-center justify-center gap-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleVote(build.id, 1)}
|
|
||||||
className="flex h-6 w-6 items-center justify-center rounded-full border border-zinc-700 bg-zinc-900/70 text-xs text-zinc-300 hover:bg-zinc-800 hover:border-zinc-500"
|
|
||||||
aria-label="Upvote"
|
|
||||||
title="Upvote"
|
|
||||||
>
|
|
||||||
▲
|
|
||||||
</button>
|
|
||||||
<div className="text-xs font-semibold text-amber-200">
|
|
||||||
{votes[build.id] ?? build.votes}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleVote(build.id, -1)}
|
|
||||||
className="flex h-6 w-6 items-center justify-center rounded-full border border-zinc-700 bg-zinc-900/70 text-xs text-zinc-300 hover:bg-zinc-800 hover:border-zinc-500"
|
|
||||||
aria-label="Downvote"
|
|
||||||
title="Downvote"
|
|
||||||
>
|
|
||||||
▼
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Thumbnail */}
|
|
||||||
<div className="hidden sm:block">
|
|
||||||
<div className="overflow-hidden rounded-md border border-zinc-800 bg-zinc-900/80 h-24 w-40">
|
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
<img
|
|
||||||
src={
|
|
||||||
build.coverImageUrl ||
|
|
||||||
"https://placehold.co/320x200/png?text=Build+Photo"
|
|
||||||
}
|
|
||||||
alt={`${build.title} cover`}
|
|
||||||
className="h-full w-full object-cover"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main content */}
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
|
|
||||||
<div>
|
|
||||||
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
|
|
||||||
{build.buildClass} · {build.caliber}
|
|
||||||
</div>
|
|
||||||
<h3 className="text-lg md:text-xl font-semibold text-zinc-50">
|
|
||||||
<Link
|
|
||||||
href={`/builds/${build.slug}`}
|
|
||||||
className="hover:text-amber-200 transition-colors"
|
|
||||||
>
|
|
||||||
{build.title}
|
|
||||||
</Link>
|
|
||||||
</h3>
|
|
||||||
<p className="text-xs text-zinc-500 mt-1">
|
|
||||||
Posted by{" "}
|
|
||||||
<span className="text-zinc-300">
|
|
||||||
b/{build.creator}
|
|
||||||
</span>{" "}
|
|
||||||
· Build detail page will show the parts list +
|
|
||||||
comments.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-right mt-1 md:mt-0">
|
|
||||||
<div className="text-xs uppercase tracking-[0.16em] text-zinc-500">
|
|
||||||
Est. Build Cost
|
|
||||||
</div>
|
|
||||||
<div className="text-lg font-semibold text-amber-300">
|
|
||||||
{build.price > 0 ? (
|
|
||||||
<span>
|
|
||||||
${Math.floor(build.price / 100).toLocaleString()}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
"—"
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
|
||||||
{build.tags.map((tag) => (
|
|
||||||
<span
|
|
||||||
key={tag}
|
|
||||||
className="rounded-full border border-zinc-700 bg-zinc-900/60 px-2 py-0.5 text-[0.7rem] text-zinc-400"
|
|
||||||
>
|
|
||||||
{tag}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<span className="ml-auto text-[0.7rem] text-zinc-500">
|
|
||||||
Comments + save coming soon.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{!loading && !error && filteredBuilds.length === 0 && (
|
|
||||||
<div className="rounded-lg border border-zinc-800 bg-zinc-950/70 p-6 text-center text-sm text-zinc-500">
|
|
||||||
No builds match those filters yet.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export default function MailingAddressComponent() {
|
||||||
|
return <p>
|
||||||
|
<strong>Mailing Address:</strong>
|
||||||
|
<br/>
|
||||||
|
Battl Builder, LLC.
|
||||||
|
<br/>
|
||||||
|
[Your Address]
|
||||||
|
<br/>
|
||||||
|
[City, State, ZIP]
|
||||||
|
</p>;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import {JSX, useEffect, useRef, useState} from "react";
|
||||||
import { sendEmailAction, type ApiResponse, type EmailRequest } from "@/app/actions/sendEmail";
|
import { sendEmailAction, type ApiResponse, type EmailRequest } from "@/app/actions/sendEmail";
|
||||||
import { Button, Field, Input, Textarea } from "@/components/ui/form";
|
import { Button, Field, Input, Textarea } from "@/components/ui/form";
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import {JSX, useEffect, useRef, useState} from "react";
|
||||||
import { sendEmailAction, type ApiResponse, type EmailRequest } from "@/app/actions/sendEmail";
|
import { sendEmailAction, type ApiResponse, type EmailRequest } from "@/app/actions/sendEmail";
|
||||||
import { Button, Field, Input } from "@/components/ui/form";
|
import { Button, Field, Input } from "@/components/ui/form";
|
||||||
import RichTextEditor from "@/components/ui/RichTextEditor";
|
import RichTextEditor from "@/components/ui/RichTextEditor";
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import {JSX, useEffect, useRef, useState} from "react";
|
||||||
import { sendEmailAction, type ApiResponse, type EmailRequest } from "@/app/actions/sendEmail";
|
import { sendEmailAction, type ApiResponse, type EmailRequest } from "@/app/actions/sendEmail";
|
||||||
import { Button, Field, Input } from "@/components/ui/form";
|
import { Button, Field, Input } from "@/components/ui/form";
|
||||||
import RichTextEditor from "@/components/ui/RichTextEditor";
|
import RichTextEditor from "@/components/ui/RichTextEditor";
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { defineConfig } from "eslint/config";
|
||||||
|
import nextCoreWebVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
export default defineConfig([{
|
||||||
|
extends: [...nextCoreWebVitals],
|
||||||
|
}]);
|
||||||
+33
-23
@@ -1,23 +1,33 @@
|
|||||||
FROM node:20-alpine AS deps
|
# ---------- deps ----------
|
||||||
WORKDIR /app
|
FROM node:20-alpine AS deps
|
||||||
COPY package.json package-lock.json ./
|
WORKDIR /app
|
||||||
RUN npm ci --only=production
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
FROM node:20-alpine AS builder
|
|
||||||
WORKDIR /app
|
# ---------- build ----------
|
||||||
COPY package.json package-lock.json ./
|
FROM node:20-alpine AS builder
|
||||||
RUN npm ci
|
WORKDIR /app
|
||||||
COPY . .
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
ENV NODE_ENV=production
|
COPY . .
|
||||||
ENV NEXT_PUBLIC_API_BASE_URL=http://battlbuilder-api:8080
|
ENV NODE_ENV=production
|
||||||
RUN npm run build
|
# Build-time public env (baked into client bundle). You can override by rebuilding.
|
||||||
|
ARG NEXT_PUBLIC_API_BASE_URL=http://battlbuilder-api:8080
|
||||||
FROM node:20-alpine AS runner
|
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
|
||||||
WORKDIR /app
|
RUN npm run build
|
||||||
|
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
# ---------- runner ----------
|
||||||
COPY --from=builder /app/.next ./.next
|
FROM node:20-alpine AS runner
|
||||||
COPY --from=builder /app/public ./public
|
WORKDIR /app
|
||||||
COPY --from=builder /app/package.json ./package.json
|
ENV NODE_ENV=production
|
||||||
EXPOSE 3000
|
|
||||||
CMD ["npm", "start"]
|
# If you want slightly better security:
|
||||||
|
RUN addgroup -S nextjs && adduser -S nextjs -G nextjs
|
||||||
|
|
||||||
|
# Standalone output includes server + minimal node_modules
|
||||||
|
COPY --from=builder /app/.next/standalone ./
|
||||||
|
COPY --from=builder /app/.next/static ./.next/static
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["node", "server.js"]
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
FROM node:20-alpine AS deps
|
||||||
|
RUN apk add curl
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci --only=production
|
||||||
|
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY . .
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV NEXT_PUBLIC_API_BASE_URL=http://battlbuilder-api:8080
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:20-alpine AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY --from=builder /app/.next ./.next
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
COPY --from=builder /app/package.json ./package.json
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["npm", "start"]
|
||||||
Vendored
+2
-1
@@ -1,5 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
|
/// <reference path="./.next/types/routes.d.ts" />
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
+10
-8
@@ -3,6 +3,7 @@ const nextConfig = {
|
|||||||
experimental: {
|
experimental: {
|
||||||
appDir: true,
|
appDir: true,
|
||||||
},
|
},
|
||||||
|
output: "standalone",
|
||||||
typescript: {
|
typescript: {
|
||||||
ignoreBuildErrors: true,
|
ignoreBuildErrors: true,
|
||||||
},
|
},
|
||||||
@@ -10,14 +11,15 @@ const nextConfig = {
|
|||||||
ignoreDuringBuilds: true,
|
ignoreDuringBuilds: true,
|
||||||
},
|
},
|
||||||
|
|
||||||
/* async rewrites() {
|
// If you want rewrites, uncomment this block:
|
||||||
return [
|
// async rewrites() {
|
||||||
{
|
// return [
|
||||||
source: "/api/:path*",
|
// {
|
||||||
destination: "http://battlbuilder-api:8080/api/:path*",
|
// source: "/api/:path*",
|
||||||
},
|
// destination: "http://battlbuilder-api:8080/api/:path*",
|
||||||
];
|
// },
|
||||||
},*/
|
// ];
|
||||||
|
// },
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
Generated
+863
-691
File diff suppressed because it is too large
Load Diff
+31
-11
@@ -1,34 +1,54 @@
|
|||||||
{
|
{
|
||||||
"name": "gunbuilder-prototype",
|
"name": "battle-builder-webui",
|
||||||
"version": "0.1.0",
|
"version": "0.9.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"author": {
|
||||||
|
"name": "Forward Group, LLC",
|
||||||
|
"email": "info@goforward.group",
|
||||||
|
"url": "https://goforward.group"
|
||||||
|
},
|
||||||
|
"contributors": [
|
||||||
|
{
|
||||||
|
"name": "Sean Strawsburg",
|
||||||
|
"email": "sean@goforward.group"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Don Strawsburg",
|
||||||
|
"email": "don@goforward.group"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "UNLICENSED",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev --turbopack",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint"
|
"lint": "eslint ."
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@headlessui/react": "^2.2.9",
|
"@headlessui/react": "^2.2.9",
|
||||||
"@heroicons/react": "^2.2.0",
|
"@heroicons/react": "^2.2.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.555.0",
|
"lucide-react": "^0.555.0",
|
||||||
"next": "14.2.3",
|
"next": "15.5.9",
|
||||||
"quill": "^2.0.3",
|
"quill": "^2.0.3",
|
||||||
"quill-image-resize-module": "^3.0.0",
|
"quill-image-resize-module": "^3.0.0",
|
||||||
"react": "18.3.1",
|
"react": "19.2.3",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "19.2.3",
|
||||||
"react-quill": "^2.0.0"
|
"react-quill": "^2.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "20.12.7",
|
"@types/node": "20.12.7",
|
||||||
"@types/react": "18.3.3",
|
"@types/react": "19.2.9",
|
||||||
"@types/react-dom": "18.3.0",
|
"@types/react-dom": "19.2.3",
|
||||||
"autoprefixer": "10.4.19",
|
"autoprefixer": "10.4.19",
|
||||||
"eslint": "8.57.0",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "14.2.3",
|
"eslint-config-next": "15.5.9",
|
||||||
"postcss": "8.4.38",
|
"postcss": "8.4.38",
|
||||||
"tailwindcss": "3.4.3",
|
"tailwindcss": "3.4.3",
|
||||||
"typescript": "5.4.5"
|
"typescript": "5.4.5"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"@types/react": "19.2.9",
|
||||||
|
"@types/react-dom": "19.2.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -e
|
|
||||||
|
|
||||||
REGISTRY="gitea.gofwd.group"
|
|
||||||
OWNER="sean/shadow-gunbuilder-ai-proto"
|
|
||||||
IMAGE="webui"
|
|
||||||
|
|
||||||
TAG=$(git rev-parse --short HEAD)
|
|
||||||
|
|
||||||
FULL_IMAGE="$REGISTRY/$OWNER/$IMAGE"
|
|
||||||
|
|
||||||
echo "Building $FULL_IMAGE:$TAG"
|
|
||||||
docker build -f frontend/Dockerfile --no-cache -t $FULL_IMAGE:$TAG .
|
|
||||||
|
|
||||||
echo "Tagging latest"
|
|
||||||
docker tag $FULL_IMAGE:$TAG $FULL_IMAGE:latest
|
|
||||||
|
|
||||||
echo "Pushing $TAG"
|
|
||||||
docker push $FULL_IMAGE:$TAG
|
|
||||||
|
|
||||||
echo "Pushing latest"
|
|
||||||
docker push $FULL_IMAGE:latest
|
|
||||||
|
|
||||||
echo "Done!"
|
|
||||||
Executable
+23
@@ -0,0 +1,23 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REGISTRY="gitea.gofwd.group"
|
||||||
|
OWNER="sean/shadow-gunbuilder-ai-proto"
|
||||||
|
IMAGE="webui"
|
||||||
|
|
||||||
|
TAG="$(git rev-parse --short HEAD)"
|
||||||
|
FULL_IMAGE="$REGISTRY/$OWNER/$IMAGE"
|
||||||
|
|
||||||
|
EXTRA_ARGS=()
|
||||||
|
if [[ "${NO_CACHE:-0}" == "1" ]]; then
|
||||||
|
EXTRA_ARGS+=(--no-cache)
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker buildx build \
|
||||||
|
-f frontend/Dockerfile \
|
||||||
|
--platform linux/amd64,linux/arm64 \
|
||||||
|
-t "$FULL_IMAGE:$TAG" \
|
||||||
|
-t "$FULL_IMAGE:latest" \
|
||||||
|
--push \
|
||||||
|
"${EXTRA_ARGS[@]}" \
|
||||||
|
.
|
||||||
Reference in New Issue
Block a user