lots of fixes. cant remember them all
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
// /lib/buildOverlaps.ts
|
||||
import type { CategoryId } from "@/types/gunbuilder";
|
||||
|
||||
export type OverlapChipModel = {
|
||||
key: string;
|
||||
tone: "warning" | "info";
|
||||
label: string;
|
||||
detail?: string;
|
||||
};
|
||||
|
||||
export const COMPLETE_UPPER_CATEGORY: CategoryId = "complete-upper";
|
||||
export const COMPLETE_LOWER_CATEGORY: CategoryId = "complete-lower";
|
||||
|
||||
export const UPPER_OVERLAP_CATEGORIES = new Set<CategoryId>([
|
||||
"upper-receiver",
|
||||
"bcg",
|
||||
"barrel",
|
||||
"gas-block",
|
||||
"gas-tube",
|
||||
"muzzle-device",
|
||||
"suppressor",
|
||||
"handguard",
|
||||
"charging-handle",
|
||||
]);
|
||||
|
||||
export const LOWER_OVERLAP_CATEGORIES = new Set<CategoryId>([
|
||||
"lower-receiver",
|
||||
"lower-parts",
|
||||
"trigger",
|
||||
"grip",
|
||||
"safety",
|
||||
"buffer",
|
||||
"stock",
|
||||
]);
|
||||
|
||||
export type BuildState = Partial<Record<CategoryId, string>>;
|
||||
|
||||
function selectedCount(build: BuildState, set: Set<CategoryId>) {
|
||||
let count = 0;
|
||||
for (const cid of set) if (build[cid]) count++;
|
||||
return count;
|
||||
}
|
||||
|
||||
function selectedList(build: BuildState, set: Set<CategoryId>) {
|
||||
const list: CategoryId[] = [];
|
||||
for (const cid of set) if (build[cid]) list.push(cid);
|
||||
return list;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chips to show on a given category row (education / “you may not need both”)
|
||||
*/
|
||||
export function getOverlapChipsForCategory(
|
||||
categoryId: CategoryId,
|
||||
build: BuildState
|
||||
): OverlapChipModel[] {
|
||||
const chips: OverlapChipModel[] = [];
|
||||
|
||||
const hasCompleteUpper = !!build[COMPLETE_UPPER_CATEGORY];
|
||||
const hasCompleteLower = !!build[COMPLETE_LOWER_CATEGORY];
|
||||
|
||||
// ---- Upper overlap ----
|
||||
if (categoryId === COMPLETE_UPPER_CATEGORY) {
|
||||
const count = selectedCount(build, UPPER_OVERLAP_CATEGORIES);
|
||||
if (count > 0) {
|
||||
chips.push({
|
||||
key: "upper-complete-overlaps-subparts",
|
||||
tone: "warning",
|
||||
label: "Overlaps selected upper parts",
|
||||
detail: `${count} upper part${count === 1 ? "" : "s"} also selected`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (UPPER_OVERLAP_CATEGORIES.has(categoryId) && hasCompleteUpper) {
|
||||
chips.push({
|
||||
key: "upper-subpart-in-complete",
|
||||
tone: "info",
|
||||
label: "Included in Complete Upper",
|
||||
detail: "You have both selected (compare if you want)",
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Lower overlap ----
|
||||
if (categoryId === COMPLETE_LOWER_CATEGORY) {
|
||||
const count = selectedCount(build, LOWER_OVERLAP_CATEGORIES);
|
||||
if (count > 0) {
|
||||
chips.push({
|
||||
key: "lower-complete-overlaps-subparts",
|
||||
tone: "warning",
|
||||
label: "Overlaps selected lower parts",
|
||||
detail: `${count} lower part${count === 1 ? "" : "s"} also selected`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (LOWER_OVERLAP_CATEGORIES.has(categoryId) && hasCompleteLower) {
|
||||
chips.push({
|
||||
key: "lower-subpart-in-complete",
|
||||
tone: "info",
|
||||
label: "Included in Complete Lower",
|
||||
detail: "You have both selected (compare if you want)",
|
||||
});
|
||||
}
|
||||
|
||||
return chips;
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-blocking “heads up” messages when selecting a part.
|
||||
* (Use for toasts / shareStatus strip — no auto-removal)
|
||||
*/
|
||||
export function getSelectionHints(
|
||||
nextCategoryId: CategoryId,
|
||||
build: BuildState
|
||||
): string[] {
|
||||
const hints: string[] = [];
|
||||
|
||||
if (nextCategoryId === COMPLETE_UPPER_CATEGORY) {
|
||||
const overlaps = selectedList(build, UPPER_OVERLAP_CATEGORIES);
|
||||
if (overlaps.length > 0) {
|
||||
hints.push(
|
||||
"Heads up: Complete Upper overlaps with some selected upper parts. Nothing was removed — compare options and keep what you want."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (UPPER_OVERLAP_CATEGORIES.has(nextCategoryId) && build[COMPLETE_UPPER_CATEGORY]) {
|
||||
hints.push(
|
||||
"Heads up: You also have a Complete Upper selected. Nothing was removed — compare options and keep what you want."
|
||||
);
|
||||
}
|
||||
|
||||
if (nextCategoryId === COMPLETE_LOWER_CATEGORY) {
|
||||
const overlaps = selectedList(build, LOWER_OVERLAP_CATEGORIES);
|
||||
if (overlaps.length > 0) {
|
||||
hints.push(
|
||||
"Heads up: Complete Lower overlaps with some selected lower parts. Nothing was removed — compare options and keep what you want."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (LOWER_OVERLAP_CATEGORIES.has(nextCategoryId) && build[COMPLETE_LOWER_CATEGORY]) {
|
||||
hints.push(
|
||||
"Heads up: You also have a Complete Lower selected. Nothing was removed — compare options and keep what you want."
|
||||
);
|
||||
}
|
||||
|
||||
return hints;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// app/lib/catalog.ts
|
||||
export const API_BASE_URL =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
||||
|
||||
export type ProductListItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
brand: string;
|
||||
platform: string;
|
||||
partRole: string;
|
||||
categoryKey?: string | null;
|
||||
price?: number | null;
|
||||
buyUrl?: string | null;
|
||||
imageUrl?: string | null;
|
||||
slug?: string | null; // if your API returns it; otherwise we derive it client-side
|
||||
};
|
||||
|
||||
export type ProductDetail = {
|
||||
id: string;
|
||||
name: string;
|
||||
brand: string;
|
||||
platform: string;
|
||||
partRole: string;
|
||||
rawCategoryKey?: string | null;
|
||||
description?: string | null;
|
||||
shortDescription?: string | null;
|
||||
imageUrl?: string | null;
|
||||
offers?: Array<{
|
||||
merchantName?: string;
|
||||
price?: number | null;
|
||||
buyUrl?: string | null;
|
||||
inStock?: boolean | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
export function slugify(input: string) {
|
||||
return (input ?? "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/(^-|-$)/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Your existing list endpoint:
|
||||
* GET /api/products?platform=AR-15&partRoles=complete-upper
|
||||
*/
|
||||
export async function fetchProducts(params: {
|
||||
platform: string;
|
||||
partRole: string;
|
||||
}) {
|
||||
const { platform, partRole } = params;
|
||||
|
||||
const url =
|
||||
`${API_BASE_URL}/api/products?` +
|
||||
new URLSearchParams({
|
||||
platform,
|
||||
partRoles: partRole,
|
||||
});
|
||||
|
||||
const res = await fetch(url, { cache: "no-store" });
|
||||
if (!res.ok) throw new Error(`Failed to load products (${res.status})`);
|
||||
const data = (await res.json()) as ProductListItem[];
|
||||
|
||||
// Ensure each item has a "productSlug" of the form "{id}-{slugified-name}"
|
||||
return data.map((p) => ({
|
||||
...p,
|
||||
productSlug: `${p.id}-${slugify(p.name)}`,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* You likely have (or should add) a detail endpoint like:
|
||||
* GET /api/products/{id}?platform=AR-15 OR GET /api/products/{id}
|
||||
*
|
||||
* We'll call /api/products/{id} and optionally include platform as a query param.
|
||||
*/
|
||||
export async function fetchProductById(params: {
|
||||
id: string;
|
||||
platform?: string;
|
||||
}) {
|
||||
const { id, platform } = params;
|
||||
|
||||
const url =
|
||||
`${API_BASE_URL}/api/products/${encodeURIComponent(id)}` +
|
||||
(platform ? `?${new URLSearchParams({ platform })}` : "");
|
||||
|
||||
const res = await fetch(url, { cache: "no-store" });
|
||||
if (!res.ok) throw new Error(`Failed to load product (${res.status})`);
|
||||
return (await res.json()) as ProductDetail;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { CategoryId } from "@/types/gunbuilder";
|
||||
|
||||
/**
|
||||
* Normalize backend part roles into a canonical kebab-case form.
|
||||
* - trims whitespace
|
||||
* - lowercases
|
||||
* - converts snake_case / ENUM_CASE to kebab-case
|
||||
*/
|
||||
export const normalizePartRole = (role: string) =>
|
||||
role.trim().toLowerCase().replace(/_/g, "-");
|
||||
|
||||
/**
|
||||
* Maps canonical kebab-case CategoryIds to their associated part roles from the backend.
|
||||
*
|
||||
* Notes:
|
||||
* - Keep the list here as the *source of truth*.
|
||||
* - The derived `PART_ROLE_TO_CATEGORY` map below stores both the original and normalized keys.
|
||||
*/
|
||||
export const CATEGORY_TO_PART_ROLES: Partial<Record<CategoryId, string[]>> = {
|
||||
// ===== UPPER =====
|
||||
"upper-receiver": ["upper-receiver"],
|
||||
// (optional) Back-compat if anything still uses the old ids:
|
||||
// upper: ["upper-receiver", "upper"],
|
||||
|
||||
"complete-upper": ["complete-upper"],
|
||||
barrel: ["barrel"],
|
||||
"gas-block": ["gas-block"],
|
||||
"gas-tube": ["gas-tube"],
|
||||
"muzzle-device": ["muzzle-device", "compensator", "brake"],
|
||||
suppressor: ["suppressor"],
|
||||
handguard: ["handguard"],
|
||||
"charging-handle": ["charging-handle"],
|
||||
bcg: ["bcg", "bolt-carrier-group"],
|
||||
|
||||
// ===== LOWER =====
|
||||
"lower-receiver": ["lower-receiver", "lower", "LOWER_RECEIVER_STRIPPED"],
|
||||
// (optional) Back-compat if anything still uses the old ids:
|
||||
|
||||
"complete-lower": ["complete-lower"],
|
||||
"lower-parts": ["lower-parts-kit", "lower-parts"],
|
||||
trigger: ["trigger", "trigger-kit"],
|
||||
grip: ["pistol-grip", "grip"],
|
||||
safety: ["safety", "safety-selector"],
|
||||
buffer: ["buffer-kit", "buffer"],
|
||||
stock: ["stock"],
|
||||
|
||||
// ===== OPTICS =====
|
||||
sights: ["sight", "sights", "iron-sights"],
|
||||
optic: ["optic", "optics"],
|
||||
|
||||
// ===== ACCESSORIES =====
|
||||
magazine: ["magazine", "mag", "magazine-ar15", "magazine-308", "drum-magazine"],
|
||||
"weapon-light": [
|
||||
"weapon-light",
|
||||
"light",
|
||||
"weapon-light-laser",
|
||||
"light-laser-combo",
|
||||
"laser",
|
||||
],
|
||||
foregrip: ["vertical-grip", "angled-foregrip", "foregrip", "handstop"],
|
||||
bipod: ["bipod"],
|
||||
sling: ["sling", "sling-mount", "sling-swivel", "qd-sling-mount"],
|
||||
"rail-accessory": [
|
||||
"rail-section",
|
||||
"picatinny-rail-section",
|
||||
"m-lok-rail-section",
|
||||
"keymod-rail-section",
|
||||
"rail-cover",
|
||||
"rail-panel",
|
||||
],
|
||||
tools: [
|
||||
"tool",
|
||||
"armorer-tool",
|
||||
"armorer-wrench",
|
||||
"cleaning-kit",
|
||||
"bore-snake",
|
||||
"vise-block",
|
||||
"torque-wrench",
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Reverse lookup: backend partRole -> CategoryId.
|
||||
*
|
||||
* Implementation details:
|
||||
* - Stores BOTH the original role and its normalized version as keys.
|
||||
* - De-dupes role lists per category.
|
||||
*/
|
||||
export const PART_ROLE_TO_CATEGORY: Record<string, CategoryId> = Object.entries(
|
||||
CATEGORY_TO_PART_ROLES
|
||||
).reduce((acc, [categoryId, roles]) => {
|
||||
const unique = Array.from(new Set(roles ?? []));
|
||||
|
||||
for (const role of unique) {
|
||||
acc[role] = categoryId as CategoryId;
|
||||
acc[normalizePartRole(role)] = categoryId as CategoryId;
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, {} as Record<string, CategoryId>);
|
||||
Reference in New Issue
Block a user