2 Commits

Author SHA1 Message Date
sean 777618f684 fixed platform selector and part selector 2025-12-12 07:15:27 -05:00
sean b26dcb947e fixing platform routing. not done. updated logo on builder 2025-12-12 07:15:27 -05:00
9 changed files with 718 additions and 668 deletions
@@ -27,7 +27,10 @@ const API_BASE_URL =
export default function PartDetailPage() { export default function PartDetailPage() {
const params = useParams(); const params = useParams();
const categoryId = params.categoryId as CategoryId; const categoryId = params.categoryId as CategoryId;
const partId = params.partId as string; // this is the UUID we passed in the link
// Support URLs like /builder/lower/25969-m5-complete-lower-receiver
const rawPartParam = params.partId as string;
const partId = rawPartParam.split("-")[0]; // "25969-m5-..." -> "25969"
const [product, setProduct] = useState<GunbuilderProductFromApi | null>(null); const [product, setProduct] = useState<GunbuilderProductFromApi | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
+101 -42
View File
@@ -2,11 +2,10 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter, useSearchParams } from "next/navigation";
import { CATEGORIES } from "@/data/gunbuilderParts"; import { CATEGORIES } from "@/data/gunbuilderParts";
import type { CategoryId, Part } from "@/types/gunbuilder"; import type { CategoryId, Part } from "@/types/gunbuilder";
type ViewMode = "card" | "list"; type ViewMode = "card" | "list";
type GunbuilderProductFromApi = { type GunbuilderProductFromApi = {
@@ -26,10 +25,11 @@ type UiPart = Part & {
inStock?: boolean; inStock?: boolean;
}; };
const PLATFORMS = ["AR-15", "AR-10"];
const API_BASE_URL = const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080"; process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
// sort options // sort options
type SortOption = "relevance" | "price-asc" | "price-desc" | "brand-asc"; type SortOption = "relevance" | "price-asc" | "price-desc" | "brand-asc";
@@ -37,10 +37,18 @@ type SortOption = "relevance" | "price-asc" | "price-desc" | "brand-asc";
type BuildState = Partial<Record<CategoryId, string>>; type BuildState = Partial<Record<CategoryId, string>>;
const STORAGE_KEY = "gunbuilder-build-state"; const STORAGE_KEY = "gunbuilder-build-state";
// support for url id-slug
const slugify = (str: string) =>
str
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "");
export default function CategoryPage() { export default function CategoryPage() {
const params = useParams(); const params = useParams();
const categoryId = params.categoryId as CategoryId; const categoryId = params.categoryId as CategoryId;
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams();
const [viewMode, setViewMode] = useState<ViewMode>("list"); const [viewMode, setViewMode] = useState<ViewMode>("list");
const [parts, setParts] = useState<UiPart[]>([]); const [parts, setParts] = useState<UiPart[]>([]);
@@ -51,7 +59,10 @@ export default function CategoryPage() {
const [brandFilter, setBrandFilter] = useState<string[]>([]); const [brandFilter, setBrandFilter] = useState<string[]>([]);
const [sortBy, setSortBy] = useState<SortOption>("relevance"); const [sortBy, setSortBy] = useState<SortOption>("relevance");
const [searchQuery, setSearchQuery] = useState<string>(""); const [searchQuery, setSearchQuery] = useState<string>("");
const [priceRange, setPriceRange] = useState<{ min: number | null; max: number | null }>({ const [priceRange, setPriceRange] = useState<{
min: number | null;
max: number | null;
}>({
min: null, min: null,
max: null, max: null,
}); });
@@ -70,9 +81,12 @@ export default function CategoryPage() {
} }
}); });
const initialPlatform = (searchParams.get("platform") as string) || "AR-15";
const [platform, setPlatform] = useState<string>(initialPlatform);
const category = useMemo( const category = useMemo(
() => CATEGORIES.find((c) => c.id === categoryId), () => CATEGORIES.find((c) => c.id === categoryId),
[categoryId], [categoryId]
); );
useEffect(() => { useEffect(() => {
@@ -86,10 +100,9 @@ export default function CategoryPage() {
setError(null); setError(null);
const search = new URLSearchParams(); const search = new URLSearchParams();
search.set("platform", "AR-15"); search.set("platform", platform);
search.set("categorySlug", categoryId);
const url = `${API_BASE_URL}/api/gunbuilder/products/by-category?${search.toString()}`; const url = `${API_BASE_URL}/api/products?${search.toString()}`;
const res = await fetch(url, { signal: controller.signal }); const res = await fetch(url, { signal: controller.signal });
@@ -123,7 +136,7 @@ export default function CategoryPage() {
fetchCategoryParts(); fetchCategoryParts();
return () => controller.abort(); return () => controller.abort();
}, [categoryId]); }, [categoryId, platform]);
// persist build to localStorage whenever it changes // persist build to localStorage whenever it changes
useEffect(() => { useEffect(() => {
@@ -146,14 +159,21 @@ export default function CategoryPage() {
delete updated[categoryId]; delete updated[categoryId];
return updated; return updated;
}); });
} else { return;
// Add to build and navigate back to main gunbuilder page }
// Add to build and navigate back to main builder page.
// Pass selection via URL so `/builder` can apply it.
setBuild((prev) => ({ setBuild((prev) => ({
...prev, ...prev,
[categoryId]: partId, [categoryId]: partId,
})); }));
router.push("/builder");
} const qp = new URLSearchParams();
qp.set("platform", platform || "AR-15");
qp.set("select", `${categoryId}:${partId}`);
router.push(`/builder?${qp.toString()}`);
}; };
// available brands for filter // available brands for filter
@@ -162,7 +182,7 @@ export default function CategoryPage() {
Array.from(new Set(parts.map((p) => p.brand))) Array.from(new Set(parts.map((p) => p.brand)))
.filter(Boolean) .filter(Boolean)
.sort((a, b) => a.localeCompare(b)), .sort((a, b) => a.localeCompare(b)),
[parts], [parts]
); );
const priceBounds = useMemo(() => { const priceBounds = useMemo(() => {
if (parts.length === 0) { if (parts.length === 0) {
@@ -231,8 +251,7 @@ export default function CategoryPage() {
const name = p.name.toLowerCase(); const name = p.name.toLowerCase();
const brand = p.brand.toLowerCase(); const brand = p.brand.toLowerCase();
return ( return (
name.includes(normalizedQuery) || name.includes(normalizedQuery) || brand.includes(normalizedQuery)
brand.includes(normalizedQuery)
); );
}); });
} }
@@ -265,7 +284,10 @@ export default function CategoryPage() {
}, [parts, brandFilter, sortBy, build, categoryId, searchQuery]); }, [parts, brandFilter, sortBy, build, categoryId, searchQuery]);
const totalPages = useMemo( const totalPages = useMemo(
() => (filteredParts.length === 0 ? 1 : Math.ceil(filteredParts.length / PAGE_SIZE)), () =>
filteredParts.length === 0
? 1
: Math.ceil(filteredParts.length / PAGE_SIZE),
[filteredParts, PAGE_SIZE] [filteredParts, PAGE_SIZE]
); );
@@ -278,18 +300,33 @@ export default function CategoryPage() {
useEffect(() => { useEffect(() => {
// whenever the category or filters change, jump back to page 1 // whenever the category or filters change, jump back to page 1
setCurrentPage(1); setCurrentPage(1);
}, [categoryId, brandFilter, sortBy, searchQuery, priceRange, inStockOnly]); }, [
categoryId,
brandFilter,
sortBy,
searchQuery,
priceRange,
inStockOnly,
platform,
]);
useEffect(() => {
const nextPlatform = (searchParams.get("platform") as string) || "AR-15";
setPlatform(nextPlatform);
}, [searchParams]);
const visibleRange = useMemo(() => { const visibleRange = useMemo(() => {
if (filteredParts.length === 0) { if (filteredParts.length === 0) {
return { start: 0, end: 0 }; return { start: 0, end: 0 };
} }
const start = (currentPage - 1) * PAGE_SIZE + 1; const start = (currentPage - 1) * PAGE_SIZE + 1;
const end = Math.min(start + paginatedParts.length - 1, filteredParts.length); const end = Math.min(
start + paginatedParts.length - 1,
filteredParts.length
);
return { start, end }; return { start, end };
}, [filteredParts.length, currentPage, paginatedParts.length, PAGE_SIZE]); }, [filteredParts.length, currentPage, paginatedParts.length, PAGE_SIZE]);
if (!category) { if (!category) {
return ( return (
<main className="min-h-screen bg-black text-zinc-50"> <main className="min-h-screen bg-black text-zinc-50">
@@ -411,15 +448,13 @@ export default function CategoryPage() {
<span> <span>
Min:{" "} Min:{" "}
<span className="font-semibold text-zinc-200"> <span className="font-semibold text-zinc-200">
$ ${(priceRange.min ?? priceBounds.min).toFixed(0)}
{(priceRange.min ?? priceBounds.min).toFixed(0)}
</span> </span>
</span> </span>
<span> <span>
Max:{" "} Max:{" "}
<span className="font-semibold text-zinc-200"> <span className="font-semibold text-zinc-200">
$ ${(priceRange.max ?? priceBounds.max).toFixed(0)}
{(priceRange.max ?? priceBounds.max).toFixed(0)}
</span> </span>
</span> </span>
</div> </div>
@@ -436,10 +471,7 @@ export default function CategoryPage() {
value, value,
prev.max ?? priceBounds.max ?? value prev.max ?? priceBounds.max ?? value
), ),
max: max: prev.max ?? priceBounds.max ?? value,
prev.max ??
priceBounds.max ??
value,
})); }));
}} }}
className="w-full" className="w-full"
@@ -452,10 +484,7 @@ export default function CategoryPage() {
onChange={(e) => { onChange={(e) => {
const value = Number(e.target.value); const value = Number(e.target.value);
setPriceRange((prev) => ({ setPriceRange((prev) => ({
min: min: prev.min ?? priceBounds.min ?? value,
prev.min ??
priceBounds.min ??
value,
max: Math.max( max: Math.max(
value, value,
prev.min ?? priceBounds.min ?? value prev.min ?? priceBounds.min ?? value
@@ -588,6 +617,26 @@ export default function CategoryPage() {
)} )}
</div> </div>
</div> </div>
<div className="flex items-center gap-2">
<label
htmlFor="platform-select"
className="text-xs text-zinc-500"
>
Platform
</label>
<select
id="platform-select"
value={platform}
onChange={(e) => setPlatform(e.target.value)}
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-2 py-1 text-xs text-zinc-200 focus:outline-none focus:ring-1 focus:ring-amber-400"
>
{PLATFORMS.map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</select>
</div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<label <label
htmlFor="sort-by" htmlFor="sort-by"
@@ -644,9 +693,7 @@ export default function CategoryPage() {
<div className="flex-1"> <div className="flex-1">
<div className="text-sm font-semibold text-zinc-50"> <div className="text-sm font-semibold text-zinc-50">
{part.brand}{" "} {part.brand}{" "}
<span className="font-normal"> <span className="font-normal"> {part.name}</span>
{part.name}
</span>
</div> </div>
{part.notes && ( {part.notes && (
<p className="mt-1 line-clamp-2 text-xs text-zinc-400"> <p className="mt-1 line-clamp-2 text-xs text-zinc-400">
@@ -660,14 +707,21 @@ export default function CategoryPage() {
</div> </div>
<div className="mt-2 flex gap-2"> <div className="mt-2 flex gap-2">
<Link <Link
href={`/builder/${categoryId}/${part.id}`} href={{
pathname: `/builder/${categoryId}/${
part.id
}-${slugify(part.name)}`,
query: { platform },
}}
className="flex-1 rounded-md border border-zinc-700 bg-zinc-800/50 px-3 py-2 text-center text-xs font-medium text-zinc-300 transition-colors hover:border-zinc-600 hover:bg-zinc-700" className="flex-1 rounded-md border border-zinc-700 bg-zinc-800/50 px-3 py-2 text-center text-xs font-medium text-zinc-300 transition-colors hover:border-zinc-600 hover:bg-zinc-700"
> >
View Details View Details
</Link> </Link>
<button <button
type="button" type="button"
onClick={() => handleTogglePart(categoryId, part.id)} onClick={() =>
handleTogglePart(categoryId, part.id)
}
className={`flex-1 rounded-md px-3 py-2 text-center text-xs font-semibold transition-colors ${ className={`flex-1 rounded-md px-3 py-2 text-center text-xs font-semibold transition-colors ${
isSelected isSelected
? "border border-zinc-600 bg-zinc-800 text-zinc-100 hover:bg-zinc-700" ? "border border-zinc-600 bg-zinc-800 text-zinc-100 hover:bg-zinc-700"
@@ -723,7 +777,12 @@ export default function CategoryPage() {
</div> </div>
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Link <Link
href={`/builder/${categoryId}/${part.id}`} href={{
pathname: `/builder/${categoryId}/${
part.id
}-${slugify(part.name)}`,
query: { platform },
}}
className="whitespace-nowrap rounded-md border border-zinc-700 bg-zinc-800/50 px-3 py-1.5 text-xs font-medium text-zinc-300 transition-colors hover:border-zinc-600 hover:bg-zinc-700" className="whitespace-nowrap rounded-md border border-zinc-700 bg-zinc-800/50 px-3 py-1.5 text-xs font-medium text-zinc-300 transition-colors hover:border-zinc-600 hover:bg-zinc-700"
> >
View Details View Details
@@ -739,7 +798,9 @@ export default function CategoryPage() {
: "bg-amber-400 text-black hover:bg-amber-300" : "bg-amber-400 text-black hover:bg-amber-300"
}`} }`}
> >
{isSelected ? "Remove from Build" : "Add to Build"} {isSelected
? "Remove from Build"
: "Add to Build"}
</button> </button>
</div> </div>
</div> </div>
@@ -766,9 +827,7 @@ export default function CategoryPage() {
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
type="button" type="button"
onClick={() => onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
setCurrentPage((p) => Math.max(1, p - 1))
}
disabled={currentPage === 1} disabled={currentPage === 1}
className="rounded border border-zinc-700 bg-zinc-900/70 px-3 py-1 hover:bg-zinc-800 disabled:opacity-40" className="rounded border border-zinc-700 bg-zinc-900/70 px-3 py-1 hover:bg-zinc-800 disabled:opacity-40"
> >
-32
View File
@@ -24,35 +24,3 @@ export default function BuilderLayout({ children }: { children: ReactNode }) {
); );
} }
//
// Original homepage. will delete after testing
//
// import "./globals.css";
// import type { ReactNode } from "react";
// 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, datadriven way to build your next firearm.",
// };
// export default function RootLayout({ children }: { children: ReactNode }) {
// return (
// <html lang="en">
// <body className="bg-black text-zinc-100">
// <AuthProvider>
// <TopNav />
// <BuilderNav />
// <main className="min-h-screen">{children}</main>
// </AuthProvider>
// </body>
// </html>
// );
// }
+109 -42
View File
@@ -20,6 +20,8 @@ type GunbuilderProductFromApi = {
buyUrl: string | null; buyUrl: string | null;
}; };
const PLATFORMS = ["AR-15", "AR-10", "AR-9"] as const;
const API_BASE_URL = const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080"; process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
@@ -85,6 +87,16 @@ export default function GunbuilderPage() {
const [parts, setParts] = useState<Part[]>([]); const [parts, setParts] = useState<Part[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const isValidPlatform = (
value: string | null
): value is (typeof PLATFORMS)[number] =>
!!value && (PLATFORMS as readonly string[]).includes(value);
const [platform, setPlatform] = useState<(typeof PLATFORMS)[number]>(() => {
if (typeof window === "undefined") return "AR-15";
const initial = new URLSearchParams(window.location.search).get("platform");
return isValidPlatform(initial) ? initial : "AR-15";
});
const [build, setBuild] = useState<BuildState>(() => { const [build, setBuild] = useState<BuildState>(() => {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
const stored = localStorage.getItem(STORAGE_KEY); const stored = localStorage.getItem(STORAGE_KEY);
@@ -110,8 +122,10 @@ export default function GunbuilderPage() {
setError(null); setError(null);
const res = await fetch( const res = await fetch(
`${API_BASE_URL}/api/products/gunbuilder?platform=AR-15`, `${API_BASE_URL}/api/products?platform=${encodeURIComponent(
{ signal: controller.signal }, platform
)}`,
{ signal: controller.signal }
); );
if (!res.ok) { if (!res.ok) {
@@ -144,7 +158,6 @@ export default function GunbuilderPage() {
}) })
.filter((p): p is Part => p !== null); .filter((p): p is Part => p !== null);
setParts(normalized); setParts(normalized);
} catch (err: any) { } catch (err: any) {
if (err.name === "AbortError") return; if (err.name === "AbortError") return;
@@ -157,7 +170,16 @@ export default function GunbuilderPage() {
fetchProducts(); fetchProducts();
return () => controller.abort(); return () => controller.abort();
}, []); }, [platform]);
useEffect(() => {
// When the platform changes, reset the current build so we don't
// show stale part selections from a different platform.
setBuild({});
if (typeof window !== "undefined") {
localStorage.removeItem(STORAGE_KEY);
}
}, [platform]);
// Handle URL query parameter for part selection (?select=upper:165) // Handle URL query parameter for part selection (?select=upper:165)
useEffect(() => { useEffect(() => {
@@ -176,11 +198,26 @@ export default function GunbuilderPage() {
return updated; return updated;
}); });
router.replace("/builder", { scroll: false }); const qp = searchParams.get("platform");
const nextPlatform = isValidPlatform(qp) ? qp : platform;
router.replace(
`/builder?platform=${encodeURIComponent(nextPlatform)}`,
{
scroll: false,
}
);
} }
} }
}, [searchParams, router]); }, [searchParams, router]);
useEffect(() => {
const qp = searchParams.get("platform");
if (isValidPlatform(qp) && qp !== platform) {
setPlatform(qp);
}
}, [searchParams, platform]);
// Persist build state to localStorage whenever it changes // Persist build state to localStorage whenever it changes
useEffect(() => { useEffect(() => {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
@@ -191,9 +228,7 @@ export default function GunbuilderPage() {
const partsByCategory: Record<CategoryId, Part[]> = useMemo(() => { const partsByCategory: Record<CategoryId, Part[]> = useMemo(() => {
const grouped = {} as Record<CategoryId, Part[]>; const grouped = {} as Record<CategoryId, Part[]>;
for (const category of CATEGORIES) { for (const category of CATEGORIES) {
grouped[category.id] = parts.filter( grouped[category.id] = parts.filter((p) => p.categoryId === category.id);
(p) => p.categoryId === category.id,
);
} }
return grouped; return grouped;
}, [parts]); }, [parts]);
@@ -201,7 +236,7 @@ export default function GunbuilderPage() {
const selectedParts: Part[] = useMemo(() => { const selectedParts: Part[] = useMemo(() => {
return Object.entries(build) return Object.entries(build)
.map(([categoryId, partId]) => .map(([categoryId, partId]) =>
parts.find((p) => p.id === partId && p.categoryId === categoryId), parts.find((p) => p.id === partId && p.categoryId === categoryId)
) )
.filter(Boolean) as Part[]; .filter(Boolean) as Part[];
}, [build, parts]); }, [build, parts]);
@@ -212,14 +247,14 @@ export default function GunbuilderPage() {
Object.entries(build).map(([categoryId, partId]) => [ Object.entries(build).map(([categoryId, partId]) => [
categoryId as CategoryId, categoryId as CategoryId,
partId ? true : false, partId ? true : false,
]), ])
) as Record<CategoryId, unknown>, ) as Record<CategoryId, unknown>,
[build], [build]
); );
const totalPrice = useMemo( const totalPrice = useMemo(
() => selectedParts.reduce((sum, p) => sum + p.price, 0), () => selectedParts.reduce((sum, p) => sum + p.price, 0),
[selectedParts], [selectedParts]
); );
useEffect(() => { useEffect(() => {
@@ -236,7 +271,7 @@ export default function GunbuilderPage() {
const encoded = window.btoa(payload); const encoded = window.btoa(payload);
const origin = window.location?.origin ?? ""; const origin = window.location?.origin ?? "";
const url = `${origin}/builder/build?build=${encodeURIComponent( const url = `${origin}/builder/build?build=${encodeURIComponent(
encoded, encoded
)}`; )}`;
setShareUrl(url); setShareUrl(url);
} catch { } catch {
@@ -254,10 +289,10 @@ export default function GunbuilderPage() {
// Use group ordering for the summary as well // Use group ordering for the summary as well
const summaryCategoryOrder: CategoryId[] = useMemo( const summaryCategoryOrder: CategoryId[] = useMemo(
() => () =>
CATEGORY_GROUPS.flatMap((group) => group.categoryIds).filter( CATEGORY_GROUPS.flatMap((group) => group.categoryIds).filter((id) =>
(id) => CATEGORIES.some((c) => c.id === id), CATEGORIES.some((c) => c.id === id)
), ),
[], []
); );
const handleShare = async () => { const handleShare = async () => {
@@ -278,14 +313,14 @@ export default function GunbuilderPage() {
const selectedPartId = build[cat.id]; const selectedPartId = build[cat.id];
const part = parts.find( const part = parts.find(
(p) => p.id === selectedPartId && p.categoryId === cat.id, (p) => p.id === selectedPartId && p.categoryId === cat.id
); );
if (part) { if (part) {
lines.push( lines.push(
`${cat.name}: ${part.brand}${part.name} ($${part.price.toFixed( `${cat.name}: ${part.brand}${part.name} ($${part.price.toFixed(
2, 2
)})`, )})`
); );
} else { } else {
lines.push(`${cat.name}: [Not selected]`); lines.push(`${cat.name}: [Not selected]`);
@@ -299,7 +334,7 @@ export default function GunbuilderPage() {
setShareStatus("Build summary copied to clipboard."); setShareStatus("Build summary copied to clipboard.");
} catch { } catch {
setShareStatus( setShareStatus(
"Could not access clipboard. You can copy from the build summary page.", "Could not access clipboard. You can copy from the build summary page."
); );
} }
}; };
@@ -334,7 +369,9 @@ export default function GunbuilderPage() {
setShareStatus("Share dialog opened."); setShareStatus("Share dialog opened.");
} catch { } catch {
// User canceled or share failed; keep it quiet but let them know // User canceled or share failed; keep it quiet but let them know
setShareStatus("Share canceled or unavailable. You can copy the link instead."); setShareStatus(
"Share canceled or unavailable. You can copy the link instead."
);
} }
} else { } else {
// Fallback to copying the link // Fallback to copying the link
@@ -344,7 +381,6 @@ export default function GunbuilderPage() {
return ( return (
<main className="min-h-screen bg-black text-zinc-50"> <main className="min-h-screen bg-black text-zinc-50">
<div className="mx-auto max-w-6xl px-4 py-6 lg:py-10"> <div className="mx-auto max-w-6xl px-4 py-6 lg:py-10">
{/* Header */} {/* Header */}
<header className="mb-6"> <header className="mb-6">
@@ -361,10 +397,31 @@ export default function GunbuilderPage() {
update as you go. This early-access builder keeps your setup saved update as you go. This early-access builder keeps your setup saved
locally so you can come back and refine it anytime. locally so you can come back and refine it anytime.
</p> </p>
<div className="mt-4 flex flex-wrap items-center gap-3">
<label className="text-xs font-medium text-zinc-400 flex items-center gap-2">
Platform
<select
value={platform}
onChange={(e) =>
setPlatform(e.target.value as (typeof PLATFORMS)[number])
}
className="rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-xs text-zinc-100 focus:outline-none focus:ring-1 focus:ring-amber-400"
>
{PLATFORMS.map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</select>
</label>
<p className="text-[0.7rem] text-zinc-500">
Parts list updates automatically when you change platforms.
</p>
</div>
</div> </div>
</header> </header>
{/* Build summary panel */} {/* Build summary panel */}
<section className="mb-6 rounded-lg border border-zinc-800 bg-zinc-950/80 p-3 md:p-4 flex flex-col gap-3 md:flex-row md:items-start md:justify-between"> <section className="mb-6 rounded-lg border border-zinc-800 bg-zinc-950/80 p-3 md:p-4 flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
{/* Left: title + totals + primary actions */} {/* Left: title + totals + primary actions */}
@@ -416,7 +473,9 @@ export default function GunbuilderPage() {
}} }}
disabled={selectedParts.length === 0} disabled={selectedParts.length === 0}
className={`w-full sm:w-auto rounded-md border border-zinc-700 bg-zinc-900/50 px-3 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600 transition-colors ${ className={`w-full sm:w-auto rounded-md border border-zinc-700 bg-zinc-900/50 px-3 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600 transition-colors ${
selectedParts.length === 0 ? "opacity-40 cursor-not-allowed" : "" selectedParts.length === 0
? "opacity-40 cursor-not-allowed"
: ""
}`} }`}
> >
Clear Build Clear Build
@@ -432,8 +491,8 @@ export default function GunbuilderPage() {
Share Your Build Share Your Build
</h3> </h3>
<p className="mt-1 text-[0.7rem] text-zinc-500"> <p className="mt-1 text-[0.7rem] text-zinc-500">
Share this link to let others view your build or bookmark it to come Share this link to let others view your build or bookmark it
back later. to come back later.
</p> </p>
<div className="mt-2 flex flex-col gap-2 md:flex-row md:items-center"> <div className="mt-2 flex flex-col gap-2 md:flex-row md:items-center">
<div className="flex-1"> <div className="flex-1">
@@ -468,9 +527,7 @@ export default function GunbuilderPage() {
)} )}
{shareStatus && ( {shareStatus && (
<p className="mt-1 text-[0.7rem] text-zinc-500"> <p className="mt-1 text-[0.7rem] text-zinc-500">{shareStatus}</p>
{shareStatus}
</p>
)} )}
</div> </div>
</section> </section>
@@ -511,15 +568,12 @@ export default function GunbuilderPage() {
<section className="rounded-lg border border-zinc-800 bg-zinc-950/60 p-3 md:p-4"> <section className="rounded-lg border border-zinc-800 bg-zinc-950/60 p-3 md:p-4">
<p className="mb-4 text-xs text-zinc-500"> <p className="mb-4 text-xs text-zinc-500">
Work top-down through the major sections. Each row shows your Work top-down through the major sections. Each row shows your
current pick for that part type, with price and a direct buy current pick for that part type, with price and a direct buy linkor
linkor a quick way to choose a part if you haven&apos;t picked a quick way to choose a part if you haven&apos;t picked one yet.
one yet.
</p> </p>
{loading && ( {loading && (
<p className="text-sm text-zinc-500"> <p className="text-sm text-zinc-500">Loading parts from backend</p>
Loading parts from backend
</p>
)} )}
{error && !loading && ( {error && !loading && (
<p className="text-sm text-red-400"> <p className="text-sm text-red-400">
@@ -532,7 +586,7 @@ export default function GunbuilderPage() {
<div className="space-y-6"> <div className="space-y-6">
{CATEGORY_GROUPS.map((group) => { {CATEGORY_GROUPS.map((group) => {
const groupCategories = CATEGORIES.filter((c) => const groupCategories = CATEGORIES.filter((c) =>
group.categoryIds.includes(c.id as CategoryId), group.categoryIds.includes(c.id as CategoryId)
); );
if (groupCategories.length === 0) { if (groupCategories.length === 0) {
@@ -584,7 +638,7 @@ export default function GunbuilderPage() {
partsByCategory[category.id] ?? []; partsByCategory[category.id] ?? [];
const selectedPartId = build[category.id]; const selectedPartId = build[category.id];
const selectedPart = categoryParts.find( const selectedPart = categoryParts.find(
(p) => p.id === selectedPartId, (p) => p.id === selectedPartId
); );
const hasParts = categoryParts.length > 0; const hasParts = categoryParts.length > 0;
@@ -663,13 +717,26 @@ export default function GunbuilderPage() {
</> </>
) : hasParts ? ( ) : hasParts ? (
<Link <Link
href={`/builder/${category.id}`} href={{
className="inline-flex rounded-md border border-zinc-700 bg-zinc-900/70 px-2.5 py-1 text-[0.7rem] font-medium text-zinc-200 hover:bg-zinc-800 hover:border-zinc-600 transition-colors" pathname: `/builder/${category.id}`,
onClick={() => { query: platform ? { platform } : {},
// If you want to pre-select something later, you can handle here.
}} }}
className="inline-flex items-center gap-1.5 whitespace-nowrap rounded-md border border-zinc-700 bg-zinc-900/70 px-3 py-1.5 text-xs font-medium text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600 transition-colors"
> >
Choose a Part <svg
className="w-3.5 h-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
/>
</svg>
<span>Choose a Part</span>
</Link> </Link>
) : ( ) : (
<span className="text-[0.7rem] text-zinc-600"> <span className="text-[0.7rem] text-zinc-600">
-1
View File
@@ -96,7 +96,6 @@ export default function HomePage() {
{/* Top Bar / Logo */} {/* Top Bar / Logo */}
<header className="flex items-center justify-between gap-4"> <header className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{/* Logo slot replace src with your actual logo file */}
<div className="flex items-center"> <div className="flex items-center">
<Image <Image
src="/battl/battl-logo-mark-2.svg" src="/battl/battl-logo-mark-2.svg"
+11 -3
View File
@@ -9,6 +9,7 @@ interface CategoryColumnProps {
parts: Part[]; parts: Part[];
selectedPartId?: string; selectedPartId?: string;
onSelectPart: (partId: string) => void; onSelectPart: (partId: string) => void;
platform?: string;
} }
export function CategoryColumn({ export function CategoryColumn({
@@ -16,6 +17,7 @@ export function CategoryColumn({
parts, parts,
selectedPartId, selectedPartId,
onSelectPart, onSelectPart,
platform,
}: CategoryColumnProps) { }: CategoryColumnProps) {
// Show selected part if available, otherwise show placeholder // Show selected part if available, otherwise show placeholder
const displayedPart = selectedPartId const displayedPart = selectedPartId
@@ -37,8 +39,11 @@ export function CategoryColumn({
<div className="w-full border border-zinc-700 rounded-md p-3 mb-2 flex items-center justify-between gap-3"> <div className="w-full border border-zinc-700 rounded-md p-3 mb-2 flex items-center justify-between gap-3">
<p className="text-sm text-zinc-500">No part selected</p> <p className="text-sm text-zinc-500">No part selected</p>
<Link <Link
href={`/builder/${category.id}`} href={{
className="inline-flex items-center gap-2 rounded-md border border-zinc-700 bg-zinc-900/60 px-3 py-1.5 text-xs font-medium text-zinc-200 hover:bg-zinc-800 hover:border-zinc-500 transition-colors" pathname: `/builder/${category.id}`,
query: platform ? { platform } : {},
}}
className="inline-flex w-full items-center justify-center rounded-md border border-zinc-700 bg-zinc-900 px-4 py-2 text-xs font-semibold text-zinc-100 hover:bg-zinc-800 hover:border-zinc-600 transition-colors"
> >
<svg <svg
className="w-4 h-4" className="w-4 h-4"
@@ -68,7 +73,10 @@ export function CategoryColumn({
</div> </div>
{parts.length >= 1 && ( {parts.length >= 1 && (
<Link <Link
href={`/builder/${category.id}`} href={{
pathname: `/builder/${category.id}`,
query: platform ? { platform } : {},
}}
className="mt-2 w-full rounded-md border border-zinc-700 bg-zinc-900/50 px-3 py-2 text-xs font-medium text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600 transition-colors text-center" className="mt-2 w-full rounded-md border border-zinc-700 bg-zinc-900/50 px-3 py-2 text-xs font-medium text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600 transition-colors text-center"
> >
View All ({parts.length}) View All ({parts.length})
+10 -1
View File
@@ -2,6 +2,8 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import Image from "next/image";
import { useAuth } from "@/context/AuthContext"; import { useAuth } from "@/context/AuthContext";
export function TopNav() { export function TopNav() {
@@ -19,7 +21,14 @@ export function TopNav() {
href="/" href="/"
className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-400" className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-400"
> >
Battl Builder
<Image
src="/battl/battl-logo-mark-2.svg"
alt="Battl Builders Logo"
width={260} // adjust to taste
height={48} // adjust to taste
className="block"
/>
</Link> </Link>
{/* Right side actions */} {/* Right side actions */}
+64 -159
View File
@@ -1,163 +1,68 @@
import type { Category, Part } from "@/types/gunbuilder"; import type { Category } from "@/types/gunbuilder";
export const CATEGORY_SLUGS = {
lower: "lower",
"complete-lower": "complete-lower",
"lower-parts": "lower-parts",
trigger: "trigger",
grip: "grip",
safety: "safety",
buffer: "buffer",
stock: "stock",
upper: "upper",
"complete-upper": "complete-upper",
barrel: "barrel",
handguard: "handguard",
"gas-block": "gas-block",
"gas-tube": "gas-tube",
"muzzle-device": "muzzle-device",
bcg: "bcg",
sights: "sights",
"charging-handle": "charging-handle",
suppressor: "suppressor",
optic: "optic",
magazine: "magazine",
"weapon-light": "weapon-light",
foregrip: "foregrip",
bipod: "bipod",
sling: "sling",
"rail-accessory": "rail-accessory",
tools: "tools",
} as const;
export const CATEGORIES: Category[] = [ export const CATEGORIES: Category[] = [
// LOWER // Lower group
{ { id: "lower", name: "Stripped Lowers", group: "lower", slug: CATEGORY_SLUGS.lower },
id: "lower", { id: "complete-lower", name: "Complete Lower", group: "lower", slug: CATEGORY_SLUGS["complete-lower"] },
name: "Stripped Lowers", { id: "lower-parts", name: "Lower Parts Kit", group: "lower", slug: CATEGORY_SLUGS["lower-parts"] },
description: "Serialized lower the foundation of the build.", { id: "trigger", name: "Trigger / Fire Control", group: "lower", slug: CATEGORY_SLUGS.trigger },
group: "lower", { id: "grip", name: "Pistol Grip", group: "lower", slug: CATEGORY_SLUGS.grip },
}, { id: "safety", name: "Safety / Selector", group: "lower", slug: CATEGORY_SLUGS.safety },
{ { id: "buffer", name: "Buffer System", group: "lower", slug: CATEGORY_SLUGS.buffer },
id: "completeLower", { id: "stock", name: "Stock / Brace", group: "lower", slug: CATEGORY_SLUGS.stock },
name: "Complete Lowers",
description: "Factory-built lower with stock, buffer system, and controls.",
group: "lower",
},
{
id: "lowerParts",
name: "Lower Parts Kits",
description: "Pins, springs, and controls for the lower.",
group: "lower",
},
{
id: "trigger",
name: "Trigger / Fire Control",
description: "Single-stage, two-stage, and match triggers.",
group: "lower",
},
{
id: "grip",
name: "Pistol Grip",
description: "Grips that shape how the rifle handles.",
group: "lower",
},
{
id: "safety",
name: "Safety / Selector",
description: "Ambi selectors and control upgrades.",
group: "lower",
},
{
id: "buffer",
name: "Buffer System",
description: "Buffer tube, buffer, spring, and related parts.",
group: "lower",
},
{
id: "stock",
name: "Stock / Brace",
description: "Adjustable, fixed, and minimalist stocks.",
group: "lower",
},
// UPPER // Upper group
{ { id: "upper", name: "Stripped Upper", group: "upper", slug: CATEGORY_SLUGS.upper },
id: "upper", { id: "complete-upper", name: "Complete Upper", group: "upper", slug: CATEGORY_SLUGS["complete-upper"] },
name: "Stripped Uppers", { id: "barrel", name: "Barrels", group: "upper", slug: CATEGORY_SLUGS.barrel },
description: "The core of the top half of your build.", { id: "handguard", name: "Handguards / Rails", group: "upper", slug: CATEGORY_SLUGS.handguard },
group: "upper", { id: "gas-block", name: "Gas Block", group: "upper", slug: CATEGORY_SLUGS["gas-block"] },
}, { id: "gas-tube", name: "Gas Tube", group: "upper", slug: CATEGORY_SLUGS["gas-tube"] },
{ { id: "muzzle-device", name: "Muzzle Device", group: "upper", slug: CATEGORY_SLUGS["muzzle-device"] },
id: "completeUpper", { id: "sights", name: "Iron Sights", group: "upper", slug: CATEGORY_SLUGS.sights },
name: "Complete Uppers", { id: "bcg", name: "Bolt Carrier Group", group: "upper", slug: CATEGORY_SLUGS.bcg },
description: "Pre-assembled uppers ready to pin and shoot.", { id: "charging-handle", name: "Charging Handle", group: "upper", slug: CATEGORY_SLUGS["charging-handle"] },
group: "upper", { id: "suppressor", name: "Suppressors", group: "upper", slug: CATEGORY_SLUGS.suppressor },
}, { id: "optic", name: "Optics", group: "upper", slug: CATEGORY_SLUGS.optic },
{
id: "bcg", // Accessories
name: "Bolt Carriers", { id: "magazine", name: "Magazines", group: "accessories", slug: CATEGORY_SLUGS.magazine },
description: "The heartbeat of the rifles cycling.", { id: "weapon-light", name: "Weapon Lights & Lasers", group: "accessories", slug: CATEGORY_SLUGS["weapon-light"] },
group: "upper", { id: "foregrip", name: "Vertical / Angled Grips", group: "accessories", slug: CATEGORY_SLUGS.foregrip },
}, { id: "bipod", name: "Bipods", group: "accessories", slug: CATEGORY_SLUGS.bipod },
{ { id: "sling", name: "Slings & Mounts", group: "accessories", slug: CATEGORY_SLUGS.sling },
id: "barrel", { id: "rail-accessory", name: "Rail Accessories", group: "accessories", slug: CATEGORY_SLUGS["rail-accessory"] },
name: "Barrels", { id: "tools", name: "Tools & Maintenance", group: "accessories", slug: CATEGORY_SLUGS.tools },
description: "Different lengths, profiles, and calibers.",
group: "upper",
},
{
id: "gasBlock",
name: "Gas Blocks",
description: "Standard and adjustable gas blocks.",
group: "upper",
},
{
id: "gasTube",
name: "Gas Tubes",
description: "Carbine, mid, rifle, and more.",
group: "upper",
},
{
id: "muzzleDevice",
name: "Muzzle Devices",
description: "Brakes, comps, and flash hiders.",
group: "upper",
},
{
id: "suppressor",
name: "Suppressors",
description: "Hearing-safe setups and hosts.",
group: "upper",
},
{
id: "handguard",
name: "Handguards / Rails",
description: "M-LOK rails and front-end furniture.",
group: "upper",
},
{
id: "chargingHandle",
name: "Charging Handles",
description: "Standard and ambi charging handles.",
group: "upper",
},
{
id: "sights",
name: "Iron Sights",
description: "Backup and primary irons.",
group: "upper",
},
{
id: "optic",
name: "Optics",
description: "LPVOs, red dots, and magnifiers.",
group: "upper",
},
// ACCESSORIES GROUP
{
id: "magazine",
name: "Magazines",
group: "accessories",
},
{
id: "weaponLight",
name: "Weapon Lights & Lasers",
group: "accessories",
},
{
id: "foregrip",
name: "Vertical / Angled Grips",
group: "accessories",
},
{
id: "bipod",
name: "Bipods",
group: "accessories",
},
{
id: "sling",
name: "Slings & Mounts",
group: "accessories",
},
{
id: "railAccessory",
name: "Rail Sections & Covers",
group: "accessories",
},
{
id: "tools",
name: "Tools & Maintenance",
group: "accessories",
},
]; ];
+32
View File
@@ -50,3 +50,35 @@ export interface Part {
imageUrl?: string; imageUrl?: string;
notes?: string; notes?: string;
} }
export const CATEGORY_SLUGS: Record<CategoryId, string> = {
lower: "lower",
completeLower: "complete-lower",
lowerParts: "lower-parts",
trigger: "trigger",
grip: "grip",
safety: "safety",
buffer: "buffer",
stock: "stock",
upper: "upper",
completeUpper: "complete-upper",
barrel: "barrel",
handguard: "handguard",
gasBlock: "gas-block",
gasTube: "gas-tube",
muzzleDevice: "muzzle-device",
sights: "sights",
bcg: "bcg",
chargingHandle: "charging-handle",
suppressor: "suppressor",
optic: "optic",
magazine: "magazine",
weaponLight: "weapon-light",
foregrip: "foregrip",
bipod: "bipod",
sling: "sling",
railAccessory: "rail-accessory",
tools: "tools",
};