cleaning up api calls and the builder page

This commit is contained in:
2025-12-30 13:53:52 -05:00
parent ee91fa1353
commit e69205e6f8
6 changed files with 82 additions and 1556 deletions
+74 -95
View File
@@ -158,7 +158,7 @@ export default function GunbuilderPage() {
// Parts data state
// -----------------------------
const [parts, setParts] = useState<Part[]>([]);
const [loading, setLoading] = useState(true);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// -----------------------------
@@ -379,104 +379,85 @@ export default function GunbuilderPage() {
};
// -----------------------------
// Fetch products (scoped + universal merge)
// Hydrate selected parts (only) from backend
// -----------------------------
const lastHydrateKeyRef = useRef<string>("");
useEffect(() => {
const controller = new AbortController();
async function fetchProducts() {
try {
setLoading(true);
async function hydrateSelected() {
const ids = (Object.values(build).filter(Boolean) as string[])
.map(String)
.sort();
const key = ids.join(",");
if (key === lastHydrateKeyRef.current) return;
lastHydrateKeyRef.current = key;
if (ids.length === 0) {
setParts([]);
setError(null);
const size = 2000; // temporary "big bucket" until we optimize
const scopedUrl = `${API_BASE_URL}/api/v1/products?platform=${encodeURIComponent(
platform
)}&page=0&size=${size}`;
const universalUrl = `${API_BASE_URL}/api/v1/products?page=0&size=${size}`;
const [scopedRes, universalRes] = await Promise.all([
fetch(scopedUrl, { signal: controller.signal }),
fetch(universalUrl, { signal: controller.signal }).catch(
() => null as any
),
]);
if (!scopedRes || !scopedRes.ok) {
const status = scopedRes?.status ?? "";
throw new Error(`Failed to load products (${status})`);
}
const scopedJson = await scopedRes.json();
const scopedData: GunbuilderProductFromApi[] =
unwrapPage<GunbuilderProductFromApi>(scopedJson);
let universalData: GunbuilderProductFromApi[] = [];
if (universalRes && universalRes.ok) {
const universalJson = await universalRes.json();
universalData = unwrapPage<GunbuilderProductFromApi>(universalJson);
}
const normalize = (data: GunbuilderProductFromApi[]): Part[] =>
data
.map((p): Part | null => {
const normalizedRole = (p.partRole ?? "")
.trim()
.toLowerCase()
.replace(/_/g, "-");
const categoryId = resolveCategoryId(normalizedRole);
if (!categoryId) return null;
// Only accept universal fetch results for "universal" categories
if (
data === universalData &&
!UNIVERSAL_CATEGORIES.has(categoryId)
) {
return null;
}
const buyUrl = p.buyUrl ?? undefined;
return {
id: String(p.id),
categoryId,
name: p.name,
brand: p.brand,
price: p.price ?? 0,
imageUrl:
(p as any).imageUrl ?? (p as any).mainImageUrl ?? undefined,
affiliateUrl: buyUrl,
url: buyUrl,
notes: undefined,
};
})
.filter((p): p is Part => p !== null);
const scopedParts = normalize(scopedData);
const universalParts = normalize(universalData);
// Merge (avoid duplicates across calls)
const seen = new Set<string>();
const merged: Part[] = [];
for (const p of [...scopedParts, ...universalParts]) {
const key = `${p.categoryId}:${p.id}`;
if (seen.has(key)) continue;
seen.add(key);
merged.push(p);
}
setParts(merged);
setLoading(false);
return;
}
setLoading(true);
setError(null);
try {
const res = await fetch(
`${API_BASE_URL}/api/v1/catalog/products/by-ids`,
{
method: "POST",
signal: controller.signal,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids }),
}
);
if (!res.ok) throw new Error(`Failed to hydrate parts (${res.status})`);
const data: GunbuilderProductFromApi[] = await res.json();
const hydrated: Part[] = data
.map((p): Part | null => {
const normalizedRole = (p.partRole ?? "")
.trim()
.toLowerCase()
.replace(/_/g, "-");
const categoryId = resolveCategoryId(normalizedRole);
if (!categoryId) return null;
const buyUrl = p.buyUrl ?? undefined;
return {
id: String(p.id),
categoryId,
name: p.name,
brand: p.brand,
price: p.price ?? 0,
imageUrl: p.imageUrl ?? undefined,
affiliateUrl: buyUrl,
url: buyUrl,
notes: undefined,
};
})
.filter((p): p is Part => p !== null);
setParts(hydrated);
} catch (err: any) {
if (err?.name === "AbortError") return;
setError(err?.message ?? "Failed to load products");
setError(err?.message ?? "Failed to hydrate selected parts");
} finally {
setLoading(false);
}
}
fetchProducts();
hydrateSelected();
return () => controller.abort();
}, [platform]);
}, [build]);
// -----------------------------
// Persist build to localStorage
@@ -1367,11 +1348,13 @@ export default function GunbuilderPage() {
<Link2 className="h-3.5 w-3.5 text-black" />
</a>
</>
) : hasParts ? (
) : (
<Link
href={`/parts/p/${encodeURIComponent(
href={`/parts/${encodeURIComponent(
category.id
)}?platform=${encodeURIComponent(
platform
)}/${encodeURIComponent(category.id)}`}
)}`}
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"
>
<svg
@@ -1389,10 +1372,6 @@ export default function GunbuilderPage() {
</svg>
Choose
</Link>
) : (
<span className="text-[0.7rem] text-zinc-600">
</span>
)}
</div>
</td>