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; } }