Compare commits
2 Commits
54c30b1d8a
...
777618f684
| Author | SHA1 | Date | |
|---|---|---|---|
| 777618f684 | |||
| b26dcb947e |
@@ -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);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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, data‑driven 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>
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
|
|||||||
+130
-63
@@ -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) {
|
||||||
@@ -121,29 +135,28 @@ export default function GunbuilderPage() {
|
|||||||
const data: GunbuilderProductFromApi[] = await res.json();
|
const data: GunbuilderProductFromApi[] = await res.json();
|
||||||
|
|
||||||
const normalized: Part[] = data
|
const normalized: Part[] = data
|
||||||
.map((p): Part | null => {
|
.map((p): Part | null => {
|
||||||
const categoryId = PART_ROLE_TO_CATEGORY[p.partRole];
|
const categoryId = PART_ROLE_TO_CATEGORY[p.partRole];
|
||||||
if (!categoryId) {
|
if (!categoryId) {
|
||||||
// Skip any parts we don't know how to map yet
|
// Skip any parts we don't know how to map yet
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const buyUrl = p.buyUrl ?? undefined;
|
const buyUrl = p.buyUrl ?? undefined;
|
||||||
|
|
||||||
return {
|
|
||||||
id: String(p.id), // ALWAYS string
|
|
||||||
categoryId,
|
|
||||||
name: p.name,
|
|
||||||
brand: p.brand,
|
|
||||||
price: p.price ?? 0,
|
|
||||||
imageUrl: p.mainImageUrl ?? undefined,
|
|
||||||
affiliateUrl: buyUrl, // main link
|
|
||||||
url: buyUrl, // 👈optional alias if you still reference .url. maybe pretty url?
|
|
||||||
notes: undefined,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter((p): p is Part => p !== null);
|
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: String(p.id), // ALWAYS string
|
||||||
|
categoryId,
|
||||||
|
name: p.name,
|
||||||
|
brand: p.brand,
|
||||||
|
price: p.price ?? 0,
|
||||||
|
imageUrl: p.mainImageUrl ?? undefined,
|
||||||
|
affiliateUrl: buyUrl, // main link
|
||||||
|
url: buyUrl, // 👈optional alias if you still reference .url. maybe pretty url?
|
||||||
|
notes: undefined,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((p): p is Part => p !== null);
|
||||||
|
|
||||||
setParts(normalized);
|
setParts(normalized);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -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 link—or
|
||||||
link—or a quick way to choose a part if you haven't picked
|
a quick way to choose a part if you haven'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">
|
||||||
@@ -729,4 +796,4 @@ export default function GunbuilderPage() {
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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 */}
|
||||||
|
|||||||
+65
-160
@@ -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 rifle’s 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",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -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",
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user