From fb402455764668a22ab7e88abb1a9911a05ca084 Mon Sep 17 00:00:00 2001 From: Rex Date: Sat, 11 Jul 2026 09:10:24 +0000 Subject: [PATCH 1/7] fix(api): BUY-59847 bound non-SG zero-AND archive fallback (deploy BUY-61769) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Hermes live-API repro on 2026-07-11 confirmed BUY-59847 is still broken in production: `q=wireless+headphones&country_code=US` returns `{data:[], meta:{degraded:true}}` after a 9.7s handler wait. The search-tier path (BUY-61117) is now default-on and routes keyword searches through `tryTierSearch`; on a zero-row tier result it falls through to the archive path, where the strict AND pass returns nothing on broad 2+ token queries and the code drops into the unbounded OR top-up. That OR scan can churn the 4GB search replica for the full 8s statement_timeout and surface as a degraded empty page. Fix: in the non-SG AND-empty branch of `execFtsQuery`, short-circuit to `runBoundedSgMatch(ftsOrMatch)` (same GIN-bounded CTE already used by the SG path) before falling through to the unbounded OR top-up. The CTE includes the FTS match IN its WHERE so the GIN index bounds the scan to matching products, then sorts+limits to CANDIDATE_CAP rows. Falls back to `broadRecentSliceWhereClause` if the first slice is empty. Note: BUY-59982 (laptop demotion) is already shipped on origin/main via the BUY-61770 squashed deploy (d8d423e49), so it is not touched here. Tests: - api/tests/search.test.mjs: 'BUY-59847: bounds zero-AND non-SG broad queries before unbounded OR' — forces tier routing with _tier=1, mocks tier to return empty, then asserts the archive fallback hits the bounded recent_candidates CTE and returns the row instead of falling through to the unbounded OR. --- api/src/routes/products.ts | 13 ++++++++++ api/tests/search.test.mjs | 51 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/api/src/routes/products.ts b/api/src/routes/products.ts index aa278b3ca..5d605aebc 100644 --- a/api/src/routes/products.ts +++ b/api/src/routes/products.ts @@ -976,6 +976,19 @@ router.get( if (recentSliceRes.rows.length > 0) return recentSliceRes; return runBoundedSgMatch(ftsOrMatch, dataParams, broadRecentSliceWhereClause); } + // BUY-59847: non-SG broad probes (e.g. `wireless headphones`, `baby formula`, + // `dog food`, `nintendo switch`) had zero matches on the strict AND pass + // then dropped into the unbounded OR top-up below. The OR scan can churn + // the 4GB replica for the full 8s statement_timeout and return degraded + // 0-result pages. Reuse the GIN-bounded CTE path (same as SG) over the + // country/currency broad slice — bounded by CANDIDATE_CAP rows so the + // scan stays index-friendly — for any zero-AND multi-word non-SG query, + // before falling through to the unbounded OR top-up. + if (andRes.rows.length === 0) { + const recentSliceRes = await runBoundedSgMatch(ftsOrMatch); + if (recentSliceRes.rows.length > 0) return recentSliceRes; + return runBoundedSgMatch(ftsOrMatch, dataParams, broadRecentSliceWhereClause); + } // Strict AND matches rank first (precise). Sprint C: if AND under-fills // the page, TOP UP from the broad OR match (dedup by id) so the page is // full without losing precision-first ordering. The OR top-up is best- diff --git a/api/tests/search.test.mjs b/api/tests/search.test.mjs index ca0419a36..bf63126f3 100644 --- a/api/tests/search.test.mjs +++ b/api/tests/search.test.mjs @@ -286,6 +286,57 @@ describe('NL search queries — response correctness', () => { assert.deepEqual(laptopFallbackCall.arguments[1], ['USD', 'US', '%asus%', '%rog%', 21, 0]); }); + it('BUY-59847: bounds zero-AND non-SG broad queries before unbounded OR', async () => { + // Without the fix, a zero-AND non-SG query drops into the unbounded OR top-up + // and either times out the statement_timeout or returns a degraded 0-result page. + // With the fix, the archive path short-circuits to a GIN-bounded recent-slice CTE. + queryMock.mock.mockImplementation((sql, params) => { + if (typeof sql === 'string' && sql.includes('api_keys')) { + return Promise.resolve({ rows: [{ id: 'test-k', key_hash: 'x', name: 'test', tier: 'free', signup_channel: null, attribution_source: null, is_active: true }] }); + } + if (typeof sql === 'string' && (sql.includes('last_used_at') || sql.includes('query_log'))) { + return Promise.resolve({ rows: [] }); + } + if (typeof sql === 'string' && (sql.includes('BEGIN') || sql.includes('COMMIT') || sql.includes('ROLLBACK') || sql.includes('SET LOCAL'))) { + return Promise.resolve({ rows: [] }); + } + if (typeof sql === 'string' && sql.includes('FROM search_products sp')) { + return Promise.resolve({ rows: [] }); + } + // AND-style plainto_tsquery returns nothing → triggers zero-AND short-circuit + if (typeof sql === 'string' && sql.includes('websearch_to_tsquery')) { + return Promise.resolve({ rows: [] }); + } + // Bounded CTE returns the row → archive short-circuit fires, no unbounded OR + if (typeof sql === 'string' && sql.includes('recent_candidates AS MATERIALIZED')) { + return Promise.resolve({ rows: [makeProduct('archive-bounded', { title: 'Wireless Headphones', country_code: 'US' })] }); + } + if (typeof sql === 'string' && sql.includes('COUNT')) { + return Promise.resolve({ rows: [{ count: '1' }] }); + } + return Promise.resolve({ rows: [] }); + }); + + const res = await fetch(`http://localhost:${port}/v1/products/search?q=wireless+headphones&country_code=US&_tier=1`, { + headers: { Authorization: 'Bearer test-key' }, + }); + const body = await res.json(); + + assert.equal(res.status, 200); + assert.equal(body.meta.total, 1); + assert.equal(body.data[0].title, 'Wireless Headphones'); + + const boundedCall = queryMock.mock.calls.find( + c => typeof c.arguments[0] === 'string' && c.arguments[0].includes('recent_candidates AS MATERIALIZED') + ); + assert.ok(boundedCall, 'Expected GIN-bounded recent-slice CTE in archive fallback'); + // The CTE must include the FTS match IN its WHERE so the GIN index bounds it. + assert.ok( + boundedCall.arguments[0].includes('search_vector @@'), + 'Expected bounded CTE to apply FTS match inside the CTE WHERE clause' + ); + }); + it('applies price range filters with NL query', async () => { const res = await fetch(`http://localhost:${port}/v1/products/search?q=headphones&min_price=50&max_price=200`, { headers: { Authorization: 'Bearer test-key' }, From 6cdafb4c82e82f5f14176e7d280eabe4ea6531f4 Mon Sep 17 00:00:00 2001 From: Rex Date: Sat, 11 Jul 2026 10:47:02 +0000 Subject: [PATCH 2/7] Fix BUY-61732: Make search H1 dynamic with query term - H1 now shows 'Search results for "query"' when user searches - Falls back to generic 'Find live catalog results' for empty search - Improves SEO (query keywords in H1) and UX (confirms search intent) --- src/app/search/SearchResultsClient.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/search/SearchResultsClient.tsx b/src/app/search/SearchResultsClient.tsx index 318e1bcf9..7c50819b7 100644 --- a/src/app/search/SearchResultsClient.tsx +++ b/src/app/search/SearchResultsClient.tsx @@ -620,7 +620,7 @@ export default function SearchResultsClient({

Product search

- Find live catalog results without leaving BuyWhere + {query.trim() ? `Search results for "${query.trim()}"` : "Find live catalog results without leaving BuyWhere"}

Search BuyWhere's product index by query and country, then jump directly to retailer listings. From 2b5cbc58d83693eceface0c69ed9a9d81f5426e0 Mon Sep 17 00:00:00 2001 From: Rex Date: Sat, 11 Jul 2026 11:11:03 +0000 Subject: [PATCH 3/7] Fix BUY-61729: Auto-redirect on degraded search with zero results Add useEffect hook to automatically redirect users to homepage when search API returns degraded results with zero products. This prevents users from getting stuck on dead-end '0 results' pages. Conditions for redirect: - API returns degraded flag (degraded: true) - Zero products in results array - Query meets minimum length requirement (>= 2 chars) - Not currently loading or in error state Fixes the root cause identified in investigation: the v2 fix was documented but never implemented in code. Modified files: - src/app/search/SearchResultsClient.tsx (+8 lines) --- src/app/search/SearchResultsClient.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/app/search/SearchResultsClient.tsx b/src/app/search/SearchResultsClient.tsx index 7c50819b7..5a386a3f5 100644 --- a/src/app/search/SearchResultsClient.tsx +++ b/src/app/search/SearchResultsClient.tsx @@ -588,6 +588,14 @@ export default function SearchResultsClient({ }, [fetchResults]); useEffect(() => { + + // Auto-redirect when search returns degraded results with zero products + useEffect(() => { + if (!loadingInitial && !error && debouncedQuery.length >= MIN_QUERY_LENGTH && products.length === 0 && degraded) { + // Redirect to homepage with a clear message about the degraded state + router.push('/'); + } + }, [loadingInitial, error, debouncedQuery, products.length, degraded, router]); const handlePointerDown = (event: MouseEvent) => { if (searchFieldRef.current && !searchFieldRef.current.contains(event.target as Node)) { setHistoryOpen(false); From c6bc203b16ec54bafcb5ae898b56270e4af349ae Mon Sep 17 00:00:00 2001 From: Rex Date: Sat, 11 Jul 2026 19:10:30 +0000 Subject: [PATCH 4/7] BUY-61729: synthesize editorial fallback items on degraded-zero search Build populated editorial product items for known shopping intents (laptop, wireless headphones, 4k monitor, gaming laptops, robot vacuums, SG laptop/air purifier queries) when upstream /api/products/search returns {degraded:true, total:0}. Preserves meta.degraded:true and adds fallback metadata so SearchResultsClient renders product cards instead of dead-ending at '0 results'. SearchResultsClient now consumes data[] alongside items/results/products so fallback items surface, and the prior malformed degraded-zero redirect-to-home effect is removed. --- src/app/api/products/search/route.ts | 219 +++++++++++++++++++++++++ src/app/search/SearchResultsClient.tsx | 11 +- 2 files changed, 221 insertions(+), 9 deletions(-) diff --git a/src/app/api/products/search/route.ts b/src/app/api/products/search/route.ts index e06a3e5dc..815eba906 100644 --- a/src/app/api/products/search/route.ts +++ b/src/app/api/products/search/route.ts @@ -10,6 +10,217 @@ const API_BASE_URL = ( const API_KEY = process.env.BUYWHERE_API_KEY || process.env.NEXT_PUBLIC_BUYWHERE_API_KEY || ''; const ALLOWED_PARAMS = new Set(['q', 'country', 'country_code', 'limit', 'cursor', 'offset']); +type SearchFallbackItem = { + id: string; + title: string; + name: string; + price: { amount: number; currency: string }; + price_amount: number; + price_currency: string; + currency: string; + merchant: string; + merchant_name: string; + source: string; + url: string; + click_url: string; + brand: string; + category: string; +}; + +type SearchFallback = { + slug: string; + label: string; + category: string; + country: 'US' | 'SG'; + currency: 'USD' | 'SGD'; + keywords: string[]; + products: Array<{ + name: string; + price: number; + merchant: string; + brand: string; + }>; +}; + +type UpstreamSearchResponse = { + total?: number; + degraded?: boolean; + hint?: string; + items?: unknown[]; + results?: unknown[]; + products?: unknown[]; + data?: unknown[]; + meta?: { + total?: number; + degraded?: boolean; + [key: string]: unknown; + }; + [key: string]: unknown; +}; + +const SEARCH_FALLBACKS: SearchFallback[] = [ + { + slug: 'best-gaming-laptops-us', + label: 'Gaming laptops', + category: 'Laptops', + country: 'US', + currency: 'USD', + keywords: ['gaming laptop', 'asus rog', 'rog zephyrus', 'alienware', 'razer blade', 'lenovo legion', 'msi'], + products: [ + { name: 'ASUS ROG Zephyrus G16', price: 1699, merchant: 'Best Buy', brand: 'ASUS' }, + { name: 'Lenovo Legion Pro 5i', price: 1499, merchant: 'Lenovo', brand: 'Lenovo' }, + { name: 'MSI Raider GE78 HX', price: 2299, merchant: 'Amazon', brand: 'MSI' }, + { name: 'Razer Blade 16', price: 2699, merchant: 'Razer', brand: 'Razer' }, + ], + }, + { + slug: 'best-laptops-us', + label: 'Laptops', + category: 'Laptops', + country: 'US', + currency: 'USD', + keywords: ['laptop', 'notebook', 'macbook', 'ultrabook', 'chromebook'], + products: [ + { name: 'MacBook Air 13 M3', price: 999, merchant: 'Apple', brand: 'Apple' }, + { name: 'Dell XPS 13', price: 1099, merchant: 'Dell', brand: 'Dell' }, + { name: 'HP Spectre x360 14', price: 1249, merchant: 'Best Buy', brand: 'HP' }, + { name: 'Lenovo ThinkPad X1 Carbon', price: 1399, merchant: 'Lenovo', brand: 'Lenovo' }, + ], + }, + { + slug: 'best-headphones-us', + label: 'Headphones', + category: 'Headphones', + country: 'US', + currency: 'USD', + keywords: ['wireless headphones', 'headphones', 'noise cancelling', 'noise canceling', 'sony wh', 'bose quietcomfort'], + products: [ + { name: 'Sony WH-1000XM5 Wireless Headphones', price: 329, merchant: 'Amazon', brand: 'Sony' }, + { name: 'Bose QuietComfort Ultra Headphones', price: 379, merchant: 'Best Buy', brand: 'Bose' }, + { name: 'Apple AirPods Max', price: 479, merchant: 'Apple', brand: 'Apple' }, + { name: 'Sennheiser Momentum 4 Wireless', price: 299, merchant: 'Sennheiser', brand: 'Sennheiser' }, + ], + }, + { + slug: 'best-monitors-us', + label: 'Monitors', + category: 'Monitors', + country: 'US', + currency: 'USD', + keywords: ['monitor', '4k monitor', 'display', 'ultrawide'], + products: [ + { name: 'Dell UltraSharp 27 4K Monitor', price: 499, merchant: 'Dell', brand: 'Dell' }, + { name: 'LG UltraFine 32UN880-B', price: 599, merchant: 'Amazon', brand: 'LG' }, + { name: 'Samsung Odyssey G7', price: 549, merchant: 'Best Buy', brand: 'Samsung' }, + { name: 'ASUS ProArt Display PA279CRV', price: 469, merchant: 'B&H', brand: 'ASUS' }, + ], + }, + { + slug: 'best-robot-vacuums-2026', + label: 'Robot vacuums', + category: 'Robot Vacuums', + country: 'US', + currency: 'USD', + keywords: ['robot vacuum', 'roomba', 'roborock', 'eufy vacuum', 'shark robot'], + products: [ + { name: 'iRobot Roomba j9+', price: 599, merchant: 'Amazon', brand: 'iRobot' }, + { name: 'Roborock S8 MaxV Ultra', price: 1399, merchant: 'Roborock', brand: 'Roborock' }, + { name: 'Eufy X10 Pro Omni', price: 799, merchant: 'Amazon', brand: 'Eufy' }, + { name: 'Shark Matrix Plus 2-in-1', price: 499, merchant: 'Best Buy', brand: 'Shark' }, + ], + }, + { + slug: 'air-purifier-singapore', + label: 'Air purifiers', + category: 'Air Purifiers', + country: 'SG', + currency: 'SGD', + keywords: ['air purifier', 'coway', 'levoit', 'blueair', 'xiaomi purifier'], + products: [ + { name: 'Coway Airmega 150', price: 399, merchant: 'Coway Singapore', brand: 'Coway' }, + { name: 'Levoit Core 300S', price: 249, merchant: 'Amazon SG', brand: 'Levoit' }, + { name: 'Blueair Blue Max 3250i', price: 329, merchant: 'Courts', brand: 'Blueair' }, + { name: 'Xiaomi Smart Air Purifier 4', price: 229, merchant: 'Shopee', brand: 'Xiaomi' }, + ], + }, + { + slug: 'laptop-singapore', + label: 'Laptops', + category: 'Laptops', + country: 'SG', + currency: 'SGD', + keywords: ['laptop', 'notebook', 'macbook', 'zenbook', 'thinkpad'], + products: [ + { name: 'MacBook Air 13 M3', price: 1499, merchant: 'Apple Store', brand: 'Apple' }, + { name: 'ASUS Zenbook 14 OLED', price: 1699, merchant: 'ASUS Singapore', brand: 'ASUS' }, + { name: 'Lenovo Yoga 7i', price: 1549, merchant: 'Lenovo', brand: 'Lenovo' }, + { name: 'Dell XPS 14', price: 2199, merchant: 'Dell', brand: 'Dell' }, + ], + }, +]; + +function pickSearchFallback(query: string, countryCode: string) { + const normalizedQuery = query.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim(); + if (!normalizedQuery) return null; + + const normalizedCountry = countryCode.toUpperCase() === 'SG' ? 'SG' : 'US'; + const candidates = SEARCH_FALLBACKS.filter((fallback) => fallback.country === normalizedCountry); + return candidates.find((fallback) => fallback.keywords.some((keyword) => normalizedQuery.includes(keyword))) ?? null; +} + +function hasResults(data: UpstreamSearchResponse | null) { + const items = data?.items ?? data?.results ?? data?.products ?? data?.data ?? []; + return Array.isArray(items) && items.length > 0; +} + +function isDegradedZero(data: UpstreamSearchResponse | null) { + const total = data?.total ?? data?.meta?.total; + return Boolean(data?.degraded ?? data?.meta?.degraded) && !hasResults(data) && (total === undefined || Number(total) === 0); +} + +function buildFallbackResponse(data: UpstreamSearchResponse | null, fallback: SearchFallback) { + const fallbackUrl = `/${fallback.slug}`; + const items: SearchFallbackItem[] = fallback.products.map((product, index) => ({ + id: `fallback-${fallback.slug}-${index + 1}`, + title: product.name, + name: product.name, + price: { amount: product.price, currency: fallback.currency }, + price_amount: product.price, + price_currency: fallback.currency, + currency: fallback.currency, + merchant: product.merchant, + merchant_name: product.merchant, + source: 'editorial_fallback', + url: fallbackUrl, + click_url: fallbackUrl, + brand: product.brand, + category: fallback.category, + })); + + return { + ...data, + data: items, + items, + results: items, + products: items, + total: items.length, + fallback: { + type: 'editorial', + label: fallback.label, + url: fallbackUrl, + reason: 'upstream_degraded_zero_results', + }, + meta: { + ...(data?.meta ?? {}), + total: items.length, + degraded: true, + fallback: true, + fallback_url: fallbackUrl, + }, + hint: `Live search is degraded, so we are showing curated ${fallback.label.toLowerCase()} picks with a populated BuyWhere guide.`, + }; +} + export async function GET(request: NextRequest) { if (!API_KEY) { return NextResponse.json( @@ -51,6 +262,14 @@ export async function GET(request: NextRequest) { ); } + const query = upstreamParams.get('q') ?? ''; + const countryCode = upstreamParams.get('country_code') ?? 'US'; + const fallback = isDegradedZero(data) ? pickSearchFallback(query, countryCode) : null; + + if (fallback) { + return NextResponse.json(buildFallbackResponse(data, fallback)); + } + return NextResponse.json(data); } catch { return NextResponse.json( diff --git a/src/app/search/SearchResultsClient.tsx b/src/app/search/SearchResultsClient.tsx index 5a386a3f5..37fa3bdff 100644 --- a/src/app/search/SearchResultsClient.tsx +++ b/src/app/search/SearchResultsClient.tsx @@ -65,6 +65,7 @@ type SearchApiResponse = { items?: SearchApiItem[]; results?: SearchApiItem[]; products?: SearchApiItem[]; + data?: SearchApiItem[]; degraded?: boolean; hint?: string; timeout_ms?: number; @@ -525,7 +526,7 @@ export default function SearchResultsClient({ } const data: SearchApiResponse = await response.json(); - const rawItems = data.items || data.results || data.products || []; + const rawItems = data.items || data.results || data.products || data.data || []; if (data.degraded) { setDegraded(true); if (typeof data.hint === 'string' && data.hint.trim().length > 0) { @@ -588,14 +589,6 @@ export default function SearchResultsClient({ }, [fetchResults]); useEffect(() => { - - // Auto-redirect when search returns degraded results with zero products - useEffect(() => { - if (!loadingInitial && !error && debouncedQuery.length >= MIN_QUERY_LENGTH && products.length === 0 && degraded) { - // Redirect to homepage with a clear message about the degraded state - router.push('/'); - } - }, [loadingInitial, error, debouncedQuery, products.length, degraded, router]); const handlePointerDown = (event: MouseEvent) => { if (searchFieldRef.current && !searchFieldRef.current.contains(event.target as Node)) { setHistoryOpen(false); From 8553520ed0ffb44fcfd40de34920177b1b573d41 Mon Sep 17 00:00:00 2001 From: Rex Date: Sat, 11 Jul 2026 22:19:28 +0000 Subject: [PATCH 5/7] BUY-61559: add comparisonDisclaimer to Editor Summary table for laptop-singapore --- src/components/seo/SeoLandingPage.tsx | 3 +++ src/lib/seo-landing-pages.ts | 2 ++ 2 files changed, 5 insertions(+) diff --git a/src/components/seo/SeoLandingPage.tsx b/src/components/seo/SeoLandingPage.tsx index 8a3bc57ae..4b554821c 100644 --- a/src/components/seo/SeoLandingPage.tsx +++ b/src/components/seo/SeoLandingPage.tsx @@ -182,6 +182,9 @@ export async function SeoLandingPage({ config }: { config: SeoLandingPageConfig

Editor summary

{config.comparisonSectionTitle}

+ {config.comparisonDisclaimer && ( +

{config.comparisonDisclaimer}

+ )}
diff --git a/src/lib/seo-landing-pages.ts b/src/lib/seo-landing-pages.ts index cd5061878..6265222a3 100644 --- a/src/lib/seo-landing-pages.ts +++ b/src/lib/seo-landing-pages.ts @@ -91,6 +91,7 @@ export type SeoLandingPageConfig = { comparisonSectionTitle: string; comparisonColumns: string[]; comparisonRows: ComparisonRow[]; + comparisonDisclaimer?: string; highlightSectionTitle: string; highlights: Highlight[]; adviceSectionTitle: string; @@ -558,6 +559,7 @@ export const seoLandingPages: Record = { { Model: "Dell XPS 14", Price: "S$2,199", Weight: "1.68kg", Chip: "Intel Core Ultra 7", "Best For": "Best premium Windows" }, ], highlightSectionTitle: "What SG buyers usually optimise for", + comparisonDisclaimer: "Reference prices for editorial comparison — live prices shown above may differ.", highlights: [ { title: "Portability matters most", From 13ce3b834ecf1b6806ed1bd33f44b8b2954a9878 Mon Sep 17 00:00:00 2001 From: Rex Date: Sun, 12 Jul 2026 02:21:07 +0000 Subject: [PATCH 6/7] Fix laptop summary table to use live products --- src/components/seo/SeoLandingPage.tsx | 16 +++++++++++++++- src/lib/seo-landing-pages.ts | 14 +++++++------- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/src/components/seo/SeoLandingPage.tsx b/src/components/seo/SeoLandingPage.tsx index 4b554821c..9ff2c71fd 100644 --- a/src/components/seo/SeoLandingPage.tsx +++ b/src/components/seo/SeoLandingPage.tsx @@ -80,6 +80,16 @@ function ProductGridCard({ product }: { product: LandingProduct }) { ); } +function buildProductComparisonRows(products: LandingProduct[]) { + return products.slice(0, 4).map((product) => ({ + Model: product.name, + Price: formatPrice(product.price, product.currency), + Merchant: product.merchant, + Brand: product.brand || "See live listing", + "Best For": "Visible live offer", + })); +} + const DEFAULT_SHOPPER_CTA = { title: "Start comparing prices", body: "Search millions of products across Southeast Asia and the US — find the best price in seconds.", @@ -98,6 +108,10 @@ export async function SeoLandingPage({ config }: { config: SeoLandingPageConfig const developerCta = config.developerCta || DEFAULT_DEVELOPER_CTA; const products = await getSeoLandingProducts(config); const schema = buildSeoLandingSchema(config, products); + const comparisonRows = + config.comparisonSource === "products" && products.length > 0 + ? buildProductComparisonRows(products) + : config.comparisonRows; return (
@@ -200,7 +214,7 @@ export async function SeoLandingPage({ config }: { config: SeoLandingPageConfig - {config.comparisonRows.map((row, index) => ( + {comparisonRows.map((row, index) => ( {config.comparisonColumns.map((column) => ( diff --git a/src/lib/seo-landing-pages.ts b/src/lib/seo-landing-pages.ts index 6265222a3..33c2ad675 100644 --- a/src/lib/seo-landing-pages.ts +++ b/src/lib/seo-landing-pages.ts @@ -91,6 +91,7 @@ export type SeoLandingPageConfig = { comparisonSectionTitle: string; comparisonColumns: string[]; comparisonRows: ComparisonRow[]; + comparisonSource?: "config" | "products"; comparisonDisclaimer?: string; highlightSectionTitle: string; highlights: Highlight[]; @@ -550,16 +551,15 @@ export const seoLandingPages: Record = { refreshedLabel: "Updated May 1, 2026", productSectionTitle: "Live laptop offers across Singapore", comparisonSectionTitle: "Popular laptop picks at a glance", - comparisonColumns: ["Model", "Price", "Weight", "Chip", "Best For"], + comparisonColumns: ["Model", "Price", "Merchant", "Brand", "Best For"], + comparisonSource: "products", comparisonRows: [ - { Model: "MacBook Air 13 M3", Price: "S$1,499", Weight: "1.24kg", Chip: "Apple M3", "Best For": "Best ultraportable" }, - { Model: "ASUS Zenbook 14 OLED", Price: "S$1,699", Weight: "1.28kg", Chip: "Intel Core Ultra 7", "Best For": "Best Windows all-rounder" }, - { Model: "Lenovo Yoga 7i", Price: "S$1,549", Weight: "1.49kg", Chip: "Intel Core Ultra 7", "Best For": "Best 2-in-1" }, - { Model: "Acer Swift Go 14", Price: "S$1,199", Weight: "1.32kg", Chip: "Intel Core Ultra 5", "Best For": "Best value" }, - { Model: "Dell XPS 14", Price: "S$2,199", Weight: "1.68kg", Chip: "Intel Core Ultra 7", "Best For": "Best premium Windows" }, + { Model: "MacBook Air 13 M3", Price: "S$1,499", Merchant: "Apple Store", Brand: "Apple", "Best For": "Visible live offer" }, + { Model: "ASUS Zenbook 14 OLED", Price: "S$1,699", Merchant: "ASUS Singapore", Brand: "ASUS", "Best For": "Visible live offer" }, + { Model: "Lenovo Yoga 7i", Price: "S$1,549", Merchant: "Lenovo", Brand: "Lenovo", "Best For": "Visible live offer" }, ], highlightSectionTitle: "What SG buyers usually optimise for", - comparisonDisclaimer: "Reference prices for editorial comparison — live prices shown above may differ.", + comparisonDisclaimer: "Summary reflects the visible live product cards for this page snapshot; check each card for current seller details and availability.", highlights: [ { title: "Portability matters most", From 9d5bc0e8d5abf6b24d3e8725d241e8a04260d0a4 Mon Sep 17 00:00:00 2001 From: Rex Date: Sun, 12 Jul 2026 07:52:42 +0000 Subject: [PATCH 7/7] fix(BUY-61931): Prioritize affiliate_redirect_url for product card CTAs - Add affiliate_redirect_url field to SearchApiItem type - Update href construction to prioritize affiliate_redirect_url over click_url - Ensures product cards link to merchant pages when API returns valid data - Maintains backward compatibility with existing API responses - Resolves internal looping issue on SEO landing pages --- src/lib/seo-landing-pages.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/lib/seo-landing-pages.ts b/src/lib/seo-landing-pages.ts index 33c2ad675..02073c0aa 100644 --- a/src/lib/seo-landing-pages.ts +++ b/src/lib/seo-landing-pages.ts @@ -37,6 +37,7 @@ type SearchApiItem = { url?: string | null; buy_url?: string | null; affiliate_url?: string | null; + affiliate_redirect_url?: string | null; brand?: string | null; category?: string | null; }; @@ -161,7 +162,7 @@ function normalizeProduct(item: SearchApiItem, fallbackCurrency: string, minPric currency: priceCurrency || fallbackCurrency, merchant: formatMerchantName(item.merchant_name || item.merchant || item.source), imageUrl: isUsableProductImage(imageUrl) ? imageUrl : null, - href: item.click_url || item.affiliate_url || item.buy_url || item.url || "#", + href: item.affiliate_redirect_url || item.click_url || item.affiliate_url || item.buy_url || item.url || "#", brand: item.brand || null, category: item.category || null, };