fixing platform routing. not done. updated logo on builder
This commit is contained in:
@@ -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);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
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";
|
||||||
|
|
||||||
@@ -26,6 +26,8 @@ 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";
|
||||||
|
|
||||||
@@ -37,10 +39,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[]>([]);
|
||||||
@@ -70,6 +80,10 @@ 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],
|
||||||
@@ -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(() => {
|
||||||
@@ -275,10 +288,16 @@ export default function CategoryPage() {
|
|||||||
return filteredParts.slice(startIndex, startIndex + PAGE_SIZE);
|
return filteredParts.slice(startIndex, startIndex + PAGE_SIZE);
|
||||||
}, [filteredParts, currentPage, PAGE_SIZE]);
|
}, [filteredParts, currentPage, PAGE_SIZE]);
|
||||||
|
|
||||||
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) {
|
||||||
@@ -588,6 +607,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"
|
||||||
@@ -660,7 +699,10 @@ 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
|
||||||
@@ -723,7 +765,10 @@ 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
|
||||||
|
|||||||
@@ -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>
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
|
|||||||
@@ -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,7 @@ 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 [platform, setPlatform] = useState<(typeof PLATFORMS)[number]>("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 +113,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 +126,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 +161,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(() => {
|
||||||
@@ -191,9 +204,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 +212,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 +223,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 +247,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 +265,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 +289,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 +310,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 +345,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 +357,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 +373,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 +449,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 +467,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 +503,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 +544,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 +562,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 +614,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,10 +693,9 @@ 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 },
|
||||||
// If you want to pre-select something later, you can handle here.
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Choose a Part
|
Choose a Part
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
+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 */}
|
||||||
|
|||||||
+64
-159
@@ -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