cleaning up api calls and the builder page
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user