builds page concept. rename project
This commit is contained in:
@@ -0,0 +1,436 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { CATEGORIES } from "@/data/gunbuilderParts";
|
||||
import type { CategoryId } from "@/types/gunbuilder";
|
||||
import {
|
||||
getRetailerOffers,
|
||||
type RetailerOffer,
|
||||
} from "./data";
|
||||
|
||||
type GunbuilderProductFromApi = {
|
||||
id: string; // backend UUID string
|
||||
name: string;
|
||||
brand: string;
|
||||
platform: string;
|
||||
partRole: string;
|
||||
price: number | null;
|
||||
mainImageUrl: string | null;
|
||||
buyUrl: string | null;
|
||||
};
|
||||
|
||||
const API_BASE_URL =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
||||
|
||||
export default function PartDetailPage() {
|
||||
const params = useParams();
|
||||
const categoryId = params.categoryId as CategoryId;
|
||||
const partId = params.partId as string; // this is the UUID we passed in the link
|
||||
|
||||
const [product, setProduct] = useState<GunbuilderProductFromApi | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [offers, setOffers] = useState<RetailerOffer[]>([]);
|
||||
const [offersLoading, setOffersLoading] = useState(true);
|
||||
const [offersError, setOffersError] = useState<string | null>(null);
|
||||
|
||||
const category = useMemo(
|
||||
() => CATEGORIES.find((c) => c.id === categoryId),
|
||||
[categoryId],
|
||||
);
|
||||
|
||||
// 1) Load product (same as before)
|
||||
useEffect(() => {
|
||||
if (!partId) return;
|
||||
|
||||
const controller = new AbortController();
|
||||
|
||||
async function fetchProduct() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// For now, pull the full AR-15 list and find by UUID.
|
||||
// We can optimize with a dedicated /api/products/{uuid} later.
|
||||
const url = `${API_BASE_URL}/api/products/gunbuilder?platform=AR-15`;
|
||||
const res = await fetch(url, { signal: controller.signal });
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to load product (${res.status})`);
|
||||
}
|
||||
|
||||
const data: GunbuilderProductFromApi[] = await res.json();
|
||||
const found = data.find((p) => p.id === partId) ?? null;
|
||||
|
||||
if (!found) {
|
||||
setError("Product not found");
|
||||
}
|
||||
|
||||
setProduct(found);
|
||||
} catch (err: any) {
|
||||
if (err.name === "AbortError") return;
|
||||
setError(err.message ?? "Failed to load product");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchProduct();
|
||||
|
||||
return () => controller.abort();
|
||||
}, [partId]);
|
||||
|
||||
// 2) Load offers for this product from Ballistic backend
|
||||
useEffect(() => {
|
||||
if (!partId) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
async function fetchOffers() {
|
||||
try {
|
||||
setOffersLoading(true);
|
||||
setOffersError(null);
|
||||
|
||||
const data = await getRetailerOffers(partId);
|
||||
|
||||
if (!cancelled) {
|
||||
setOffers(data);
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (!cancelled) {
|
||||
setOffersError(err.message ?? "Failed to load retailer offers");
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setOffersLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fetchOffers();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [partId]);
|
||||
|
||||
// Best price: prefer live offers, fall back to summary product.price
|
||||
const bestPrice = useMemo(() => {
|
||||
if (offers.length > 0) {
|
||||
return offers[0].price;
|
||||
}
|
||||
return product?.price ?? null;
|
||||
}, [offers, product]);
|
||||
|
||||
if (!category) {
|
||||
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">
|
||||
<h1 className="text-2xl font-semibold mb-4">Category Not Found</h1>
|
||||
<Link
|
||||
href="/gunbuilder"
|
||||
className="text-amber-300 hover:text-amber-200 underline"
|
||||
>
|
||||
Return to Gunbuilder
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-black text-zinc-50">
|
||||
<div className="mx-auto max-w-5xl px-4 py-6 lg:py-10">
|
||||
{/* Breadcrumbs */}
|
||||
<nav className="mb-4 text-xs text-zinc-500">
|
||||
<Link
|
||||
href="/gunbuilder"
|
||||
className="hover:text-zinc-300 transition-colors"
|
||||
>
|
||||
Gunbuilder
|
||||
</Link>
|
||||
<span className="mx-1">/</span>
|
||||
<Link
|
||||
href={`/gunbuilder/${categoryId}`}
|
||||
className="hover:text-zinc-300 transition-colors"
|
||||
>
|
||||
{category.name}
|
||||
</Link>
|
||||
{product && (
|
||||
<>
|
||||
<span className="mx-1">/</span>
|
||||
<span className="text-zinc-300">{product.name}</span>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{loading ? (
|
||||
<p className="text-sm text-zinc-500">Loading product…</p>
|
||||
) : error || !product ? (
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold mb-2">Product Unavailable</h1>
|
||||
<p className="text-sm text-zinc-500 mb-4">
|
||||
{error ?? "We couldn’t find this product."}
|
||||
</p>
|
||||
<Link
|
||||
href={`/gunbuilder/${categoryId}`}
|
||||
className="text-amber-300 hover:text-amber-200 underline text-sm"
|
||||
>
|
||||
Back to {category.name} parts
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-6 md:grid-cols-[minmax(0,2fr)_minmax(0,1.3fr)] items-start">
|
||||
{/* Left: image + meta */}
|
||||
<div className="space-y-4">
|
||||
{product.mainImageUrl && (
|
||||
<div className="overflow-hidden rounded-lg border border-zinc-800 bg-zinc-950">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={product.mainImageUrl}
|
||||
alt={product.name}
|
||||
className="w-full object-contain max-h-80 bg-zinc-950"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
|
||||
{product.brand}
|
||||
</div>
|
||||
<h1 className="text-2xl md:text-3xl font-semibold tracking-tight">
|
||||
{product.name}
|
||||
</h1>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 text-xs">
|
||||
<p className="text-zinc-500">
|
||||
Platform:{" "}
|
||||
<span className="text-zinc-300">{product.platform}</span>
|
||||
</p>
|
||||
<p className="text-zinc-500">
|
||||
Role:{" "}
|
||||
<span className="text-zinc-300">{product.partRole}</span>
|
||||
</p>
|
||||
<p className="text-zinc-500">
|
||||
Category:{" "}
|
||||
<span className="text-zinc-300">{category.name}</span>
|
||||
</p>
|
||||
<p className="text-zinc-500">
|
||||
Offers:{" "}
|
||||
<span className="text-zinc-300">
|
||||
{offers.length > 0
|
||||
? `${offers.length} live offer${
|
||||
offers.length === 1 ? "" : "s"
|
||||
}`
|
||||
: "No live offers yet"}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Placeholder content until long-form fields are wired up */}
|
||||
<section className="mt-4 space-y-3 text-sm text-zinc-300">
|
||||
<h2 className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
|
||||
Product Overview
|
||||
</h2>
|
||||
<p className="text-zinc-400">
|
||||
This is placeholder copy for the product overview. Once
|
||||
the Ballistic backend exposes richer metadata (short
|
||||
description, feature bullets, etc.), we'll swap this
|
||||
text out and surface the real content here.
|
||||
</p>
|
||||
<p className="text-zinc-500 text-xs">
|
||||
Use this section to highlight what makes this part worth
|
||||
a slot in your build: materials, intended use
|
||||
(duty/range/competition), and any standout features.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mt-4 rounded-lg border border-zinc-800 bg-zinc-950/70 p-3">
|
||||
<h2 className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500 mb-2">
|
||||
Quick Specs (Placeholder)
|
||||
</h2>
|
||||
<dl className="grid grid-cols-2 gap-x-4 gap-y-2 text-xs text-zinc-400">
|
||||
<div>
|
||||
<dt className="text-zinc-500">Configuration</dt>
|
||||
<dd className="text-zinc-200">
|
||||
TBD (Stripped / Complete / Kit)
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-zinc-500">Caliber</dt>
|
||||
<dd className="text-zinc-200">TBD from feed</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-zinc-500">Finish</dt>
|
||||
<dd className="text-zinc-200">TBD from merchant data</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-zinc-500">Weight</dt>
|
||||
<dd className="text-zinc-200">TBD (oz)</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p className="mt-2 text-[11px] text-zinc-500">
|
||||
Specs are placeholders for now. As we normalize more
|
||||
structured attributes in the importer, this block will
|
||||
auto-populate per product.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Right: pricing + actions */}
|
||||
<aside className="rounded-lg border border-zinc-800 bg-zinc-950/70 p-4 space-y-4">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-zinc-500 mb-1">
|
||||
Price
|
||||
</p>
|
||||
<p className="text-2xl font-semibold text-amber-300">
|
||||
{bestPrice && bestPrice > 0
|
||||
? `$${bestPrice.toFixed(2)}`
|
||||
: "—"}
|
||||
</p>
|
||||
{!bestPrice && (
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
Pricing not available yet. Live pricing will appear once
|
||||
offers are imported.
|
||||
</p>
|
||||
)}
|
||||
{offers.length > 0 && (
|
||||
<p className="mt-1 text-[11px] text-zinc-500">
|
||||
Showing the best available price across all live
|
||||
offers for this part.
|
||||
</p>
|
||||
)}
|
||||
{offers.length > 1 && (
|
||||
<p className="mt-1 text-[11px] text-zinc-500">
|
||||
Price range across retailers:{" "}
|
||||
<span className="text-zinc-300">
|
||||
${Math.min(...offers.map(o => o.price)).toFixed(2)} – ${Math.max(...offers.map(o => o.price)).toFixed(2)}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Offers list */}
|
||||
<div className="space-y-2">
|
||||
{offersLoading && (
|
||||
<p className="text-xs text-zinc-500">Loading offers…</p>
|
||||
)}
|
||||
{offersError && (
|
||||
<p className="text-xs text-red-400">{offersError}</p>
|
||||
)}
|
||||
{!offersLoading && !offersError && offers.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{offers.map((offer) => (
|
||||
<a
|
||||
key={offer.id}
|
||||
href={offer.affiliateUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center justify-between rounded-md border border-zinc-700 bg-zinc-900 px-3 py-1.5 text-xs hover:bg-zinc-800 transition-colors"
|
||||
>
|
||||
<span className="text-zinc-200">
|
||||
{offer.retailerName}
|
||||
</span>
|
||||
<span className="font-semibold text-amber-300">
|
||||
${offer.price.toFixed(2)}
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!offersLoading &&
|
||||
!offersError &&
|
||||
offers.length === 0 && (
|
||||
<p className="text-xs text-zinc-500">
|
||||
No live retailer offers yet. As feeds are imported,
|
||||
merchants and pricing will show up here.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Link
|
||||
href={`/gunbuilder?select=${categoryId}:${product.id}`}
|
||||
className="block w-full rounded-md bg-amber-400 text-black text-sm font-semibold text-center py-2.5 hover:bg-amber-300 transition-colors"
|
||||
>
|
||||
Add to Build
|
||||
</Link>
|
||||
|
||||
{product.buyUrl ? (
|
||||
<a
|
||||
href={product.buyUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="block w-full rounded-md border border-zinc-700 bg-zinc-900 text-xs font-medium text-zinc-200 text-center py-2 hover:bg-zinc-800 transition-colors"
|
||||
>
|
||||
View on Merchant Site
|
||||
</a>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
className="block w-full rounded-md border border-zinc-800 bg-zinc-900/60 text-xs font-medium text-zinc-500 text-center py-2 cursor-not-allowed"
|
||||
>
|
||||
External link coming soon
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{/* Lower content: builder-focused helper copy (placeholder) */}
|
||||
<section className="mt-10 grid gap-6 md:grid-cols-3 text-sm md:grid-cols-4">
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
|
||||
Why We Like This Part
|
||||
</h2>
|
||||
<p className="text-zinc-400">
|
||||
Drop in a short blurb here about what makes this part
|
||||
worth picking over similar options — think reliability,
|
||||
track record, and value for the money.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
|
||||
Best For
|
||||
</h2>
|
||||
<p className="text-zinc-400">
|
||||
Use this block to call out ideal use cases:{" "}
|
||||
<span className="text-zinc-200">
|
||||
duty rifle, home defense, range toy, competition, night
|
||||
work
|
||||
</span>
|
||||
, etc.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
|
||||
Builder Notes
|
||||
</h2>
|
||||
<p className="text-zinc-400">
|
||||
Add any compatibility quirks or install tips here once
|
||||
the compatibility engine is wired up — gas system length,
|
||||
buffer recommendations, known fitment notes, and more.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
|
||||
Compatibility
|
||||
</h2>
|
||||
<div className="inline-flex items-center gap-2 rounded-md border border-amber-400/30 bg-amber-400/10 px-2 py-1 text-[11px] text-amber-300">
|
||||
<span className="inline-block h-2 w-2 rounded-full bg-amber-300 animate-pulse"></span>
|
||||
Compatibility engine coming online soon
|
||||
</div>
|
||||
<p className="text-zinc-500 text-xs">
|
||||
Fitment checks, caliber validation, buffer/gas system logic, and platform rules will appear here once the engine is active.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { CATEGORIES } from "@/data/gunbuilderParts";
|
||||
import type { CategoryId, Part } from "@/types/gunbuilder";
|
||||
import { PART_ROLE_TO_CATEGORY } from "@/data/partRoleMappings";
|
||||
import { Pencil, Link2 } from "lucide-react";
|
||||
|
||||
type GunbuilderProductFromApi = {
|
||||
id: number; // backend numeric id
|
||||
@@ -598,17 +599,21 @@ export default function GunbuilderPage() {
|
||||
<>
|
||||
<Link
|
||||
href={`/gunbuilder/${category.id}`}
|
||||
className="hidden md:inline-flex rounded-md border border-zinc-700 bg-zinc-900/70 px-2.5 py-1 text-[0.7rem] font-medium text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600 transition-colors"
|
||||
className="hidden md:inline-flex items-center justify-center rounded-md border border-zinc-700 bg-zinc-900/70 px-2 py-1 hover:bg-zinc-800 hover:border-zinc-600 transition-colors"
|
||||
aria-label="Change part"
|
||||
title="Change part"
|
||||
>
|
||||
Change Part
|
||||
<Pencil className="h-3.5 w-3.5 text-zinc-300" />
|
||||
</Link>
|
||||
<a
|
||||
href={selectedPart.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex rounded-md bg-amber-400 px-2.5 py-1 text-[0.7rem] font-semibold text-black hover:bg-amber-300 transition-colors"
|
||||
className="inline-flex items-center justify-center rounded-md bg-amber-400 px-2 py-1 hover:bg-amber-300 transition-colors"
|
||||
aria-label="Buy part"
|
||||
title="Buy part"
|
||||
>
|
||||
Buy
|
||||
<Link2 className="h-3.5 w-3.5 text-black" />
|
||||
</a>
|
||||
</>
|
||||
) : hasParts ? (
|
||||
@@ -0,0 +1,366 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
type BuildClass = "Rifle" | "Pistol" | "NFA";
|
||||
type Caliber = "5.56" | "7.62" | "9mm" | ".300 BLK" | "12ga";
|
||||
|
||||
type BuildCard = {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
creator: string;
|
||||
caliber: Caliber;
|
||||
buildClass: BuildClass;
|
||||
price: number;
|
||||
votes: number;
|
||||
tags: string[];
|
||||
};
|
||||
|
||||
const DUMMY_BUILDS: BuildCard[] = [
|
||||
{
|
||||
id: "1",
|
||||
title: "Duty-Grade 12.5\" AR-15",
|
||||
slug: "duty-12-5-ar15",
|
||||
creator: "quietpro_01",
|
||||
caliber: "5.56",
|
||||
buildClass: "Rifle",
|
||||
price: 2450,
|
||||
votes: 128,
|
||||
tags: ["Duty", "NV-Ready", "LPVO"],
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
title: "Home Defense PCC",
|
||||
slug: "home-defense-pcc",
|
||||
creator: "nerdgunner",
|
||||
caliber: "9mm",
|
||||
buildClass: "Pistol",
|
||||
price: 1650,
|
||||
votes: 74,
|
||||
tags: ["Home Defense", "Red Dot", "Suppressor-Ready"],
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
title: "Short King .300 BLK SBR",
|
||||
slug: "short-king-300blk-sbr",
|
||||
creator: "subsonic_six",
|
||||
caliber: ".300 BLK",
|
||||
buildClass: "NFA",
|
||||
price: 3125,
|
||||
votes: 201,
|
||||
tags: ["SBR", "Suppressed", "Night Work"],
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
title: "Budget 16\" Training Rifle",
|
||||
slug: "budget-16-training-rifle",
|
||||
creator: "range_rat",
|
||||
caliber: "5.56",
|
||||
buildClass: "Rifle",
|
||||
price: 975,
|
||||
votes: 53,
|
||||
tags: ["Budget", "Trainer", "Recce-ish"],
|
||||
},
|
||||
];
|
||||
|
||||
const CALIBER_FILTERS: (Caliber | "all")[] = [
|
||||
"all",
|
||||
"5.56",
|
||||
"7.62",
|
||||
"9mm",
|
||||
".300 BLK",
|
||||
"12ga",
|
||||
];
|
||||
|
||||
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 },
|
||||
{ id: "1to2k", label: "$1k–$2k", min: 1000, max: 2000 },
|
||||
{ id: "2to3k", label: "$2k–$3k", min: 2000, max: 3000 },
|
||||
{ id: "3kplus", label: "$3k+", min: 3000, max: Infinity },
|
||||
];
|
||||
|
||||
export default function BuildsPage() {
|
||||
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(DUMMY_BUILDS.map((b) => [b.id, b.votes])),
|
||||
);
|
||||
|
||||
const activePriceFilter = PRICE_FILTERS.find(
|
||||
(p) => p.id === priceFilterId,
|
||||
) ?? PRICE_FILTERS[0];
|
||||
|
||||
const filteredBuilds = useMemo(() => {
|
||||
return DUMMY_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;
|
||||
});
|
||||
}, [caliberFilter, classFilter, activePriceFilter]);
|
||||
|
||||
const handleVote = (id: string, delta: 1 | -1) => {
|
||||
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 rifle builds, vote them up or down, and steal
|
||||
good ideas without shame. This is placeholder content for now —
|
||||
real accounts, comments, and saved builds will wire in later.
|
||||
</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>
|
||||
<button
|
||||
type="button"
|
||||
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 (Coming Soon)
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 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, platform class, and ballpark price range.
|
||||
All logic is client-side placeholder until the real feed is wired
|
||||
into the backend.
|
||||
</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">
|
||||
{DUMMY_BUILDS.length}
|
||||
</span>{" "}
|
||||
demo 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"
|
||||
>
|
||||
▲
|
||||
</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"
|
||||
>
|
||||
▼
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Placeholder 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="https://placehold.co/320x200/png?text=Build+Photo"
|
||||
alt={`${build.title} placeholder`}
|
||||
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>{" "}
|
||||
· Placeholder build listing — details, parts list, and
|
||||
comments will live on the build detail page later.
|
||||
</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.toLocaleString()}
|
||||
</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, and share coming soon.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
|
||||
{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. Once the real feed is wired
|
||||
up, this will update live as the community posts rifles, pistols,
|
||||
and NFA builds.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,298 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { CATEGORIES } from "@/data/gunbuilderParts";
|
||||
import type { CategoryId } from "@/types/gunbuilder";
|
||||
import {
|
||||
getRetailerOffers,
|
||||
type RetailerOffer,
|
||||
} from "./data";
|
||||
|
||||
type GunbuilderProductFromApi = {
|
||||
id: string; // backend UUID string
|
||||
name: string;
|
||||
brand: string;
|
||||
platform: string;
|
||||
partRole: string;
|
||||
price: number | null;
|
||||
mainImageUrl: string | null;
|
||||
buyUrl: string | null;
|
||||
};
|
||||
|
||||
const API_BASE_URL =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
||||
|
||||
export default function PartDetailPage() {
|
||||
const params = useParams();
|
||||
const categoryId = params.categoryId as CategoryId;
|
||||
const partId = params.partId as string; // this is the UUID we passed in the link
|
||||
|
||||
const [product, setProduct] = useState<GunbuilderProductFromApi | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [offers, setOffers] = useState<RetailerOffer[]>([]);
|
||||
const [offersLoading, setOffersLoading] = useState(true);
|
||||
const [offersError, setOffersError] = useState<string | null>(null);
|
||||
|
||||
const category = useMemo(
|
||||
() => CATEGORIES.find((c) => c.id === categoryId),
|
||||
[categoryId],
|
||||
);
|
||||
|
||||
// 1) Load product (same as before)
|
||||
useEffect(() => {
|
||||
if (!partId) return;
|
||||
|
||||
const controller = new AbortController();
|
||||
|
||||
async function fetchProduct() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// For now, pull the full AR-15 list and find by UUID.
|
||||
// We can optimize with a dedicated /api/products/{uuid} later.
|
||||
const url = `${API_BASE_URL}/api/products/gunbuilder?platform=AR-15`;
|
||||
const res = await fetch(url, { signal: controller.signal });
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to load product (${res.status})`);
|
||||
}
|
||||
|
||||
const data: GunbuilderProductFromApi[] = await res.json();
|
||||
const found = data.find((p) => p.id === partId) ?? null;
|
||||
|
||||
if (!found) {
|
||||
setError("Product not found");
|
||||
}
|
||||
|
||||
setProduct(found);
|
||||
} catch (err: any) {
|
||||
if (err.name === "AbortError") return;
|
||||
setError(err.message ?? "Failed to load product");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchProduct();
|
||||
|
||||
return () => controller.abort();
|
||||
}, [partId]);
|
||||
|
||||
// 2) Load offers for this product from Ballistic backend
|
||||
useEffect(() => {
|
||||
if (!partId) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
async function fetchOffers() {
|
||||
try {
|
||||
setOffersLoading(true);
|
||||
setOffersError(null);
|
||||
|
||||
const data = await getRetailerOffers(partId);
|
||||
|
||||
if (!cancelled) {
|
||||
setOffers(data);
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (!cancelled) {
|
||||
setOffersError(err.message ?? "Failed to load retailer offers");
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setOffersLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fetchOffers();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [partId]);
|
||||
|
||||
// Best price: prefer live offers, fall back to summary product.price
|
||||
const bestPrice = useMemo(() => {
|
||||
if (offers.length > 0) {
|
||||
return offers[0].price;
|
||||
}
|
||||
return product?.price ?? null;
|
||||
}, [offers, product]);
|
||||
|
||||
if (!category) {
|
||||
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">
|
||||
<h1 className="text-2xl font-semibold mb-4">Category Not Found</h1>
|
||||
<Link
|
||||
href="/gunbuilder"
|
||||
className="text-amber-300 hover:text-amber-200 underline"
|
||||
>
|
||||
Return to Gunbuilder
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-black text-zinc-50">
|
||||
<div className="mx-auto max-w-5xl px-4 py-6 lg:py-10">
|
||||
{/* Breadcrumbs */}
|
||||
<nav className="mb-4 text-xs text-zinc-500">
|
||||
<Link
|
||||
href="/gunbuilder"
|
||||
className="hover:text-zinc-300 transition-colors"
|
||||
>
|
||||
Gunbuilder
|
||||
</Link>
|
||||
<span className="mx-1">/</span>
|
||||
<Link
|
||||
href={`/gunbuilder/${categoryId}`}
|
||||
className="hover:text-zinc-300 transition-colors"
|
||||
>
|
||||
{category.name}
|
||||
</Link>
|
||||
{product && (
|
||||
<>
|
||||
<span className="mx-1">/</span>
|
||||
<span className="text-zinc-300">{product.name}</span>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{loading ? (
|
||||
<p className="text-sm text-zinc-500">Loading product…</p>
|
||||
) : error || !product ? (
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold mb-2">Product Unavailable</h1>
|
||||
<p className="text-sm text-zinc-500 mb-4">
|
||||
{error ?? "We couldn’t find this product."}
|
||||
</p>
|
||||
<Link
|
||||
href={`/gunbuilder/${categoryId}`}
|
||||
className="text-amber-300 hover:text-amber-200 underline text-sm"
|
||||
>
|
||||
Back to {category.name} parts
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-6 md:grid-cols-[minmax(0,2fr)_minmax(0,1.3fr)] items-start">
|
||||
{/* Left: image + meta */}
|
||||
<div className="space-y-4">
|
||||
{product.mainImageUrl && (
|
||||
<div className="overflow-hidden rounded-lg border border-zinc-800 bg-zinc-950">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={product.mainImageUrl}
|
||||
alt={product.name}
|
||||
className="w-full object-contain max-h-80 bg-zinc-950"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
|
||||
{product.brand}
|
||||
</div>
|
||||
<h1 className="text-2xl md:text-3xl font-semibold tracking-tight">
|
||||
{product.name}
|
||||
</h1>
|
||||
<p className="text-xs text-zinc-500">
|
||||
Platform:{" "}
|
||||
<span className="text-zinc-300">{product.platform}</span>
|
||||
</p>
|
||||
<p className="text-xs text-zinc-500">
|
||||
Role: <span className="text-zinc-300">{product.partRole}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Right: pricing + actions */}
|
||||
<aside className="rounded-lg border border-zinc-800 bg-zinc-950/70 p-4 space-y-4">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-zinc-500 mb-1">
|
||||
Price
|
||||
</p>
|
||||
<p className="text-2xl font-semibold text-amber-300">
|
||||
{bestPrice && bestPrice > 0
|
||||
? `$${bestPrice.toFixed(2)}`
|
||||
: "—"}
|
||||
</p>
|
||||
{!bestPrice && (
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
Pricing not available yet. Live pricing will appear once
|
||||
offers are imported.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Offers list */}
|
||||
<div className="space-y-2">
|
||||
{offersLoading && (
|
||||
<p className="text-xs text-zinc-500">Loading offers…</p>
|
||||
)}
|
||||
{offersError && (
|
||||
<p className="text-xs text-red-400">
|
||||
{offersError}
|
||||
</p>
|
||||
)}
|
||||
{!offersLoading && !offersError && offers.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{offers.map((offer) => (
|
||||
<a
|
||||
key={offer.id}
|
||||
href={offer.affiliateUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center justify-between rounded-md border border-zinc-700 bg-zinc-900 px-3 py-1.5 text-xs hover:bg-zinc-800 transition-colors"
|
||||
>
|
||||
<span className="text-zinc-200">
|
||||
{offer.retailerName}
|
||||
</span>
|
||||
<span className="font-semibold text-amber-300">
|
||||
${offer.price.toFixed(2)}
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Link
|
||||
href={`/gunbuilder?select=${categoryId}:${product.id}`}
|
||||
className="block w-full rounded-md bg-amber-400 text-black text-sm font-semibold text-center py-2.5 hover:bg-amber-300 transition-colors"
|
||||
>
|
||||
Add to Build
|
||||
</Link>
|
||||
|
||||
{product.buyUrl ? (
|
||||
<a
|
||||
href={product.buyUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="block w-full rounded-md border border-zinc-700 bg-zinc-900 text-xs font-medium text-zinc-200 text-center py-2 hover:bg-zinc-800 transition-colors"
|
||||
>
|
||||
View on Merchant Site
|
||||
</a>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
className="block w-full rounded-md border border-zinc-800 bg-zinc-900/60 text-xs font-medium text-zinc-500 text-center py-2 cursor-not-allowed"
|
||||
>
|
||||
External link coming soon
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
+8
-1
@@ -1,4 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import "./globals.css";
|
||||
import type { ReactNode } from "react";
|
||||
@@ -6,6 +5,14 @@ import { AuthProvider } from "@/context/AuthContext";
|
||||
import { TopNav } from "@/components/TopNav";
|
||||
import { BuilderNav } from "@/components/BuilderNav";
|
||||
|
||||
export const metadata = {
|
||||
title: {
|
||||
default: "Battl Builder",
|
||||
template: "%s | Battl Builder",
|
||||
},
|
||||
description: "Battl Builder — the smarter, faster, data‑driven way to build your next firearm.",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
|
||||
+12
-12
@@ -5,27 +5,27 @@ export default function HomePage() {
|
||||
<main className="min-h-screen bg-black text-zinc-50 flex items-center justify-center px-4">
|
||||
<div className="max-w-xl text-center">
|
||||
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
|
||||
Shadow Standard Co.
|
||||
For Those Who Build With Intent.
|
||||
</p>
|
||||
<h1 className="mt-2 text-3xl md:text-4xl font-semibold tracking-tight">
|
||||
The Build Bench
|
||||
<h1
|
||||
className="mt-2 text-4xl md:text-5xl font-extrabold tracking-tight
|
||||
bg-gradient-to-r from-amber-400 via-amber-200 to-amber-400
|
||||
text-transparent uppercase bg-clip-text drop-shadow-[0_2px_6px_rgba(255,193,7,0.25)]"
|
||||
>
|
||||
Battl Builder
|
||||
</h1>
|
||||
<p className="mt-3 text-sm md:text-base text-zinc-400">
|
||||
Shadow Standard’s Armory Build Bench isn’t a catalog — it’s a
|
||||
workbench for people who take their rifles, and their craft,
|
||||
seriously. We pull live parts and pricing from the brands that matter,
|
||||
lay everything out with purpose, and let you shape a rifle the same
|
||||
way you’d shape a hunt or a mission: one smart choice at a time. Track
|
||||
your build cost, swap components on the fly, save your setup, and
|
||||
share it with the crew. No noise. No gimmicks. Just clean information
|
||||
and the tools to build something worth carrying.
|
||||
Battl Builder is your command center for rifle builds. We surface live
|
||||
parts and hard-use brands, giving you a mission-ready workbench to
|
||||
design smarter, track cost, swap components, and save setups. No
|
||||
fluff. Just clean, trustworthy data.
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<Link
|
||||
href="/gunbuilder"
|
||||
className="inline-flex items-center justify-center rounded-md border border-amber-400/70 bg-amber-400/10 px-4 py-2 text-sm font-medium text-amber-200 hover:bg-amber-400/15"
|
||||
>
|
||||
Open The Build Bench
|
||||
Launch The Builder
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user