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