fixing platform routing. not done. updated logo on builder

This commit is contained in:
2025-12-10 14:32:09 -05:00
parent 54c30b1d8a
commit b26dcb947e
8 changed files with 255 additions and 265 deletions
+90 -61
View File
@@ -20,6 +20,8 @@ type GunbuilderProductFromApi = {
buyUrl: string | null;
};
const PLATFORMS = ["AR-15", "AR-10", "AR-9"] as const;
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
@@ -85,6 +87,7 @@ export default function GunbuilderPage() {
const [parts, setParts] = useState<Part[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [platform, setPlatform] = useState<(typeof PLATFORMS)[number]>("AR-15");
const [build, setBuild] = useState<BuildState>(() => {
if (typeof window !== "undefined") {
const stored = localStorage.getItem(STORAGE_KEY);
@@ -110,8 +113,10 @@ export default function GunbuilderPage() {
setError(null);
const res = await fetch(
`${API_BASE_URL}/api/products/gunbuilder?platform=AR-15`,
{ signal: controller.signal },
`${API_BASE_URL}/api/products?platform=${encodeURIComponent(
platform
)}`,
{ signal: controller.signal }
);
if (!res.ok) {
@@ -121,29 +126,28 @@ export default function GunbuilderPage() {
const data: GunbuilderProductFromApi[] = await res.json();
const normalized: Part[] = data
.map((p): Part | null => {
const categoryId = PART_ROLE_TO_CATEGORY[p.partRole];
if (!categoryId) {
// Skip any parts we don't know how to map yet
return null;
}
.map((p): Part | null => {
const categoryId = PART_ROLE_TO_CATEGORY[p.partRole];
if (!categoryId) {
// Skip any parts we don't know how to map yet
return null;
}
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);
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);
setParts(normalized);
} catch (err: any) {
@@ -157,7 +161,16 @@ export default function GunbuilderPage() {
fetchProducts();
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)
useEffect(() => {
@@ -191,9 +204,7 @@ export default function GunbuilderPage() {
const partsByCategory: Record<CategoryId, Part[]> = useMemo(() => {
const grouped = {} as Record<CategoryId, Part[]>;
for (const category of CATEGORIES) {
grouped[category.id] = parts.filter(
(p) => p.categoryId === category.id,
);
grouped[category.id] = parts.filter((p) => p.categoryId === category.id);
}
return grouped;
}, [parts]);
@@ -201,7 +212,7 @@ export default function GunbuilderPage() {
const selectedParts: Part[] = useMemo(() => {
return Object.entries(build)
.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[];
}, [build, parts]);
@@ -212,14 +223,14 @@ export default function GunbuilderPage() {
Object.entries(build).map(([categoryId, partId]) => [
categoryId as CategoryId,
partId ? true : false,
]),
])
) as Record<CategoryId, unknown>,
[build],
[build]
);
const totalPrice = useMemo(
() => selectedParts.reduce((sum, p) => sum + p.price, 0),
[selectedParts],
[selectedParts]
);
useEffect(() => {
@@ -236,7 +247,7 @@ export default function GunbuilderPage() {
const encoded = window.btoa(payload);
const origin = window.location?.origin ?? "";
const url = `${origin}/builder/build?build=${encodeURIComponent(
encoded,
encoded
)}`;
setShareUrl(url);
} catch {
@@ -254,10 +265,10 @@ export default function GunbuilderPage() {
// Use group ordering for the summary as well
const summaryCategoryOrder: CategoryId[] = useMemo(
() =>
CATEGORY_GROUPS.flatMap((group) => group.categoryIds).filter(
(id) => CATEGORIES.some((c) => c.id === id),
CATEGORY_GROUPS.flatMap((group) => group.categoryIds).filter((id) =>
CATEGORIES.some((c) => c.id === id)
),
[],
[]
);
const handleShare = async () => {
@@ -278,14 +289,14 @@ export default function GunbuilderPage() {
const selectedPartId = build[cat.id];
const part = parts.find(
(p) => p.id === selectedPartId && p.categoryId === cat.id,
(p) => p.id === selectedPartId && p.categoryId === cat.id
);
if (part) {
lines.push(
`${cat.name}: ${part.brand}${part.name} ($${part.price.toFixed(
2,
)})`,
2
)})`
);
} else {
lines.push(`${cat.name}: [Not selected]`);
@@ -299,7 +310,7 @@ export default function GunbuilderPage() {
setShareStatus("Build summary copied to clipboard.");
} catch {
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.");
} catch {
// 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 {
// Fallback to copying the link
@@ -344,7 +357,6 @@ export default function GunbuilderPage() {
return (
<main className="min-h-screen bg-black text-zinc-50">
<div className="mx-auto max-w-6xl px-4 py-6 lg:py-10">
{/* Header */}
<header className="mb-6">
@@ -361,10 +373,31 @@ export default function GunbuilderPage() {
update as you go. This early-access builder keeps your setup saved
locally so you can come back and refine it anytime.
</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>
</header>
{/* 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">
{/* Left: title + totals + primary actions */}
@@ -416,7 +449,9 @@ export default function GunbuilderPage() {
}}
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 ${
selectedParts.length === 0 ? "opacity-40 cursor-not-allowed" : ""
selectedParts.length === 0
? "opacity-40 cursor-not-allowed"
: ""
}`}
>
Clear Build
@@ -432,8 +467,8 @@ export default function GunbuilderPage() {
Share Your Build
</h3>
<p className="mt-1 text-[0.7rem] text-zinc-500">
Share this link to let others view your build or bookmark it to come
back later.
Share this link to let others view your build or bookmark it
to come back later.
</p>
<div className="mt-2 flex flex-col gap-2 md:flex-row md:items-center">
<div className="flex-1">
@@ -468,9 +503,7 @@ export default function GunbuilderPage() {
)}
{shareStatus && (
<p className="mt-1 text-[0.7rem] text-zinc-500">
{shareStatus}
</p>
<p className="mt-1 text-[0.7rem] text-zinc-500">{shareStatus}</p>
)}
</div>
</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">
<p className="mb-4 text-xs text-zinc-500">
Work top-down through the major sections. Each row shows your
current pick for that part type, with price and a direct buy
linkor a quick way to choose a part if you haven&apos;t picked
one yet.
current pick for that part type, with price and a direct buy linkor
a quick way to choose a part if you haven&apos;t picked one yet.
</p>
{loading && (
<p className="text-sm text-zinc-500">
Loading parts from backend
</p>
<p className="text-sm text-zinc-500">Loading parts from backend</p>
)}
{error && !loading && (
<p className="text-sm text-red-400">
@@ -532,7 +562,7 @@ export default function GunbuilderPage() {
<div className="space-y-6">
{CATEGORY_GROUPS.map((group) => {
const groupCategories = CATEGORIES.filter((c) =>
group.categoryIds.includes(c.id as CategoryId),
group.categoryIds.includes(c.id as CategoryId)
);
if (groupCategories.length === 0) {
@@ -584,7 +614,7 @@ export default function GunbuilderPage() {
partsByCategory[category.id] ?? [];
const selectedPartId = build[category.id];
const selectedPart = categoryParts.find(
(p) => p.id === selectedPartId,
(p) => p.id === selectedPartId
);
const hasParts = categoryParts.length > 0;
@@ -663,10 +693,9 @@ export default function GunbuilderPage() {
</>
) : hasParts ? (
<Link
href={`/builder/${category.id}`}
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"
onClick={() => {
// If you want to pre-select something later, you can handle here.
href={{
pathname: `/builder/${category.id}`,
query: { platform },
}}
>
Choose a Part
@@ -729,4 +758,4 @@ export default function GunbuilderPage() {
</div>
</main>
);
}
}