Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 [
{
Expand Down
18 changes: 13 additions & 5 deletions src/components/seo/SeoLandingPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -152,11 +152,19 @@ export async function SeoLandingPage({ config }: { config: SeoLandingPageConfig
</Link>
</div>

<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
{products.map((product) => (
<ProductGridCard key={product.id} product={product} />
))}
</div>
{products.length === 0 ? (
<div className="rounded-2xl border border-dashed border-slate-300 bg-slate-50 p-8 text-center">
<p className="text-slate-500">
Live product data is currently unavailable for this category. Please check back shortly or use the search to find products.
</p>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
{products.map((product) => (
<ProductGridCard key={product.id} product={product} />
))}
</div>
)}
</div>
</section>

Expand Down
64 changes: 55 additions & 9 deletions src/lib/seo-landing-pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<LandingProduct[]> {
const fallback = config.fallbackProducts.filter(isValidLandingProduct);

try {
const params = new URLSearchParams({
q: config.searchQuery,
Expand All @@ -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;
}
}

Expand Down