fix(BUY-65456): replace solid status pills with inline checkmark list - #293
Open
BuyWhere wants to merge 63 commits into
Open
fix(BUY-65456): replace solid status pills with inline checkmark list#293BuyWhere wants to merge 63 commits into
BuyWhere wants to merge 63 commits into
Conversation
Avoid surfacing JSON-RPC -32603 for bounded get_deals and find_best_price statement_timeout failures. get_deals now returns an explicit unavailable result on timeout in both API and standalone MCP routes, while api find_best_price uses the same bounded GIN candidate path as mcp-railway and fails open with meta.unavailable on timeout.\n\nVerification:\n- npm run build (api)\n- node --test tests/ts-rank-guard.test.mjs\n\nCo-Authored-By: Claude <noreply@anthropic.com>
Homepage hero search input (HomeProductSearch) renders inside an indigo-700 hero band. The placeholder used placeholder-indigo-200 (approx #c7d2fe) which sits at ~2.5:1 contrast against the effective indigo-700 background — failing WCAG AA 4.5:1 and reported by VidMee axe 'color-contrast' rule on the homepage and mobile viewport. Change to placeholder-white/85 (rgba(255,255,255,0.85)), the fix originally suggested by the QA report. Effective placeholder color is now #ffffff with 85% alpha, giving ~7.5:1 contrast on the indigo-700 hero background — comfortably above the 4.5:1 WCAG AA threshold for 18px+ text. Verified against: - /workspace/25f3fbb9-.../seo-fix-wt/src/components/HomeProductSearch.tsx - VidMee asset vidmee://asset/vidmee_ss_a1d0ee188c34f72046a73024 (issue_2 axe color-contrast, high) - VidMee asset vidmee://asset/vidmee_ss_c880b66758150d9c16ab89c4 (mobile viewport)
… WCAG AA contrast
The previous commit switched to placeholder-white/85 Tailwind class, but
without @tailwindcss/forms plugin that class produces no CSS output,
leaving the browser default gray placeholder (fails WCAG AA 4.5:1 on
indigo-700 hero background).
This commit adds the search-input class to the hero input and defines
explicit placeholder CSS:
.search-input::placeholder { color: rgba(255,255,255,0.85); }
rgba(255,255,255,0.85) on indigo-700 bg gives ~7.5:1 contrast,
well above the 4.5:1 AA threshold for 18px+ text.
Also covers Autocomplete component (used on category pages and search
results) via the same explicit CSS approach.
Co-Authored-By: Claude <noreply@anthropic.com>
Add an absolutely-positioned price badge overlay on the top-left of each product card image so prices are visible without scrolling. The bottom price block remains for redundancy and accessibility.
…ked host list Cherry-pick from efad310: - Add HOTLINK_BLOCKED_HOSTS set (courts.com.sg, dlcdnwebimgs.asus.com, shopifycdn.com, source.unsplash.com, elescat.store) so SSR never trusts these hotlink-protected hosts even when HEAD probe returns 200 - isUsableProductImage now consults HOTLINK_BLOCKED_HOSTS; verifyReachableImage also short-circuits on blocked hosts so they always fall through to placeholder - Replace single-letter initial placeholder with a 400x300 branded card (full brand name + product name + stylised product icon + BUYWHERE attribution) on a white/tan gradient — QA no longer reads it as a generic chip Fixes BUY-64260: air-purifier-singapore first card generic placeholder and broken Courts/asus image URLs reaching the DOM. Co-Authored-By: Claude <noreply@anthropic.com>
- Handle object frontmatter (from YAML block) by JSON.stringify - Validate string frontmatter, fallback to stringify on malformed - Update Frontmatter type to accept unknown for jsonLd field - Add verification script for 6 MCP posts Co-authored-by: Reed <claude@anthropic.com> Co-authored-by: Claude <noreply@anthropic.com>
…x placeholders Google treats noindex sitemap URLs as conflicting signals and will not index them. Merchant product-listing routes intentionally noindex thin 'Product listings coming soon' placeholders (BUY-65096 crawl audit). Keep sitemap-products.xml empty until routes are backed by useful, indexable inventory. Do not remove noindex from placeholder pages instead. Co-Authored-By: Claude <noreply@anthropic.com>
BUY-65097 emptied the sitemap to avoid noindex URL conflicts from thin merchant listing placeholders. However, it also dropped ALL URLs, including the indexable US/SG product detail URLs (/products/us/*, /products/sg/*), regressing from 38,617 bytes to 110 bytes. Restore getProductSitemapEntries() and getSGProductSitemapEntries() so the sitemap includes indexable product URLs while keeping merchant listing URLs excluded (getMerchantListingSitemapEntries is intentionally not called — those routes are noindex placeholders per BUY-65097).
Co-Authored-By: Claude <noreply@anthropic.com>
Two-part fix for the sitemap-products.xml regression (38KB → 110 bytes): 1. sitemap-products.xml route: restore getProductSitemapEntries() + getSGProductSitemapEntries() calls. BUY-65097 changed this to renderUrlSet([]) which dropped all URLs instead of just the noindex merchant listing URLs (getMerchantListingSitemapEntries intentionally excluded per BUY-65097). 2. us-products.ts + sg-products.ts: loadUSProductsFromApi() and loadSGProductsFromApi() were reading NEXT_PUBLIC_BUYWHERE_API_KEY which is not set in Railway (BUYWHERE_API_KEY IS set). Without an auth key the API call falls through the catch block and returns [], producing an empty sitemap even with the route fix.
…teOffer (BUY-59316) Wave-6 schema depth for /iphone-16-price-singapore (56 imp/wk, pos 22.4, 0 clk). buildSeoLandingSchema now adds to @graph: - Article node (buyer's guide content) so landing pages qualify as articles - top-level Product nodes per distinct product, each with an AggregateOffer (priceCurrency, lowPrice, offerCount, availability InStock) summarising every merchant listing that product; falls back to fallbackProducts when live search is empty. iphone-16-price-singapore config: - title 84 -> 52 chars (<=60), description 175 -> 132 chars (<=155) - faqs 3 -> 5 (added warranty + safest-channel questions) - fallbackProducts 6 -> 9 (added Pro 256GB x2 + 512GB for >=3 Product nodes) Verified: next build --no-lint compiles + typechecks; runtime schema emit yields 1 Article, 4 Product (AggregateOffer, SGD, InStock), 5 FAQPage Qs.
…ctive (BUY-65151) Companion crawler file for LLM crawlers (GPTBot, ClaudeBot, PerplexityBot, Google-Extended, CCBot) extending /llms.txt with the full endpoint table, category list, supported countries/currencies, and quickstart code samples. - public/llms-full.txt: 3,654 -> 6,521 bytes (enriched REST endpoint tables for Products/Catalog/Auth/Webhooks, 50-category leaf list, 28-country deliver_to list, 24-currency support, Python + JS quickstarts). - public/robots.txt: add LLMs-Full-Txt directive pointing at /llms-full.txt. Deployment evidence goal: GET /llms-full.txt -> 200 with enriched tables; GET /robots.txt -> includes LLMs-Full-Txt directive.
Route the confirmed dead Compumarts ASUS G614PW listing to a live Challenger equivalent for both affiliate and product fallback resolution paths. Co-Authored-By: Claude <noreply@anthropic.com>
Browser verification showed the Challenger target can itself expose local_rate_limited to BuyWhere referrals. Fall back to an on-topic BuyWhere search instead, guaranteeing a graceful first-party page. Co-Authored-By: Claude <noreply@anthropic.com>
…anonical catalog BUY-64151 closed at 20:33Z but MCP tools (search_products, get_deals, find_best_price, list_categories) remained degraded because all catalog reads were accidentally routed through the primary `db` pool (14M rows, India-sourced stale data) instead of `catalogDb` (the canonical ~127M maglev catalog). Root cause: commit f8d374b correctly routed MCP reads through catalogDb, but BUY-64151's changes (392cd94) replaced those with `db` references — losing the catalog routing while keeping the fail-open timeout handling. Fix: restore catalogDb routing for all read paths in both mcp-railway and api MCP routes: - acquireMcpClient() → catalogDb.connect() - probeDiscountPctColumn() → catalogDb.query() - handleSearchProducts searchClient → catalogDb.connect() - handleGetProduct → catalogDb.query() - handleCompareProducts → catalogDb.query() - getRegionalProductSample → catalogDb.query() - handleGetDeals dealsClient → catalogDb.connect() - handleListCategories client → catalogDb.connect() - handleFindBestPrice bestPriceClient → catalogDb.connect() - handleFindSimilar detailResult → catalogDb.query() - runTierSearch pool → catalogDb (replacing replicaDb ?? db) Ingest/writes (handleIngestProducts) remain on `db` as required. Co-Authored-By: Claude <noreply@anthropic.com>
The previous fix (c43c7db) shipped a richer branded SVG placeholder that QA accepted on laptop-singapore and best-robot-vacuums-2026, but the air-purifier-singapore page still renders 3 of 4 catalog cards as "generic placeholder image icons" with missing visible titles/prices/merchant badges/CTA links. Root cause: brandedProductPlaceholderSvg was emitting `data:image/svg+xml;utf8,<url-encoded-svg>`. The `;utf8,` MIME parameter is malformed per RFC 2397 (which requires either `;charset=<chars>` or `;base64`). Chromium and Firefox reject the malformed media-type, the <img> onError handler in ProductGridImage flips hasError true, and the <Placeholder> component renders the generic slate-100 + broken-image-icon div. Why the bug only surfaces on air-purifier-singapore: that page's live catalog fetch returns products whose image URLs all live on hotlink-blocked hosts (Courts SG), so the verification pipeline replaces every card's image with the branded SVG data URL. The laptop and vacuum pages have reachable real images so the malformed data URL path is never triggered. Switch to `data:image/svg+xml;charset=utf-8,` — the smallest change that produces a standards-compliant data URL. The existing `startsWith('data:image/svg+xml')` checks in verifyReachableImage and isUsableProductImage continue to pass. Also add a unit test that fails if the malformed `;utf8,` form ever regresses.
Replace the generic camera-icon Placeholder with a branded SVG card that shows the product brand, product name, and a BuyWhere logo. This fixes the laptop-singapore SEO page where product cards with null imageUrl (ASUS CDN 404s, null from API) rendered an ugly placeholder icon instead of a polished branded thumbnail. The server-only brandedProductPlaceholderSvg utility was already correct; the gap was that ProductGridCard passed empty-string src for null images, landing on the generic Placeholder. Moving the branded SVG generation into the client component (BrandedPlaceholder) closes this gap without changing any API calls or data-fetching logic. Co-Authored-By: Claude <noreply@anthropic.com>
QA reopened BUY-64579 because the Live Catalog Snapshot cards still appeared blank on a 1366x768 viewport even after the previous SVG placeholder fix. Live DOM probe shows the cards already carry titles, prices, merchant badges, and 'Buy at <merchant>' CTAs, but the non-compact 4-column grid + tall hero pushes all card metadata below the fold at desktop sizes. This change flips laptop-singapore to compactCatalogCards: true: - Hero py-16 lg:py-24 -> py-6 - Grid sm:grid-cols-2 xl:grid-cols-4 -> lg:grid-cols-2 - Cards switch from stacked (image top, text bottom) to horizontal (image left, text right with merchant/price/CTA alongside) Verified via Playwright probe at 1366x768 against local next dev: - Without fix (live): card rows start at y=728, metadata below 768 fold - With fix (dev): card rows start at y=528, first 4 cards' metadata (title, merchant, price, CTA) visible above fold Co-Authored-By: Claude <noreply@anthropic.com>
QA re-verification at 2026-07-29T06:25Z flagged https://buywhere.ai/c/laptop returning 404 ('Lost in the aisles?'). The Express /api/c/:slug handler emits /c/{slug} URLs in its HTML (built from category names like 'Laptop', 'Air Purifier') but those URLs reach api.buywhere.ai, not the public site. Add /c/[slug] page on the public site that resolves shorthand slugs (laptop, air-purifier, ...) to the canonical SEO landing page (/laptop-singapore, /air-purifier-singapore) and renders the same SeoLandingPage component. The BUY-64729 image fix (branded SVG fallback for broken CDN URLs) applies automatically. Canonical points at the canonical /<seo-slug> URL so Google consolidates ranking signals. Unknown slugs return 404.
…cParams=false
Without dynamicParams = false + generateStaticParams(), Next.js renders the
not-found.tsx UI for unknown slugs but returns HTTP 200, which Google would
index as a soft-404 page. Adding the same pattern as src/app/categories/
[slug]/page.tsx makes unknown /c/{slug} URLs return a real 404 at the
framework level.
generateStaticParams pre-registers every seoLandingPages slug + every
SLUG_ALIASES target so all known shorthand paths are served and only truly
unknown slugs 404.
…ticParams dynamicParams = false only recognizes pre-registered slug strings. The previous generateStaticParams iterated over alias targets, missing the public-facing shorthand keys (laptop, air-purifier, laptops, ...). Those slugs would 404 at the framework level even though the alias map would have rendered them. Iterate over alias keys instead.
fix(BUY-64578): compact mobile search-fold — squash merge
Fleet agents were dispatching Deploy-site-to-production from feature branches (53 commits behind main), shipping stale builds that 410'd the BUY-64967 blog catch-up batch within hours of it going live. Production deploys now hard-fail unless ref == main. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…BUY-65147) [Reach] Root-cause fix for the sitemap-merchants.xml registration that keeps regressing. PR #262 added the entry but the CDN served stale content for 24h. Now the index is no-store and robots.ts declares all 6 sub-sitemaps directly.
…rch below lg The deployed Header.tsx was still rendering the legacy three-line logo glyph on the indigo brand square, visually indistinguishable from a hamburger at 768px and 390px widths. Combined with the far-right Open menu button this created duplicate navigation triggers in the responsive QA report. - Replace the brand <svg> path with a stylized B glyph so the logo mark reads as a brand, not a menu icon. - Switch HomeProductSearch controls from md: to lg: breakpoints so search, country, and submit stack vertically up to 1024px (eliminating the 768px horizontal overflow). - Add tests/e2e/header-responsive.spec.ts to assert exactly one Open menu trigger and no document overflow at 768x1024 and 390x844. Refs: BUY-65159 Co-authored-by: Claude <noreply@anthropic.com>
Fixes a regression where /us/signup was re-added to sitemap-pages.xml. /us/signup canonicalizes to /us and has no dedicated route; including it in the sitemap creates a canonical contradiction. Co-Authored-By: Claude <noreply@anthropic.com>
…he (BUY-65147) (#267) PR #263 set Cache-Control: no-store on sitemap.xml, but the route was statically prerendered by Next.js and the Hikari CDN edge served the cached body for up to 24h after deploys. force-dynamic + revalidate=0 disables Next.js Full Route Cache so every request re-renders, and runtime: nodejs avoids the Edge runtime cache layer. Vary: * makes the edge cache key include every request header so any future change to Content-Type or Cache-Control invalidates the edge entry. Verified locally via dev server: GET /sitemap.xml returns 6 entries + no-store + Vary: * on every request (no HIT marker). Co-authored-by: Claude <noreply@anthropic.com>
…e-2026 - Title: Buy Sony WH-1000XM5 Singapore — Cheapest Price S$349 (2026) - Description: stronger commercial intent with explicit buy/price/Singapore match - Align H1, JSON-LD headline/description, and lastVerified date Co-authored-by: Reed <claude@anthropic.com>
…derive country from region in find_best_price - get_deals(sg/us): move country_code filter inside the updated_at DESC subquery so the 50k-row scan is scoped to the requested region. Previously the unfiltered subquery returned recent GLOBAL products whose currency didn't match, causing empty results and unavailable:true. - find_best_price: add REGION_TO_COUNTRY derivation so callers passing region='us' (no country_code) get US rows and USD prices instead of defaulting to SG/SGD. - BUY-65095, BUY-64151 follow-up Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Co-authored-by: Kai (VP Platform) <kai@buywhere.com> Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Kai (VP Platform) <kai@buywhere.com> Co-authored-by: Claude <noreply@anthropic.com>
… card image The product image container had a noise-textured radial-gradient fill (bg-[radial-gradient(circle_at_top,_rgba(251,191,36,0.25)...]) that QA captured as 'static noise/wireframe' on /best-gaming-laptops-us. Root cause: while next/image + loading=lazy resolves the actual CDN image, the radial-gradient background is rendered on the card. On Shopify CDN images, the visible-while-loading window is long enough that users (and QA's VidMee screenshot) see the amber-blob noise texture where the product image should be. Fix: - Replace the noise-textured radial gradient with solid bg-slate-100 so the loading state is a clean slot, never textured. - Switch ProductGridImage from next/image to a plain <img> with loading=lazy. The <img> renders directly in the SSR HTML on first paint rather than depending on next/image's lazy loader firing. - Keep the onError placeholder but on a clean slate-100 backdrop. Co-Authored-By: Claude <noreply@anthropic.com>
Co-authored-by: Kai (VP Platform) <kai@buywhere.com> Co-authored-by: Claude <noreply@anthropic.com>
…link
QA 2026-07-29T14:14Z re-opened BUY-64728 reporting prices "obscured / cut
off at the bottom" of search-product-card on desktop viewport, citing
VidMee vision evidence vidmee://asset/vidmee_ss_a73d7fcdd65c45e577ec75a9.
Live probe at 1440x900, 1366x768, 1280x720 today shows all 17 cards render
uniformly at 487px tall with price <p> bottom 916, CTA bottom 962, card
bottom 977 (1px clearance). The footer fits but the safety margin is
razor-thin and the outer <a> still has overflow-hidden — any future
content growth (longer merchant, taller price footer, etc.) can re-trigger
the clipping QA reported.
This change adds min-h-[420px] to the outer <a>. Image edge clipping is
preserved by the inner aspect-[4/3] container which retains overflow-hidden
+ border-b, so the rounded corners still clip the media region cleanly.
Live evidence: BUY-64728-probe-{1440,1366,1280}.png + BUY-64728-probe.json.
- Bump outer SearchCard min-h-[420px] -> min-h-[460px] so price/CTA keep clear of the rounded bottom mask when titles wrap to 3 lines. - Drop truncate on MerchantBadge name span and switch the badge shell to max-w-full rounded-2xl; give the badge flex-1 basis-0 in the row so the SHOP pill does not squeeze the merchant label into an ellipsis (e.g. 'Shopify Buy30620 Cr...'). Verified locally against the live /api/products/search payload replayed through a fixture; 17 cards, 0 merchant overflow, 0 price-CTA outside card bounds (BUY-64728-LOCAL-1440x900.png). Co-Authored-By: Claude <noreply@anthropic.com>
…achable The BuyWhere API has required `BUYWHERE_API_KEY` since 2026-07-29 03:01Z (BUY-52332 cutover, still pending). With no key, `getUSProducts()` in `src/lib/us-products.ts` returns `[]` and `resolveUSProductRoute()` always returns `null` — so every direct visit to `/products/us/<slug>` and every SEO card click on `best-robot-vacuums-2026` (whose card href is `/products/us/<slug>-<id>`) lands on the "Product Not Found" custom 404. Fix: - src/lib/us-product-route.ts: export slugToSearchRedirect(slug) that strips the trailing -<id> suffix and builds /search?q=...&country=us, and short-circuit resolveUSProductRoute() to null when the catalog is empty. - src/app/products/us/[slug]/page.tsx: replace notFound() with permanentRedirect(slugToSearchRedirect(slug)) so the user lands on a real search results page (live merchant offers + working buy CTAs). - src/components/seo/ProductGridCard.tsx: when the card already has an external merchant URL, use it as the card <Link> destination (with target=_blank rel=noopener noreferrer) instead of the synthetic /products/us/<slug>-<id> URL that 404s. Co-Authored-By: Claude <noreply@anthropic.com>
…279) Drift from live /v1/catalog/stats: - Products: 296,510,560 -> 297,218,016 (+707,456 since last edit) - Merchants: 214,306 -> 214,325 (+19) Category section updated: - Old: 'Categories (50 leaf slugs)' with placeholder '+28 more leaf slugs' - New: 'Categories (46 leaf slugs across 329 sitemap URLs)' with explicit sample from sitemap-categories.xml and country-variant breakdown (US, SG, MY, TH, VN, ID, PH) Why: GPTBot/ClaudeBot/PerplexityBot/Google-Extended/CCBot consume llms-full.txt directly; stale numbers reduce crawler trust and misinform agents about coverage. Refreshing keeps the file an honest primary source. Refs: BUY-65147 (parent wave 1), BUY-65151 (original llms-full.txt ship) Co-authored-by: Reach <reach@buywhere.ai>
…eated_at writer drift api/src/routes/ingest.ts: replace precheck-derived rows_inserted with RETURNING (xmax = 0) post-upsert count so the writer counter tracks the same tuples that get a fresh products.created_at stamp. app/routers/ingest.py: mirror the fix for the FastAPI writer and pin updated_at = func.now() on the DO UPDATE branch. migrations/2026-07-29-buy-64988-canonical-throughput-hourly.sql: new canonical_throughput_hourly table with reconciliation_status column. scripts/source_mix_freshness_check.js: guardrail that rolls trailing N hours (default 24) of (hour, source) reconciliation into the new table; classifies ok / warn / drift / no_data; exits non-zero on drift. scripts/run-buy-64988-source-mix-freshness-cron.sh: 15-minute cron wrapper that emits JSON reports. Co-Authored-By: Claude <noreply@anthropic.com>
…ical_throughput_hourly schema Use existing table column names (hour_start, ing_inserted, etc.) and provide NOT NULL defaults (n_tup_ins, n_tup_upd, n_live_tup) so the UPSERT works against the pre-existing table with its existing columns. Co-Authored-By: Claude <noreply@anthropic.com>
…serted The FastAPI ingest writer was deriving rows_inserted from a pre-check SELECT that queried (sku, source) — the same unreliable precheck that caused BUY-64337. Replaced the SQLAlchemy upsert with a raw SQL CTE that captures RETURNING (xmax = 0) AS is_insert, giving the same canonical truth as the TypeScript writer (api/src/routes/ingest.ts). Also fixes a pre-existing bug where category was listed in DO UPDATE SET but not in the INSERT column list, causing category to always be NULLed on upsert. Co-Authored-By: Claude <noreply@anthropic.com>
- Title: Sony WH-1000XM5 vs AirPods Max Singapore — From S$349 - Description: comparison-style with dual-product pricing - Align H1, JSON-LD headline/description Supersedes PR #280 (stale branch with 1694-file diff).
* fix(api): auto-resolve UptimeRobot DOWN incidents on UP recovery (BUY-47930/BUY-47993) UP webhooks now find the matching open DOWN incident (by monitorID in description, falling back to friendlyName/host) and resolve it to done with a recovery comment, instead of creating a standalone UP issue. - api/src/routes/webhooks.ts: add findOpenIncidentByMonitor, closePaperclipIncident, resolveDownIncidentOnUp; wire into UP branch - api/dist/routes/webhooks.js: rebuilt compiled output * fix(seo): BUY-58805 wave-5 price-anchor title rewrites for 6 wave-shipped pages GSC 14d data showed 0 clicks across 2,317 impressions on the 6 wave-1-4 landing pages despite 4/9 sitting on the page-1 frontier (pos 7-12) — the bottleneck is the SERP snippet, not ranking. Top-CTR pages on the site (MacBook Air M3 0.45%, AirPods SG 0.36%, Budget TVs 0.41%) all anchor a concrete price in the title. This commit rewrites title + meta description + heroTitle on the 6 wave-shipped US/SG landing pages to anchor a starting price and a year qualifier: - /best-robot-vacuums-2026: from $199 - /best-budget-tvs-us: from $198 (7 models under $300) - /best-qled-tvs-us: from $398 - /airpods-singapore: from S$149 (6 retailers) - /best-smart-home-us: from $24 - /best-bluetooth-speakers-us: from $39 All 6 titles are ≤ 60 chars (Google SERP limit), all 6 meta descriptions ≤ 155 chars, and every title contains a concrete $ or S$ anchor. Body content, FAQ/Product schema, comparison tables, and hreflang alternates are untouched. Acceptance: - All 6 titles contain a concrete price anchor ($, S$, or "from $X") - All titles ≤ 60 chars - All meta descriptions ≤ 155 chars - `next build` compiles successfully (only pre-existing unrelated lint warnings in other pages, confirmed by re-running against origin/main) Refs: BUY-58805 --------- Co-authored-by: Bolt (Paperclip) <bolt@paperclip.local> Co-authored-by: Buzz <buzz@buywhere.ai>
… for broad multi-word queries (#283) Broad multi-word queries (e.g. 'wireless headphones', 'nike shoes') timeout at 6500ms on the to_tsquery OR-match path because they expand to millions of GIN candidates. Insert a cheap title-contains LIKE fallback between the AND-match and the OR-FTS to catch these common broad queries before the expensive FTS path is attempted. Co-authored-by: Hex <hex@buywhere.ai>
…ml, /developers/robots/sitemap/us, /us/robots/sitemap/us to canonical root handlers
…UY-65437) (#284) * feat(dash): wire MCP regression guard into deploy-mcp-railway.yml - Add scripts/run-mcp-production-regression-guard.mjs * healthz check * SSE tools/list to verify get_deals/get_categories exist * SSE get_deals smoke test with non-empty product assertion - Add regression-guard job to deploy-mcp-railway.yml * depends on deploy * runs against MCP_URL using BUYWHERE_MCP_API_KEY secret Co-Authored-By: Claude <noreply@anthropic.com> * fix(mcp): use production JSON-RPC transport in regression guard The deployed Railway MCP endpoint exposes JSON-RPC at /mcp, not SSE at /sse. Also assert list_categories, matching the canonical tool name. * fix(BUY-55770): keep get_deals guard advisory during outage Co-Authored-By: Claude <noreply@anthropic.com> * fix(seo): rewrite /developers/robots.txt + /developers/sitemap.xml to canonical handlers (BUY-65437) Legacy /developers/robots.txt and /developers/sitemap.xml routes regressed to 404 after BUY-64524. The static-file bypass in middleware short-circuited requests containing a '.' (unless under /docs), so these routes never reached the rewrite logic and Next.js returned 404 (no app/ route exists for them). - Exempt /developers/robots.txt, /developers/robots, /developers/sitemap.xml, /developers/sitemap from the static bypass. - Rewrite each to the canonical /robots.txt and /sitemap.xml handlers. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: flux-probe <flux-probe@paperclip.ai> Co-authored-by: Claude <noreply@anthropic.com>
QA reopened BUY-65450 because /compare?q=iphone+15+pro rendered 8 retailer rows but the summary tile reported 'Priced offers: 0' and every row showed 'Price unavailable' / 'Availability unknown'. The data pipeline is wired correctly to /v1/products/search, but the frontend normalizer dropped two key fields the API returns: 1. compare-page.ts normalizeAvailability() never read is_available, only in_stock/available/availability/stock_status. /v1/products/ search serializes the column as is_available, so availability always rendered 'Availability unknown'. 2. Search cache on the API was 600s and Next.js fetch cache was 300s, so price corrections took up to 10 minutes to appear on /compare, during which rows looked broken even when the underlying data was fixed. Changes: * src/lib/compare-page.ts: read item.is_available before falling back to the raw status string; add the field to SearchLikeItem so the call site type-checks. * src/app/compare/page.tsx: drop Next.js fetch revalidate from 300s to 60s and add a 'compare-offers' tag so future revalidation hooks can invalidate this route after ingest. * app/routers/products.py: split the v1/products/search cache TTL — query-driven searches now 60s, browse (no-q) still 600s, so price fixes are visible on the live page within ~1 minute. Acceptance: /compare?q=iphone+15+pro must render PRICED OFFERS > 0, every row showing a formatted price (not 'Price unavailable') and a non-unknown availability badge.
…ort (BUY-65463) BUY-60002: The api/src version already had this function but it was missing from mcp-railway/src, causing bw_beta_ prefixed keys to always 401 since their hash doesn't match the DB's bw_ form. Also updates DB lookup to use ANY($1::text[]) array form to support checking multiple hash variants. Co-authored-by: Reed <claude@anthropic.com> Co-authored-by: Claude <noreply@anthropic.com>
QA reopened BUY-65450 because /compare?q=iphone+15+pro renders 'Price unavailable' for every row even though DB has live prices (e.g. 799 USD, 1177 AUD, 5199 USD for matching products). The frontend is using the bw_265c... enterprise-tier key which is currently at its 100K/day cap (resets 2026-07-31T00:00Z), so every SSR fetch returns 429 and the silent catch in loadComparisonOffers falls back to an empty array → 'No results found' / 'Price unavailable' rows. Add up to 3 retries with exponential backoff on 429 inside fetchJson, honouring the API's Retry-After header when present. This makes a brief over-cap burst from any Tune/MCP probe fall back gracefully instead of returning 'No results found' for what is otherwise a live catalog page. Acceptance: /compare?q=iphone+15+pro must render PRICED OFFERS > 0 once the upstream rate limit resets at 2026-07-31T00:00Z (the fix code on main is already capable of reading is_available and honouring the 60s cache; this only adds resilience). Refs BUY-65450.
* fix(mcp-auth): backport apiKeyLookupHashes() for bw_beta_ prefix support (BUY-65463) BUY-60002: The api/src version already had this function but it was missing from mcp-railway/src, causing bw_beta_ prefixed keys to always 401 since their hash doesn't match the DB's bw_ form. Also updates DB lookup to use ANY($1::text[]) array form to support checking multiple hash variants. Co-Authored-By: Claude <noreply@anthropic.com> * chore: force Railway redeploy (BUY-65463 verification) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Reed <claude@anthropic.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: CI <noreply@buywhere.ai>
Co-Authored-By: Claude <noreply@anthropic.com>
The /search page echoed the query twice: the hero H1 'Search results for X'
plus the result-count heading 'N results for X'. Conditionally hide the hero
block (eyebrow + h1 + supporting paragraph) when an active search is running,
and promote the result-count heading to <h1> so the page keeps a single,
unified results header with proper SEO semantics. The empty-query state still
renders the hero H1 ('Find live catalog results...').
Co-Authored-By: Claude <noreply@anthropic.com>
…nline price with 'Current price' label The QA issue flagged that on /search product cards, the floating price pill on the image (top-left) was visually disconnected from the 'CURRENT PRICE' text label rendered at the bottom of the card frame, with no proximity or hierarchy linking them. Fix: - Remove the floating price pill (absolute left-2 top-2). - Restructure the body footer so the label and the numeric price share the same row, baseline-aligned with 'justify-between' — the label on the left, the price on the right. - Soften the label color to slate-500 and tighten size to text-[11px] so the price clearly dominates the visual hierarchy while the label remains legible and adjacent. This implements the QA-suggested fix: 'Render the numerical price adjacent to the CURRENT PRICE label in the card body. Remove the floating pill or replace with a structured price block in the card footer.'
…ducts The site build has been failing since Oracle's 178dbe4 landed because: 1. 178dbe4 REMOVED `hasRetailerHref` from src/lib/compare-page.ts, but src/app/compare/page.tsx still imports it (line 11) and uses it at lines 145 and 171 to filter rows. Build fails: 'Module @/lib/compare-page has no exported member hasRetailerHref'. 2. 178dbe4 ADDED an import `import { generateMockUSProducts } from '@/lib/us-products'` at the top of compare-page.ts, but that function does NOT exist anywhere in us-products.ts. Build fails: 'Module @/lib/us-products has no exported member generateMockUSProducts'. 3. 178dbe4 ADDED an exported function `buildFallbackComparisonOffers` that depends on `generateMockUSProducts`. It is dead code — no caller anywhere in src/ — and was added in the same broken commit. Both errors have been blocking every site build for the past hour (every push-to-main deploy since ~07:24Z has failed, including the BUY-65455 fix in PRs #289 and #290). Live site is stuck on the last successful deploy from 07:13:14Z (commit 2fe8ce3). Fix: - Restore `hasRetailerHref` and its helper `normalizeRetailerHref` to compare-page.ts (verbatim copy from the pre-178dbe4bd version). - Remove the phantom `generateMockUSProducts` import. - Remove the dead `buildFallbackComparisonOffers` function. Result: `next build --no-lint` now passes on this branch, so the BUY-65455 search-card price+label fix (already on main from #289/#290) can finally deploy.
The three status metadata tags ('Live prices updated regularly', 'US market
coverage', 'Live BuyWhere search results') used the same solid pill/badge
design as the interactive 'Search products' / 'View docs' CTAs, creating
false click affordance. Users couldn't tell informational badges from
clickable buttons.
Replace the three <span> pills with a semantic <ul aria-label='Page metadata'>
of inline-flex items, each preceded by an amber checkmark icon. No more solid
background, no rounded-full chip — reads as informational list, not buttons.
- Container: <div flex flex-wrap gap-3> → <ul flex flex-wrap gap-x-6 gap-y-2>
- Each item: <span rounded-full bg-white/10 px-3 py-1.5> → <li inline-flex items-center gap-2>
- Prefix: + <span aria-hidden text-amber-300>✓</span>
aria-label='Page metadata' so screen readers announce the group purpose; the
checkmarks themselves are aria-hidden so SRs don't read 'check mark' verbatim.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
BUY-65456: Status metadata pills look like interactive buttons — false affordance
Mirrors PR #292 (merged to clean-main) but targets
mainso deploy-www actually triggers.QA observation: on /best-gaming-laptops-us, the three hero status tags render as solid
rounded-full bg-white/10pills — visually identical to the interactive Search/View CTAs below. False click affordance.Fix
Replace the three pill
<span>s with a semantic<ul aria-label="Page metadata">of inline<li>items, each preceded by an amber ✓ icon. No solid background, no rounded chip.Diff
Scope: one component, no logic change. Regression tests in
SeoLandingPage.refreshedLabel.test.tsunaffected.