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
70 changes: 57 additions & 13 deletions api/src/routes/products.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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}
Expand All @@ -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 = [
Expand Down Expand Up @@ -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
Expand Down
44 changes: 44 additions & 0 deletions api/tests/ts-rank-guard.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.`
);
}
});
});
23 changes: 12 additions & 11 deletions src/app/search/SearchResultsClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
>
<div className="relative aspect-[4/3] overflow-hidden border-b border-slate-100 bg-slate-100">
<div className="relative aspect-[16/10] border-b border-slate-100 bg-slate-100">
<div className="absolute inset-0 flex items-center justify-center overflow-hidden bg-[radial-gradient(circle_at_top,_rgba(251,191,36,0.18),_rgba(248,250,252,0.96)_55%,_rgba(226,232,240,0.96))] text-sm font-semibold text-slate-600">
Product image
</div>
Expand All @@ -332,7 +332,7 @@ function SearchCard({ product }: { product: SearchCardProduct }) {
</div>
</div>

<div className="flex flex-1 flex-col gap-3 bg-white p-4">
<div className="flex flex-1 flex-col gap-2.5 bg-white p-3.5">
<div className="flex min-h-7 items-start justify-between gap-2">
<MerchantBadge merchant={product.merchant} className="shrink-0" />
<span className="inline-flex items-center gap-1 rounded-full bg-slate-900 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-white">
Expand All @@ -343,7 +343,7 @@ function SearchCard({ product }: { product: SearchCardProduct }) {

<div className="space-y-1.5">
<h2
className="line-clamp-3 text-base font-semibold leading-snug text-slate-950 transition-colors group-hover:text-amber-700"
className="line-clamp-2 text-[15px] font-semibold leading-snug text-slate-950 transition-colors group-hover:text-amber-700"
>
{product.name}
</h2>
Expand All @@ -353,12 +353,12 @@ function SearchCard({ product }: { product: SearchCardProduct }) {
</div>
</div>

<div className="mt-auto space-y-3 border-t border-slate-100 pt-3">
<div className="mt-auto space-y-2.5 border-t border-slate-100 pt-2.5">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-600">Current price</p>
<p className="mt-0.5 text-2xl font-bold tracking-tight text-slate-950">{formatPrice(product.price, product.currency)}</p>
<p className="mt-0.5 text-xl font-bold tracking-tight text-slate-950">{formatPrice(product.price, product.currency)}</p>
</div>
<span className="inline-flex w-full items-center justify-center gap-2 rounded-full bg-slate-950 px-4 py-2.5 text-sm font-semibold text-white transition-colors group-hover:bg-amber-600">
<span className="inline-flex w-full items-center justify-center gap-2 rounded-full bg-slate-950 px-4 py-2 text-sm font-semibold text-white transition-colors group-hover:bg-amber-600">
View Deal
<ExternalLink className="h-4 w-4" />
</span>
Expand Down Expand Up @@ -644,25 +644,26 @@ 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 (
<div className="flex min-h-screen flex-col bg-[linear-gradient(180deg,_#fff7ed_0%,_#ffffff_28%,_#f8fafc_100%)]">
<Header />

<main id="main-content" className="flex-1">
<section className="border-b border-amber-100 bg-[radial-gradient(circle_at_top,_rgba(245,158,11,0.22),_rgba(255,247,237,0.85)_38%,_rgba(255,255,255,1)_80%)]">
<div className="mx-auto max-w-7xl px-4 py-10 sm:px-6 lg:px-8 lg:py-14">
<div className={`mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 ${hasActiveSearch ? 'py-5 lg:py-6' : 'py-10 lg:py-14'}`}>
<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">
<h1 className={`${hasActiveSearch ? 'mt-2 text-3xl sm:text-4xl' : 'mt-3 text-4xl sm:text-5xl'} font-semibold tracking-tight text-slate-950`}>
{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">
<p className={`${hasActiveSearch ? 'sr-only' : '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.
</p>
</div>

<div className="mt-8 rounded-[32px] border border-white/80 bg-white/80 p-4 shadow-[0_24px_80px_-48px_rgba(15,23,42,0.55)] backdrop-blur md:p-6">
<div className={`${hasActiveSearch ? 'mt-5 rounded-[28px] p-3 md:p-4' : 'mt-8 rounded-[32px] p-4 md:p-6'} border border-white/80 bg-white/80 shadow-[0_24px_80px_-48px_rgba(15,23,42,0.55)] backdrop-blur`}>
{isNavigating && showSearchPrompt ? <SearchInputSkeleton /> : null}

<div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_220px]">
Expand Down
6 changes: 3 additions & 3 deletions src/components/HomeProductSearch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export function HomeProductSearch() {
className="grid gap-3"
noValidate
>
<div className="grid gap-3 lg:grid-cols-[minmax(0,1fr)_180px_auto]">
<div className="grid gap-3 lg:grid-cols-[minmax(18rem,1fr)_11rem_12rem]">
<div className="relative">
<Search
className="pointer-events-none absolute left-5 top-1/2 h-6 w-6 -translate-y-1/2 text-indigo-100"
Expand Down Expand Up @@ -89,7 +89,7 @@ export function HomeProductSearch() {
setCountry(event.target.value as CountryValue);
setCountryTouched(true);
}}
className="h-[66px] rounded-xl border-2 border-white/20 bg-white/10 px-4 text-base font-medium text-white transition-all focus:border-white focus:bg-white/20 focus:outline-none focus:ring-4 focus:ring-white/20"
className="h-[66px] w-full rounded-xl border-2 border-white/20 bg-white/10 px-4 text-base font-medium text-white transition-all focus:border-white focus:bg-white/20 focus:outline-none focus:ring-4 focus:ring-white/20"
aria-label="Search country"
>
{countryOptions.map((option) => (
Expand All @@ -101,7 +101,7 @@ export function HomeProductSearch() {

<button
type="submit"
className="inline-flex h-[66px] items-center justify-center rounded-xl bg-white px-6 text-base font-semibold text-indigo-700 transition-colors hover:bg-indigo-50 focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-indigo-700 lg:col-start-3 lg:row-start-1"
className="inline-flex h-[66px] w-full items-center justify-center rounded-xl bg-white px-6 text-base font-semibold text-indigo-700 transition-colors hover:bg-indigo-50 focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-indigo-700 lg:col-start-3 lg:row-start-1"
>
Search catalog
</button>
Expand Down
3 changes: 2 additions & 1 deletion src/components/Nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,8 @@ export default function Nav() {
<Link href="/" className="flex items-center gap-2 font-bold text-lg text-indigo-600" aria-label="BuyWhere Home">
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<rect width="28" height="28" rx="6" fill="#4f46e5" />
<path d="M7 10h14M7 14h10M7 18h12" stroke="white" strokeWidth="2" strokeLinecap="round" />
<circle cx="12.5" cy="12.5" r="4.5" stroke="white" strokeWidth="2" />
<path d="M16 16l4.5 4.5" stroke="white" strokeWidth="2" strokeLinecap="round" />
</svg>
<span>BuyWhere</span>
</Link>
Expand Down
12 changes: 12 additions & 0 deletions work-products/BUY-62628-reopen-fix-20260716T1825Z.md
Original file line number Diff line number Diff line change
@@ -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.
Loading