diff --git a/next.config.mjs b/next.config.mjs
index 391174225..a31804d1c 100644
--- a/next.config.mjs
+++ b/next.config.mjs
@@ -8,6 +8,29 @@ const nextConfig = {
// valid pages, so this flag lets 410 pages pass through unchanged.
output: 'standalone',
distDir: '.next-deploy',
+ // BUY-59983: /_next/image was returning HTTP 400 for every product image
+ // because no remotePatterns were configured, so Next.js rejected every
+ // upstream host the catalog uses. The list below is the union of hosts
+ // observed in /api/products/search results plus the QA-fixture domains
+ // (picsum.photos, images.unsplash.com). Add new merchants here when they
+ // first appear in the catalog rather than disabling optimization globally.
+ images: {
+ remotePatterns: [
+ { protocol: 'https', hostname: 'picsum.photos' },
+ { protocol: 'https', hostname: 'images.unsplash.com' },
+ { protocol: 'https', hostname: 'source.unsplash.com' },
+ { protocol: 'https', hostname: 'cdn.shopify.com' },
+ { protocol: 'https', hostname: 'm.media-amazon.com' },
+ { protocol: 'https', hostname: 'hnsgsfp.imgix.net' },
+ { protocol: 'https', hostname: 'media.nedigital.sg' },
+ { protocol: 'https', hostname: 'sg-live.slatic.net' },
+ { protocol: 'https', hostname: 'static1.fortytwo.sg' },
+ { protocol: 'https', hostname: 'giant.sg' },
+ { protocol: 'https', hostname: 'down-sg.img.susercontent.com' },
+ { protocol: 'https', hostname: 'www.courts.com.sg' },
+ { protocol: 'https', hostname: 'www.gaincity.com' },
+ ],
+ },
async redirects() {
return [
{
diff --git a/src/components/seo/SeoLandingPage.tsx b/src/components/seo/SeoLandingPage.tsx
index d01b4a42c..62c24587e 100644
--- a/src/components/seo/SeoLandingPage.tsx
+++ b/src/components/seo/SeoLandingPage.tsx
@@ -152,11 +152,19 @@ export async function SeoLandingPage({ config }: { config: SeoLandingPageConfig
-
- {products.map((product) => (
-
- ))}
-
+ {products.length === 0 ? (
+
+
+ Live product data is currently unavailable for this category. Please check back shortly or use the search to find products.
+
+
+ ) : (
+
+ {products.map((product) => (
+
+ ))}
+
+ )}
diff --git a/src/lib/seo-landing-pages.ts b/src/lib/seo-landing-pages.ts
index c8e64f5ac..884629740 100644
--- a/src/lib/seo-landing-pages.ts
+++ b/src/lib/seo-landing-pages.ts
@@ -106,7 +106,23 @@ function formatMerchantName(value?: string | null) {
.replace(/\b\w/g, (char) => char.toUpperCase());
}
-function normalizeProduct(item: SearchApiItem, fallbackCurrency: string): LandingProduct {
+function isValidLandingProduct(product: LandingProduct): boolean {
+ return (
+ !!product.id &&
+ product.name.trim().length > 0 &&
+ product.name !== "Untitled product" &&
+ product.price !== null &&
+ product.price >= 0
+ );
+}
+
+function normalizeProduct(item: SearchApiItem, fallbackCurrency: string, fallbackCountry: string): LandingProduct {
+ const safeName =
+ typeof item.name === "string" && item.name.trim() ? item.name : typeof item.title === "string" && item.title.trim() ? item.title : "Untitled product";
+ const fallbackSearchParams = new URLSearchParams({
+ q: safeName,
+ country: fallbackCountry,
+ });
const numericPrice =
typeof item.price === "number"
? item.price
@@ -116,18 +132,20 @@ function normalizeProduct(item: SearchApiItem, fallbackCurrency: string): Landin
return {
id: String(item.id),
- name: item.name || item.title || "Untitled product",
+ name: safeName,
price: Number.isFinite(numericPrice) ? numericPrice : null,
currency: item.currency || fallbackCurrency,
merchant: formatMerchantName(item.merchant || item.source),
imageUrl: item.image_url || null,
- href: item.affiliate_url || item.buy_url || item.url || "#",
+ href: item.affiliate_url || item.buy_url || item.url || `/search?${fallbackSearchParams.toString()}`,
brand: item.brand || null,
category: item.category || null,
};
}
export async function getSeoLandingProducts(config: SeoLandingPageConfig): Promise {
+ const fallback = config.fallbackProducts.filter(isValidLandingProduct);
+
try {
const params = new URLSearchParams({
q: config.searchQuery,
@@ -139,23 +157,51 @@ export async function getSeoLandingProducts(config: SeoLandingPageConfig): Promi
headers: {
Accept: "application/json",
},
- next: { revalidate: 60 * 60 * 4 },
+ next: { revalidate: 60 * 15 },
+ signal: AbortSignal.timeout(10000),
});
+ // Non-OK HTTP response - return fallback so page still renders
if (!response.ok) {
- throw new Error(`Search request failed with ${response.status}`);
+ console.warn(`[seo] search HTTP ${response.status} for ${config.slug}`);
+ return fallback;
}
const data = (await response.json()) as SearchApiResponse;
+
+ // Check for degraded API response - return empty array to show honest empty state
+ // instead of misleading fallback products
+ if (data.degraded || (data.total !== undefined && data.total === 0)) {
+ console.warn(
+ `[seo] degraded API response for ${config.slug}: degraded=${data.degraded}, total=${data.total}`
+ );
+ return [];
+ }
+
const items = data.items || data.results || [];
+ // Empty items array - return empty to show honest state
if (!Array.isArray(items) || items.length === 0) {
- throw new Error("Search response was empty");
+ console.warn(`[seo] empty items array for ${config.slug}`);
+ return [];
+ }
+
+ const products = items
+ .map((item) => normalizeProduct(item, config.currency, config.country))
+ .filter(isValidLandingProduct)
+ .slice(0, 8);
+
+ // No valid products after normalization - return empty
+ if (products.length === 0) {
+ console.warn(`[seo] no valid products after normalization for ${config.slug}`);
+ return [];
}
- return items.map((item) => normalizeProduct(item, config.currency)).slice(0, 8);
- } catch {
- return config.fallbackProducts;
+ return products;
+ } catch (err) {
+ // Network failure - return fallback so page still renders
+ console.warn(`[seo] network failure for ${config.slug}:`, err);
+ return fallback;
}
}