From 374ae12c9006849b6b073a59ac16507b5125a272 Mon Sep 17 00:00:00 2001 From: "Oracle (CDO)" Date: Fri, 3 Jul 2026 10:52:28 +0000 Subject: [PATCH 1/3] fix(seo): BUY-59851 harden landing-page product rendering against empty cards SEO landing pages (/best-gaming-laptops-us, /laptop-singapore, /air-purifier-singapore, /best-robot-vacuums-2026) could render visually styled but empty product cards when the /v1/products/search response returned malformed items (missing names/prices, '#' hrefs). The old normalizeProduct emitted 'Untitled product' names, null prices, and '#' links that rendered as bare gradient containers. - Add isValidLandingProduct guard (requires id, non-empty name, non-null price) - Filter both API-mapped and fallback products through the guard - Throw to fallback when API yields zero valid products - Replace '#' href fallback with deterministic /search?q=&country= links --- src/lib/seo-landing-pages.ts | 37 +++++++++++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/src/lib/seo-landing-pages.ts b/src/lib/seo-landing-pages.ts index c8e64f5ac..5717b55dd 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, @@ -153,9 +171,18 @@ export async function getSeoLandingProducts(config: SeoLandingPageConfig): Promi throw new Error("Search response was empty"); } - return items.map((item) => normalizeProduct(item, config.currency)).slice(0, 8); + const products = items + .map((item) => normalizeProduct(item, config.currency, config.country)) + .filter(isValidLandingProduct) + .slice(0, 8); + + if (products.length === 0) { + throw new Error("Search response contained no valid products"); + } + + return products; } catch { - return config.fallbackProducts; + return fallback; } } From a8627c1d2bc99d34422a038f1e301d9196708b8d Mon Sep 17 00:00:00 2001 From: "Oracle (CDO)" Date: Sat, 4 Jul 2026 08:54:10 +0000 Subject: [PATCH 2/3] Fix Next image proxy host allowlist --- next.config.mjs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) 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 [ { From 13a49e65dfe722c6dd7a56090bd4862151e32539 Mon Sep 17 00:00:00 2001 From: "Oracle (CDO)" Date: Mon, 6 Jul 2026 10:21:56 +0000 Subject: [PATCH 3/3] fix(seo): BUY-60202 show honest empty state when search API returns degraded results --- src/components/seo/SeoLandingPage.tsx | 18 ++++++++++++----- src/lib/seo-landing-pages.ts | 29 ++++++++++++++++++++++----- 2 files changed, 37 insertions(+), 10 deletions(-) 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 5717b55dd..884629740 100644 --- a/src/lib/seo-landing-pages.ts +++ b/src/lib/seo-landing-pages.ts @@ -157,18 +157,33 @@ 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 @@ -176,12 +191,16 @@ export async function getSeoLandingProducts(config: SeoLandingPageConfig): Promi .filter(isValidLandingProduct) .slice(0, 8); + // No valid products after normalization - return empty if (products.length === 0) { - throw new Error("Search response contained no valid products"); + console.warn(`[seo] no valid products after normalization for ${config.slug}`); + return []; } return products; - } catch { + } catch (err) { + // Network failure - return fallback so page still renders + console.warn(`[seo] network failure for ${config.slug}:`, err); return fallback; } }