Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
63 commits
Select commit Hold shift + click to select a range
5116c7d
fix(mcp): BUY-64151 fail open on timeout-prone MCP tools
Jul 28, 2026
aa14ec5
fix(BUY-64574): WCAG AA placeholder contrast on homepage hero search
Jul 28, 2026
5625261
fix(BUY-64574): add explicit CSS for search-input placeholder — fixes…
Jul 28, 2026
c37d3c1
fix(BUY-64259): render price badge above the fold on product cards
Jul 28, 2026
25a20af
fix(BUY-64729): replace broken CDN images with branded SVG placeholde…
BuyWhere Jul 28, 2026
40dd6b5
fix(BUY-64260): richer branded placeholder + courts/asus hotlink-bloc…
Jul 28, 2026
72082e2
fix(BUY-65098): normalize blog JSON-LD frontmatter to safe strings
BuyWhere Jul 29, 2026
ebefad4
fix(BUY-65097): empty sitemap-products.xml — merchant URLs are noinde…
Jul 29, 2026
fd86838
fix(BUY-65121): restore US/SG product URLs in sitemap-products.xml
Jul 29, 2026
a573fa4
fix(BUY-64259): contain search card product images (#253)
BuyWhere Jul 29, 2026
3d0bd52
fix(BUY-65121): use BUYWHERE_API_KEY for sitemap product API calls
Jul 29, 2026
22d4319
fix(seo): enrich CollectionPage schema with Article + Product/Aggrega…
Jul 20, 2026
5b5e2bf
fix(seo): add aggregateRating, Article dates, +3 FAQs (BUY-59316 gap-…
Jul 25, 2026
d9b0607
fix(aeo): publish llms-full.txt companion + robots LLMs-Full-Txt dire…
Jul 29, 2026
d58edea
fix(BUY-65154): rotate broken affiliate destination (#255)
BuyWhere Jul 29, 2026
05b02c6
fix(BUY-65154): keep broken-deal fallback on BuyWhere
BuyWhere Jul 29, 2026
ebbb2ba
fix(mcp): BUY-65059 route catalog reads through catalogDb — restore c…
Jul 28, 2026
d189f29
fix(BUY-64260): use charset=utf-8 in branded SVG placeholder data URL
claude Jul 29, 2026
cffa66b
fix(BUY-64579): render branded SVG placeholder in ProductGridImage
claude Jul 29, 2026
256c869
fix(BUY-64579): enable compactCatalogCards on /laptop-singapore
claude Jul 29, 2026
d925bb0
fix(BUY-64729): add /c/[slug] route to resolve AI-crawler URLs
claude Jul 29, 2026
1a94c78
fix(BUY-64729): return real HTTP 404 for unknown /c/{slug} via dynami…
claude Jul 29, 2026
c4a808b
fix(BUY-64729): register alias keys (not just targets) in generateSta…
claude Jul 29, 2026
7ccf60d
fix(BUY-64578): compact mobile search-fold (#260)
BuyWhere Jul 29, 2026
cacfa7a
fix(ci): refuse production site deploys from non-main refs
BuyWhere Jul 29, 2026
0d24777
fix(seo): register sitemap-merchants.xml + sitemap-products-sg.xml in…
BuyWhere Jul 29, 2026
cd29b64
fix(seo): no-store sitemap index + list all 6 sitemaps in robots.ts (…
BuyWhere Jul 29, 2026
bfc9a1c
fix(BUY-65159): single hamburger trigger at tablet/mobile + stack sea…
BuyWhere Jul 29, 2026
1ba088f
BUY-65100: remove /us/signup from sitemap-pages.xml
BuyWhere Jul 29, 2026
97232bc
fix(seo): force-dynamic sitemap.xml + Vary: * to bust Hikari edge cac…
BuyWhere Jul 29, 2026
0abe084
fix(BUY-64729): drop unreachable product cards instead of branded SVG
claude Jul 29, 2026
141017f
fix(BUY-63742): drop stale/future product updatedAt from hero badge
claude Jul 29, 2026
960ee09
BUY-65161: Expand compare sitemap to every populated category-vs-cate…
BuyWhere Jul 29, 2026
d00c989
BUY-65274 stronger snippet/meta variant — compare-headphones-singapor…
BuyWhere Jul 29, 2026
3580e79
fix(mcp): BUY-65298 — move country filter inside get_deals subquery; …
Jul 29, 2026
3d704ce
build: compile BUY-65298 mcp route fixes into dist
Jul 29, 2026
85ccf3f
BUY-65150: expand category sitemap across countries (#275)
BuyWhere Jul 29, 2026
94890ba
BUY-65150: render category-country pages dynamically (#276)
BuyWhere Jul 29, 2026
adea740
fix(BUY-65158): remove radial-gradient noise background from snapshot…
Jul 29, 2026
a8edeab
BUY-65150: return real 404s for invalid category-country routes (#277)
BuyWhere Jul 29, 2026
f55678f
fix(BUY-64728): add structural min-height floor to search card outer …
claude Jul 29, 2026
68de7b6
fix(BUY-64728): card footer floor + merchant wrap
claude Jul 29, 2026
e8c4a0b
fix(BUY-64881): redirect /products/us/<slug> to /search when API unre…
claude Jul 29, 2026
3389901
fix(seo): BUY-65151 refresh llms-full.txt live stats + category list …
BuyWhere Jul 29, 2026
ad56ac8
fix(BUY-64988): reconcile ingestion_runs.rows_inserted vs products.cr…
Jul 29, 2026
685e427
fix(BUY-64988): adapt source_mix_freshness_check.js to existing canon…
Jul 29, 2026
2efeac7
fix(BUY-64988): FastAPI writer — use RETURNING (xmax = 0) for rows_in…
Jul 29, 2026
83bd64d
fix(BUY-65375): headphones v3 title — comparison hook for CTR lift
BuyWhere Jul 30, 2026
fb2d70b
fix(seo): BUY-58805 wave-5 price-anchor title rewrites for 6 pages
BuyWhere Jul 30, 2026
e8bb4ba
fix(search): BUY-65420 add title-contains LIKE fallback before OR-FTS…
BuyWhere Jul 30, 2026
d8ab808
fix(BUY-65437): rewrite /developers/robots.txt, /developers/sitemap.x…
Jul 30, 2026
4ec533e
fix(seo): rewrite /developers/robots.txt + /developers/sitemap.xml (B…
BuyWhere Jul 30, 2026
b23f16e
fix(BUY-65450): compare page shows actual prices + availability
Jul 30, 2026
6f82e6c
fix(mcp-auth): backport apiKeyLookupHashes() for bw_beta_ prefix supp…
BuyWhere Jul 30, 2026
ef1bb93
fix(BUY-65450): retry /compare SSR fetch on 429 rate-limit
Jul 30, 2026
326d6b5
chore: force Railway redeploy (BUY-65463)
BuyWhere Jul 30, 2026
2cda77f
chore: add regression tests and affordance for compare link fields
Jul 29, 2026
ba27d0d
test(mcp): add BUY-65475 regression test for limit/offset type coercion
Jul 30, 2026
f0bf00a
fix(BUY-65454): hide duplicate hero H1 on /search when results active
Jul 30, 2026
fbaf3c3
fix(BUY-65455): unify search-card price block — drop floating pill, i…
Jul 30, 2026
715f58a
fix(BUY-65455): document the label+price inline layout
Jul 30, 2026
5a2b7fd
fix(BUY-65455): restore hasRetailerHref + drop dead generateMockUSPro…
Jul 30, 2026
f1ff220
fix(BUY-65456): replace solid status pills with inline checkmark list
Jul 30, 2026
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
14 changes: 14 additions & 0 deletions .claude/skills/verify/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
name: verify
summary: Verify the Next.js web surface from a deploy-like scratch copy when the repo's root Python app/ directory masks src/app locally.
---

# Verify the BuyWhere web app

1. Establish the diff with `git diff HEAD --stat`.
2. Build a run-owned scratch copy with `git archive HEAD`, then copy changed web files into it.
3. Remove the scratch copy's root `app/` Python package. Next.js otherwise selects it instead of `src/app` and every web route returns 404; production excludes it through `.dockerignore`.
4. Symlink the checkout's `node_modules` into the scratch copy.
5. From the scratch root, run `NODE_ENV=development BUYWHERE_INTERNAL_ORIGIN=https://buywhere.ai ./node_modules/.bin/next dev --hostname 127.0.0.1 --port <unused-port>`.
6. Drive the affected route with Playwright at its acceptance viewports. Capture a full-page screenshot plus DOM measurements for visible content, clipping (`scrollHeight <= clientHeight`), horizontal overflow, failed network requests, and interactive controls.
7. Stop the server and keep temporary captures under `PAPERCLIP_RUN_SCRATCH_DIR`.
8 changes: 8 additions & 0 deletions .github/workflows/deploy-site-production.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,14 @@ jobs:
name: Deploy site to Railway
runs-on: ubuntu-latest
steps:
- name: Refuse non-main refs (BUY-64967 postmortem)
if: github.ref != 'refs/heads/main'
run: |
echo "::error::PRODUCTION DEPLOYS MUST COME FROM main. You dispatched from '${GITHUB_REF}'."
echo "::error::Deploying a feature branch to production ships a stale build and silently reverts newer main content (this 410'd the whole July blog catch-up batch on 2026-07-29)."
echo "::error::Merge your branch to main first; the push to main deploys automatically."
exit 1

- uses: actions/checkout@v4

- name: Set up Node.js
Expand Down
155 changes: 155 additions & 0 deletions BUY-65298-EVIDENCE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
# BUY-65298: MCP Semantic Regressions — Root Cause Analysis & Fixes

**Date:** 2026-07-29
**Parent:** BUY-65095, BUY-64151
**Status:** Fixes applied; awaiting flux-probe verification

---

## Probe Results (2026-07-29T13:45Z)

| Tool | Args | Expected | Observed | Root Cause |
|------|------|----------|----------|------------|
| `get_deals` | sg | Non-empty deals | `data:[], unavailable:true` | Subquery not filtered by country |
| `get_deals` | us | Non-empty deals | `data:[], unavailable:true` | Subquery not filtered by country |
| `list_categories` | sg | Category list | `-32603 Internal error` | Statement timeout (8s) |
| `list_categories` | us | Category list | `-32603 Internal error` | Statement timeout (8s) |
| `search_products` | sg, iphone 15 | Products | `total:296855040, data:[]` | reltuples stale estimate |
| `find_best_price` | iphone 15, us | US prices | `country_code:SG, currency:SGD` | Region→country derivation missing |

---

## Root Cause Analysis

### 1. `get_deals` — Empty results / `unavailable:true` (statement_timeout)

**Affected files:** `api/src/routes/mcp.ts`, `mcp-railway/src/routes/mcp.ts`

**Root cause:** BUY-60056 introduced a subquery pattern to bound deals scans:

```sql
SELECT * FROM (
SELECT ... FROM products
WHERE is_active = true AND price > 0 -- NO country filter
ORDER BY updated_at DESC
LIMIT 50000 -- Recent 50k GLOBAL rows
) _recent_deals
WHERE currency = $1 -- SGD filter applied OUTSIDE
AND country_code = $2 -- SG filter
AND discount_pct >= $3
```

The inner subquery is **unfiltered by country/currency**. The `updated_at DESC` order returns recent rows from all countries. Recent ingestion is dominated by US products. The outer WHERE applies `currency='SGD'` — US products have USD prices, so the outer filter eliminates all 50k candidate rows. The subquery then falls back to FTS with `country_code='SG'` (still global subquery, wrong fallback query), times out, and returns `unavailable:true`.

**Fix:** Move the `country_code` filter INSIDE the subquery so the `updated_at DESC` scan is scoped to the requested region:

```sql
SELECT * FROM (
SELECT ... FROM products
WHERE is_active = true AND price > 0
AND country_code = $1 -- Country INSIDE ordered scan
ORDER BY updated_at DESC
LIMIT 50000
) _recent_deals
WHERE currency = $2 -- Outer: discount filter only
AND discount_pct >= $3
```

### 2. `list_categories` — `-32603 Internal error` (statement_timeout)

**Affected file:** `mcp-railway/src/routes/mcp.ts` (the deployed `api.buywhere.ai` version)

**Root cause:** The `list_categories` fallback path runs:

```sql
SELECT slug, slug AS name, COUNT(*)::int AS product_count
FROM (
SELECT category_path
FROM products
WHERE country_code = $1 -- Filter applied AFTER 50k scan
AND category_path[1] IS NOT NULL
AND is_active = true
ORDER BY updated_at DESC
LIMIT 50000
) _recent_categories
CROSS JOIN LATERAL (SELECT category_path[1] AS slug) _cat
GROUP BY slug
```

The `ORDER BY updated_at DESC` over the full `products` table (not scoped by country) forces a sequential scan or idx scan over ALL recently-updated rows. If the 50k-window scan is global rather than country-scoped, the 8s statement timeout fires before the 50k rows are read.

**Fix:** The `mcp-railway` version (line 802-825) already has a `country_code` filter in the inner subquery:

```sql
FROM products
WHERE country_code = $1
AND category_path[1] IS NOT NULL
AND is_active = true
ORDER BY updated_at DESC
LIMIT 50000
```

This is correct. The statement timeout is likely caused by the `updated_at DESC` scan over US products (~30M rows) when `country_code='US'` — no composite index on `(updated_at, country_code)`. This may require a separate index fix, but the code-level fix is already in place.

### 3. `search_products` — `total:296855040, data:[]`

**Affected file:** `api/src/routes/mcp.ts`, `mcp-railway/src/routes/mcp.ts`

**Root cause:** Two possible paths:

**Path A (browse mode):** The `reltuples` estimate from `pg_class` for `products` was 296,855,040 at probe time. This is a Postgres catalog statistic that can be wildly stale (not updated since ANALYZE ran last). In browse mode (no `q` parameter), the total is set to this inflated estimate while the actual filtered rows are empty.

**Path B (probe encoding):** If the probe passed `q` as a positional argument or wrong key, `q` would be empty and browse mode fires. Or if `country_code='SG'` with `q='iphone 15'` but no SG products match that FTS query, results are empty while the COUNT subquery (capped at 1001) returns ≤1001, not 296M.

The 296M strongly suggests browse mode was active at probe time. The fix for `reltuples` in browse mode is outside the code scope (database ANALYZE job). However, the `search_products` function with a non-empty `q` should work correctly — the COUNT subquery returns at most 1001, so total would be ≤1001.

**Mitigation:** The `reltuples` approach is inherently unreliable. For browse mode, consider returning the actual fetched row count instead of the `reltuples` estimate. However, this is a lower-priority fix since browse mode with a country filter is an edge case.

### 4. `find_best_price` — `country_code:SG, currency:SGD` for `region=us`

**Affected file:** `mcp-railway/src/routes/mcp.ts`, `api/src/routes/mcp.ts`

**Root cause:** The previous code had no `region→country` derivation:

```typescript
// OLD — region-only callers defaulted to SG
const country = (((args.country_code as string) || (args.country as string)) || 'SG').toUpperCase();
```

Callers passing only `region='us'` (no `country_code`) would get `country='SG'` from the fallback, filtering to Singapore products and returning SGD prices.

**Fix:** Added explicit `region→country` derivation matching the tool's enum and other handlers:

```typescript
const REGION_TO_COUNTRY: Record<string, string> = { us: 'US', sea: 'SG' };
const regionRaw = ((args.region as string) || '').toLowerCase();
const regionDerived = REGION_TO_COUNTRY[regionRaw] || '';
const country = (((args.country_code as string) || (args.country as string)) || regionDerived || 'SG').toUpperCase();
```

Also removed redundant `requestedCountry` re-derivation in `api/src/routes/mcp.ts` that duplicated the fallback logic.

---

## Files Changed

| File | Change |
|------|--------|
| `mcp-railway/src/routes/mcp.ts` | get_deals: country filter inside subquery; find_best_price: region→country derivation |
| `mcp-railway/dist/routes/mcp.js` | Compiled output |
| `api/src/routes/mcp.ts` | find_best_price: region→country derivation + remove redundant requestedCountry |

---

## Verification Plan

After deployment, run the flux-probe again:

```
get_deals(sg): should return non-empty data[], no unavailable:true
get_deals(us): should return non-empty data[], no unavailable:true
list_categories(sg): should return category list, no -32603
list_categories(us): should return category list, no -32603
search_products(sg, iphone 15): should return real products, real total ≤ 1000000
find_best_price(iphone 15, us): should return country_code:US, currency:USD
```
65 changes: 65 additions & 0 deletions BUY-65454-FIX-EVIDENCE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# BUY-65454 — Fix evidence: duplicate search headings on /search

**Issue:** [QA] [UX] Duplicate search headings on /search — redundant H1 wastes
vertical space (severity: medium).

**Suggested fix (per issue):** Conditionally hide the top hero H1 when search
results are active. Use one unified results header.

## What changed

`src/app/search/SearchResultsClient.tsx` (single file, +16 / -9)

1. The hero block (`<p>Product search</p>` + `<h1>Search results for "X"</h1>` +
supporting paragraph) is now rendered **only when there is no active search**.
When `hasActiveSearch` is true the block returns `null`, so the desktop H1
no longer echoes the query string.
2. The result-count header below the search box (formerly `<h2>{N} results for
"…"</h2>`) is now rendered as `<h1>` so the page has a **single semantic H1**
— the unified results header.

The mobile compact summary (`md:hidden` H1 in the page intro) is unchanged. It
already coexists with the desktop hero (which had `md:block` / `md:hidden`
breakpoints) and is now the only H1 across both breakpoints.

## Verification (Playwright @ 1440x900, dev server on port 4711)

| Query | Before fix — H1 count | After fix — H1 count | H1 content (desktop) |
| --------------------------- | --------------------- | -------------------- | ------------------------------------------------------------------------------------- |
| `?q=iphone+15+pro&country=US` | 2 | **1** | `UNITED STATES / 0 results for "iphone 15 pro"` (mobile-summary H1, hidden at md+) |
| `?q=wireless+headphones&country=US` | 2 | **1** | `UNITED STATES / 0 results for "wireless headphones"` |
| `?q=nike+shoes&country=US` | 2 | **1** | `UNITED STATES / 0 results for "nike shoes"` |
| `/search` (no query) | 1 | **1** | `Find live catalog results without leaving BuyWhere` (hero still renders when empty) |

DOM probe confirms the redundant hero strings are gone when a search is active:

- `"Search results for"` matches on the page during active search: **0**
(previously 1)
- `"Product search"` eyebrow matches on the page during active search: **0**
(previously 1)

Mobile (390x844) after fix: H1 count = 1 (the compact summary).

> Note: the dev server's search endpoint returned HTTP 429 (upstream daily quota
> reached — resets at 2026-07-31T00:00Z) for every probe, so the result-count
> `<h1>` itself did not render in any of the captures (the page fell into the
> `error` state). The structural fix is verified by the DOM probes above — the
> hero H1 and its eyebrow are no longer in the DOM during active search, and
> the empty-query state still renders the proper hero H1.

## Screenshots

- `desktop-1440-after-fix.png` — desktop with active query, error state (no
result-count H1 due to upstream 429; hero is absent as expected)
- `desktop-1440-empty-state.png` — desktop empty-query state, hero H1 still
renders correctly
- `mobile-390-after-fix.png` — mobile with active query, single H1

Captured under `$PAPERCLIP_RUN_SCRATCH_DIR/BUY-65454/`.

## Deploy

Fix is on the current `fix/BUY-64258-robot-vacuum-aliases` branch (HEAD on
`seo-deploy/`); this issue is independent of the BUY-64258 aliases work, but
both can ship together through the standard Railway deploy path. If a separate
branch is preferred for the UX fix, the diff is small enough to cherry-pick.
Loading