lots of fixes. cant remember them all

This commit is contained in:
2025-12-18 09:42:25 -05:00
parent 607939c468
commit 4c2d767d09
28 changed files with 3342 additions and 996 deletions
+150
View File
@@ -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;
}
+90
View File
@@ -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;
}
+100
View File
@@ -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>);