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' }, 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 318e1bcf9..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) { @@ -620,7 +621,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. diff --git a/src/components/seo/SeoLandingPage.tsx b/src/components/seo/SeoLandingPage.tsx index 8a3bc57ae..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 (

@@ -182,6 +196,9 @@ export async function SeoLandingPage({ config }: { config: SeoLandingPageConfig

Editor summary

{config.comparisonSectionTitle}

+ {config.comparisonDisclaimer && ( +

{config.comparisonDisclaimer}

+ )}
@@ -197,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 cd5061878..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; }; @@ -91,6 +92,8 @@ export type SeoLandingPageConfig = { comparisonSectionTitle: string; comparisonColumns: string[]; comparisonRows: ComparisonRow[]; + comparisonSource?: "config" | "products"; + comparisonDisclaimer?: string; highlightSectionTitle: string; highlights: Highlight[]; adviceSectionTitle: string; @@ -159,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, }; @@ -549,15 +552,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: "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",