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
13 changes: 13 additions & 0 deletions api/src/routes/products.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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-
Expand Down
51 changes: 51 additions & 0 deletions api/tests/search.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
219 changes: 219 additions & 0 deletions src/app/api/products/search/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
5 changes: 3 additions & 2 deletions src/app/search/SearchResultsClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ type SearchApiResponse = {
items?: SearchApiItem[];
results?: SearchApiItem[];
products?: SearchApiItem[];
data?: SearchApiItem[];
degraded?: boolean;
hint?: string;
timeout_ms?: number;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -620,7 +621,7 @@ export default function SearchResultsClient({
<div className="max-w-3xl">
<p className="text-sm font-semibold uppercase tracking-[0.22em] text-amber-700">Product search</p>
<h1 className="mt-3 text-4xl font-semibold tracking-tight text-slate-950 sm:text-5xl">
Find live catalog results without leaving BuyWhere
{query.trim() ? `Search results for "${query.trim()}"` : "Find live catalog results without leaving BuyWhere"}
</h1>
<p className="mt-4 max-w-2xl text-base leading-7 text-slate-600 sm:text-lg">
Search BuyWhere&apos;s product index by query and country, then jump directly to retailer listings.
Expand Down
19 changes: 18 additions & 1 deletion src/components/seo/SeoLandingPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand All @@ -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 (
<div className="flex min-h-screen flex-col bg-white text-slate-900">
Expand Down Expand Up @@ -182,6 +196,9 @@ export async function SeoLandingPage({ config }: { config: SeoLandingPageConfig
<div className="mb-8 max-w-3xl">
<p className="text-sm font-semibold uppercase tracking-[0.2em] text-slate-500">Editor summary</p>
<h2 className="mt-2 text-3xl font-semibold tracking-tight text-slate-900">{config.comparisonSectionTitle}</h2>
{config.comparisonDisclaimer && (
<p className="mt-3 text-sm leading-6 text-slate-500">{config.comparisonDisclaimer}</p>
)}
</div>

<div className="overflow-hidden rounded-[28px] border border-slate-200 shadow-sm">
Expand All @@ -197,7 +214,7 @@ export async function SeoLandingPage({ config }: { config: SeoLandingPageConfig
</tr>
</thead>
<tbody>
{config.comparisonRows.map((row, index) => (
{comparisonRows.map((row, index) => (
<tr key={`${row[config.comparisonColumns[0]]}-${index}`} className="border-t border-slate-100">
{config.comparisonColumns.map((column) => (
<td key={column} className="px-4 py-4 align-top">
Expand Down
Loading