diff --git a/api/src/routes/products.ts b/api/src/routes/products.ts index 6c47e20f7..f414645d5 100644 --- a/api/src/routes/products.ts +++ b/api/src/routes/products.ts @@ -782,19 +782,26 @@ router.get( `source = $${seoFallbackSourceParamIdx}`, ...(seoFallbackTermConditions.length ? [`(${seoFallbackTermConditions.join(' OR ')})`] : []), ].join(' AND ')}`; + const accessoryDemotionSql = ` + CASE + WHEN products.title ~* '\\m(replacement|replace|battery|batteries|filter|filters|skin|skins|decal|decals|sticker|stickers|sleeve|sleeves|case|cases|cover|covers|protector|protectors|backpack|backpacks|software|license|accessory|accessories|part|parts)\\M' + OR products.category ~* '\\m(replacement|replace|battery|batteries|filter|filters|skin|skins|decal|decals|sticker|stickers|sleeve|sleeves|case|cases|cover|covers|protector|protectors|backpack|backpacks|software|license|accessory|accessories|part|parts)\\M' + OR array_to_string(products.category_path, ' ') ~* '\\m(replacement|replace|battery|batteries|filter|filters|skin|skins|decal|decals|sticker|stickers|sleeve|sleeves|case|cases|cover|covers|protector|protectors|backpack|backpacks|software|license|accessory|accessories|part|parts)\\M' + THEN 1 ELSE 0 + END`; const seoFallbackQuery = ` WITH fallback_ids AS ( - SELECT id, updated_at + SELECT id, updated_at, ${accessoryDemotionSql} AS accessory_rank FROM products ${seoFallbackWhereClause} - ORDER BY updated_at DESC + ORDER BY accessory_rank ASC, updated_at DESC, id DESC LIMIT $${seoFallbackLimitParamIdx} OFFSET $${seoFallbackOffsetParamIdx} ) SELECT ${joinedColumns} FROM fallback_ids JOIN products ON products.id = fallback_ids.id LEFT JOIN affiliate_links al ON al.product_id = products.id::text AND al.merchant_id = products.merchant_id - ORDER BY fallback_ids.updated_at DESC + ORDER BY fallback_ids.accessory_rank ASC, fallback_ids.updated_at DESC, products.id DESC `; const seoFallbackParamsWithPage = [ ...seoFallbackParams, @@ -816,15 +823,8 @@ router.get( `(products.title ILIKE '%laptop%' OR products.title ILIKE '%notebook%' OR products.title ILIKE '%macbook%' OR products.category ILIKE '%laptop%' OR array_to_string(products.category_path, ' ') ILIKE '%laptop%')`, ...(laptopTermConditions.length ? laptopTermConditions : []), ].join(' AND ')}`; - const laptopAccessoryDemotionSql = ` - CASE - WHEN products.title ~* '\\m(skin|skins|decal|decals|sticker|stickers|sleeve|sleeves|case|cases|cover|covers|protector|protectors)\\M' - OR products.category ~* '\\m(accessor|accessory|accessories|skin|skins|decal|decals|sleeve|sleeves|case|cases|cover|covers)\\M' - OR array_to_string(products.category_path, ' ') ~* '\\m(accessor|accessory|accessories|skin|skins|decal|decals|sleeve|sleeves|case|cases|cover|covers)\\M' - THEN 1 ELSE 0 - END`; const laptopFallbackQuery = ` - SELECT ${joinedColumns}, ${laptopAccessoryDemotionSql} AS _accessory_rank + SELECT ${joinedColumns}, ${accessoryDemotionSql} AS _accessory_rank FROM products LEFT JOIN affiliate_links al ON al.product_id = products.id::text AND al.merchant_id = products.merchant_id ${laptopFallbackWhereClause} @@ -845,11 +845,11 @@ router.get( ...(generalFallbackTermConditions.length ? [`(${generalFallbackTermConditions.join(' OR ')})`] : []), ].join(' AND ')}`; const generalFallbackQuery = ` - SELECT ${joinedColumns} + SELECT ${joinedColumns}, ${accessoryDemotionSql} AS _accessory_rank FROM products LEFT JOIN affiliate_links al ON al.product_id = products.id::text AND al.merchant_id = products.merchant_id ${generalFallbackWhereClause} - ORDER BY products.updated_at DESC + ORDER BY _accessory_rank ASC, products.updated_at DESC, products.id DESC LIMIT $${generalFallbackLimitParamIdx} `; const generalFallbackParams = [ @@ -915,6 +915,50 @@ router.get( AND lower(rhp.title) NOT LIKE '%briefcase%' AND lower(rhp.title) NOT LIKE '%charger%' AND lower(rhp.title) NOT LIKE '%table%' + -- BUY-65156: 'CARBONADO 30 L Backpack Gaming Backpack For Laptop...' was + -- matching the laptop-boost (2.0x) because its title literally contained + -- 'laptop' (and 'gaming laptop') but the product is a backpack. Add + -- 'backpack'/'accessories' to the negator so a backpack titled "Gaming + -- Backpack For Laptop, Gaming Laptop and Gaming Accessories" does not + -- outrank actual gaming-laptop SKUs. + AND lower(rhp.title) NOT LIKE '%backpack%' + AND lower(rhp.title) NOT LIKE '%backpacks%' + AND lower(rhp.title) NOT LIKE '%accessories%' + AND lower(rhp.title) NOT LIKE '%accessory%' + AND lower(rhp.title) NOT LIKE '%replacement%' + AND lower(rhp.title) NOT LIKE '%replace%' + AND lower(rhp.title) NOT LIKE '%battery%' + AND lower(rhp.title) NOT LIKE '%batteries%' + AND lower(rhp.title) NOT LIKE '%filter%' + AND lower(rhp.title) NOT LIKE '%filters%' + AND lower(rhp.title) NOT LIKE '%skin%' + AND lower(rhp.title) NOT LIKE '%skins%' + AND lower(rhp.title) NOT LIKE '%decal%' + AND lower(rhp.title) NOT LIKE '%decals%' + AND lower(rhp.title) NOT LIKE '%sticker%' + AND lower(rhp.title) NOT LIKE '%stickers%' + AND lower(rhp.title) NOT LIKE '%protector%' + AND lower(rhp.title) NOT LIKE '%protectors%' + AND lower(rhp.title) NOT LIKE '%cover%' + AND lower(rhp.title) NOT LIKE '%covers%' + AND lower(rhp.title) NOT LIKE '%software%' + AND lower(rhp.title) NOT LIKE '%license%' + AND lower(rhp.title) NOT LIKE '% part %' + AND lower(rhp.title) NOT LIKE '% parts%' + AND lower(rhp.title) NOT LIKE '%mouse%' + AND lower(rhp.title) NOT LIKE '%keyboard%' + AND lower(rhp.title) NOT LIKE '%headset%' + AND lower(rhp.title) NOT LIKE '%headphones%' + AND lower(rhp.title) NOT LIKE '%earbuds%' + AND lower(rhp.title) NOT LIKE '%cable%' + AND lower(rhp.title) NOT LIKE '%usb%' + AND lower(rhp.title) NOT LIKE '%router%' + AND lower(rhp.title) NOT LIKE '%monitor%' + AND lower(rhp.title) NOT LIKE '%stylus%' + AND lower(rhp.title) NOT LIKE '%pen %' + AND lower(rhp.title) NOT LIKE '%light %' + AND lower(rhp.title) NOT LIKE '%tote%' + AND lower(rhp.title) NOT LIKE '%strap%' THEN 2.0 ELSE 1.0 END AS rank diff --git a/api/tests/ts-rank-guard.test.mjs b/api/tests/ts-rank-guard.test.mjs index fbe607602..0a508225d 100644 --- a/api/tests/ts-rank-guard.test.mjs +++ b/api/tests/ts-rank-guard.test.mjs @@ -206,4 +206,48 @@ describe('BUY-32028 + BUY-32228: ts_rank ORDER BY regression guard', () => { 'Expected default keyword path to use the RAM-fitting search_products tier' ); }); + + it('BUY-63505 demotes accessories before SEO fallback pagination', () => { + const src = readProductsSource(); + const seoStart = src.indexOf('const seoFallbackQuery = `'); + const seoEnd = src.indexOf('const seoFallbackParamsWithPage', seoStart); + assert.ok(seoStart >= 0 && seoEnd > seoStart, 'Expected SEO fallback query block'); + const seoBlock = src.slice(seoStart, seoEnd); + + assert.ok(/accessoryDemotionSql/.test(src), 'Expected shared accessory demotion expression'); + for (const term of ['replacement', 'battery', 'filter', 'skin', 'backpack', 'software', 'accessory', 'part']) { + assert.ok(src.includes(term), `Expected accessory demotion to include ${term}`); + } + assert.ok( + /SELECT\s+id,\s+updated_at,\s+\$\{accessoryDemotionSql\}\s+AS\s+accessory_rank/i.test(seoBlock), + 'Expected SEO fallback CTE to calculate accessory_rank before LIMIT/OFFSET' + ); + assert.ok( + /ORDER\s+BY\s+accessory_rank\s+ASC,\s+updated_at\s+DESC,\s+id\s+DESC/i.test(seoBlock), + 'Expected SEO fallback candidate selection to demote accessories before pagination' + ); + assert.ok( + /ORDER\s+BY\s+fallback_ids\.accessory_rank\s+ASC,\s+fallback_ids\.updated_at\s+DESC,\s+products\.id\s+DESC/i.test(seoBlock), + 'Expected SEO fallback final ordering to preserve accessory demotion' + ); + }); + + it('BUY-65156 FTS ranking branch excludes backpack/accessory titles from the 2.0x laptop boost', () => { + // Regression guard: 'CARBONADO 30 L Backpack Gaming Backpack For Laptop, Gaming Laptop + // and Gaming Accessories' was matching the laptop boost because its title literally + // contained 'laptop' (and 'gaming laptop') but the product is a backpack. The fix + // negates 'backpack'/'accessories' (and other laptop-adjacent accessories) in the + // top_ids CTE so accessory SKUs cannot outrank actual gaming-laptop products. + const src = readProductsSource(); + const block = extractUseFtsRankingBlock(src); + const topIds = block.match(/top_ids\s+AS\s*\(([\s\S]*?)\)\s*SELECT/i); + assert.ok(topIds, 'Expected a `top_ids AS (...)` CTE in the useFtsRanking branch'); + const cte = topIds[1]; + for (const term of ['backpack', 'accessories', 'accessory', 'replacement', 'battery', 'mouse', 'keyboard', 'headset', 'monitor']) { + assert.ok( + new RegExp(`NOT\\s+LIKE\\s+'%${term}%'`, 'i').test(cte), + `BUY-65156: expected the top_ids CTE to negate '${term}' from the laptop-boost else-branch; an accessory titled 'Gaming ${term}...' would still rank above real laptops.` + ); + } + }); }); diff --git a/src/app/search/SearchResultsClient.tsx b/src/app/search/SearchResultsClient.tsx index 4647de58c..7a7dfd70c 100644 --- a/src/app/search/SearchResultsClient.tsx +++ b/src/app/search/SearchResultsClient.tsx @@ -305,9 +305,9 @@ function SearchCard({ product }: { product: SearchCardProduct }) { href={product.href} target="_blank" rel="noopener noreferrer" - className="group relative flex h-full flex-col rounded-[24px] border border-slate-200 bg-white shadow-sm ring-1 ring-slate-100 transition-all duration-200 hover:-translate-y-1 hover:border-amber-200 hover:shadow-xl" + className="group relative flex h-full min-h-0 flex-col rounded-[22px] border border-slate-200 bg-white shadow-sm ring-1 ring-slate-100 transition-all duration-200 hover:-translate-y-1 hover:border-amber-200 hover:shadow-xl" > -
+
Product image
@@ -332,7 +332,7 @@ function SearchCard({ product }: { product: SearchCardProduct }) {
-
+
@@ -343,7 +343,7 @@ function SearchCard({ product }: { product: SearchCardProduct }) {

{product.name}

@@ -353,12 +353,12 @@ function SearchCard({ product }: { product: SearchCardProduct }) {
-
+

Current price

-

{formatPrice(product.price, product.currency)}

+

{formatPrice(product.price, product.currency)}

- + View Deal @@ -644,6 +644,7 @@ export default function SearchResultsClient({ const showEmptyState = !loadingInitial && !error && debouncedQuery.length >= MIN_QUERY_LENGTH && products.length === 0 && !degraded; const showHistoryDropdown = historyOpen && query.trim().length === 0 && searchHistory.length > 0; const reversedSearchHistory = useMemo(() => [...searchHistory].reverse(), [searchHistory]); + const hasActiveSearch = debouncedQuery.length >= MIN_QUERY_LENGTH; return (
@@ -651,18 +652,18 @@ export default function SearchResultsClient({
-
+

Product search

-

+

{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.

-
+
{isNavigating && showSearchPrompt ? : null}
diff --git a/src/components/HomeProductSearch.tsx b/src/components/HomeProductSearch.tsx index 13a65e302..53ff12331 100644 --- a/src/components/HomeProductSearch.tsx +++ b/src/components/HomeProductSearch.tsx @@ -59,7 +59,7 @@ export function HomeProductSearch() { className="grid gap-3" noValidate > -
+
{countryOptions.map((option) => ( @@ -101,7 +101,7 @@ export function HomeProductSearch() { diff --git a/src/components/Nav.tsx b/src/components/Nav.tsx index 0130b3ad5..45511c771 100644 --- a/src/components/Nav.tsx +++ b/src/components/Nav.tsx @@ -78,7 +78,8 @@ export default function Nav() { BuyWhere diff --git a/work-products/BUY-62628-reopen-fix-20260716T1825Z.md b/work-products/BUY-62628-reopen-fix-20260716T1825Z.md new file mode 100644 index 000000000..f3957daee --- /dev/null +++ b/work-products/BUY-62628-reopen-fix-20260716T1825Z.md @@ -0,0 +1,12 @@ +# BUY-62628 reopen fix — tablet nav and search layout + +## QA reopen addressed + +- Removed the header false-positive duplicate menu trigger on tablet by changing the BuyWhere logo glyph from a three-line hamburger-like mark to a search-lens mark; the actual mobile/tablet menu button remains the only hamburger trigger. +- Made the homepage search layout intentionally stacked below `lg`, so tablet widths no longer get a cramped pseudo-inline control. +- Kept desktop inline with explicit column widths: product input `minmax(18rem,1fr)`, country selector `11rem`, CTA `12rem`. + +## Verification + +- `npx eslint src/components/Nav.tsx src/components/HomeProductSearch.tsx` passed. +- `npm run lint -- --dir src/components --dir src/app` still fails on unrelated pre-existing `Metadata`/unused-variable errors outside the touched files.