diff --git a/content/blog/compare-headphones-singapore-2026.md b/content/blog/compare-headphones-singapore-2026.md index db6feed6f..6c2b9646e 100644 --- a/content/blog/compare-headphones-singapore-2026.md +++ b/content/blog/compare-headphones-singapore-2026.md @@ -1,10 +1,10 @@ --- slug: "compare-headphones-singapore-2026" -title: "Sony WH-1000XM5 Price Singapore (2026) — S$349 Cheapest" -description: "Cheapest Sony WH-1000XM5 in Singapore is S$349 on Shopee SG; Lazada S$359, Amazon S$369. Compare 2026 prices." +title: "Buy Sony WH-1000XM5 Singapore — Cheapest Price S$349 (2026)" +description: "Buy Sony WH-1000XM5 in Singapore from S$349 on Shopee SG. Compare 2026 prices across Lazada, Amazon, Challenger, Courts and Harvey Norman — live headphone deals." author: "BuyWhere Team" publishedAt: "2026-06-19" -lastUpdatedAt: "2026-07-10" +lastUpdatedAt: "2026-07-29" tags: ["headphones", "singapore", "pricing", "comparison", "audio"] jsonLd: > { @@ -12,10 +12,10 @@ jsonLd: > "@graph": [ { "@type": "Article", - "headline": "Sony WH-1000XM5 Price Singapore (2026) — From S$349", - "description": "Cheapest Sony WH-1000XM5 in Singapore is S$349 on Shopee SG; Lazada S$359, Amazon S$369. Compare 2026 prices.", + "headline": "Buy Sony WH-1000XM5 Singapore — Cheapest Price S$349 (2026)", + "description": "Buy Sony WH-1000XM5 in Singapore from S$349 on Shopee SG. Compare 2026 prices across Lazada, Amazon, Challenger, Courts and Harvey Norman — live headphone deals.", "datePublished": "2026-06-19", - "dateModified": "2026-07-10", + "dateModified": "2026-07-29", "author": { "@type": "Organization", "name": "BuyWhere Team", "url": "https://buywhere.ai" }, "publisher": { "@type": "Organization", @@ -78,7 +78,7 @@ jsonLd: > } --- -# Sony WH-1000XM5 Price Singapore (2026) — From S$349 +# Buy Sony WH-1000XM5 Singapore — Cheapest Price S$349 (2026) The top wireless headphones in Singapore — Sony WH-1000XM5, Apple AirPods Max, and Bose QuietComfort Ultra — are available from 15+ merchants with significant price variation. BuyWhere tracks real-time pricing across all Singapore merchants, showing price differences of SGD 50–150 between the cheapest and most expensive retailers for the same product. @@ -176,4 +176,4 @@ Developers can use BuyWhere's API to build price monitoring agents. The `get_dea *Data powered by BuyWhere — the definitive product catalog for AI agents. Compare prices across 50+ Singapore merchants at [buywhere.ai](https://buywhere.ai).* -*Prices last verified: 2026-06-13. This page is refreshed monthly; for live multi-merchant data, query the [BuyWhere compare hub](https://buywhere.ai/compare) or read the [cross-merchant price comparison guide](/blog/compare-product-prices-singapore-2026). See also the [Cheapest iPhone in Singapore](/blog/cheapest-iphone-singapore-2026) and the [Best Laptop Deals in Singapore](/blog/best-laptop-deals-singapore) for adjacent category refreshes.* +*Prices last verified: 2026-07-29. This page is refreshed monthly; for live multi-merchant data, query the [BuyWhere compare hub](https://buywhere.ai/compare) or read the [cross-merchant price comparison guide](/blog/compare-product-prices-singapore-2026). See also the [Cheapest iPhone in Singapore](/blog/cheapest-iphone-singapore-2026) and the [Best Laptop Deals in Singapore](/blog/best-laptop-deals-singapore) for adjacent category refreshes.* diff --git a/scripts/mobile-fold-check.mjs b/scripts/mobile-fold-check.mjs new file mode 100644 index 000000000..e34201d20 --- /dev/null +++ b/scripts/mobile-fold-check.mjs @@ -0,0 +1,86 @@ +import { chromium } from '@playwright/test'; + +const BASE_URL = process.env.BASE_URL || 'http://127.0.0.1:52000'; +const SEARCH_PATH = '/search?q=wireless%20headphones&country=us'; +const MAX_FIRST_CARD_TOP = 600; + +const products = Array.from({ length: 8 }, (_, index) => ({ + id: index + 1, + name: `Wireless headphones ${index + 1}`, + price_amount: 99 + index, + price_currency: 'USD', + merchant_name: 'Example Shop', + image_url: `https://example.com/headphones-${index + 1}.jpg`, + click_url: `https://example.com/products/${index + 1}`, + brand: 'Example', + category: 'Headphones', +})); + +async function preparePage(browser, viewport) { + const context = await browser.newContext({ viewport }); + const page = await context.newPage(); + + await page.route('**/api/products/search?**', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ data: products, total: products.length, has_more: false }), + }); + }); + + await page.goto(`${BASE_URL}${SEARCH_PATH}`, { waitUntil: 'networkidle' }); + await page.getByTestId('search-product-card').first().waitFor(); + return { context, page }; +} + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +const browser = await chromium.launch({ headless: true }); + +try { + const mobile = await preparePage(browser, { width: 390, height: 844 }); + const mobileSummary = mobile.page.getByTestId('search-mobile-summary'); + const refine = mobile.page.getByTestId('search-refine'); + const editor = mobile.page.getByTestId('search-editor'); + const desktopHero = mobile.page.getByTestId('search-desktop-hero'); + const desktopToolbar = mobile.page.getByTestId('search-desktop-toolbar'); + const firstCard = mobile.page.getByTestId('search-product-card').first(); + const firstCardRect = await firstCard.boundingBox(); + + assert(await mobileSummary.isVisible(), 'Mobile search summary must be visible'); + assert(!(await editor.isVisible()), 'Mobile search editor must be collapsed by default'); + assert(!(await desktopHero.isVisible()), 'Desktop hero must be hidden on mobile'); + assert(!(await desktopToolbar.isVisible()), 'Desktop results toolbar must be hidden on mobile'); + assert(firstCardRect, 'First product card must render'); + assert( + firstCardRect.y <= MAX_FIRST_CARD_TOP, + `First product card starts at ${firstCardRect.y}px; expected <= ${MAX_FIRST_CARD_TOP}px`, + ); + + await refine.locator('summary').click(); + assert(await editor.isVisible(), 'Refine search must reveal the editor'); + assert(await mobile.page.getByLabel('Search products').isVisible(), 'Search input must remain accessible'); + assert(await mobile.page.getByLabel('Country selector').isVisible(), 'Country selector must remain accessible'); + + const mobileSnapshot = { + viewport: { width: 390, height: 844 }, + firstCardTop: firstCardRect.y, + visibleCardPixels: 844 - firstCardRect.y, + summaryText: await mobileSummary.innerText(), + refineOpen: await refine.evaluate((element) => element.open), + }; + await mobile.context.close(); + + const desktop = await preparePage(browser, { width: 1280, height: 900 }); + assert(await desktop.page.getByTestId('search-desktop-hero').isVisible(), 'Desktop hero must remain visible'); + assert(await desktop.page.getByTestId('search-editor').isVisible(), 'Desktop search editor must remain visible'); + assert(await desktop.page.getByTestId('search-desktop-toolbar').isVisible(), 'Desktop results toolbar must remain visible'); + assert(!(await desktop.page.getByTestId('search-mobile-summary').isVisible()), 'Mobile summary must be hidden on desktop'); + await desktop.context.close(); + + console.log(JSON.stringify({ passed: true, mobile: mobileSnapshot, desktop: { width: 1280, height: 900 } }, null, 2)); +} finally { + await browser.close(); +} diff --git a/src/app/search/SearchResultsClient.tsx b/src/app/search/SearchResultsClient.tsx index dcff10214..2a2cea1b5 100644 --- a/src/app/search/SearchResultsClient.tsx +++ b/src/app/search/SearchResultsClient.tsx @@ -302,6 +302,7 @@ function SearchProgressIndicator({ startedAt }: { startedAt: number }) { function SearchCard({ product }: { product: SearchCardProduct }) { return ( (null); const searchInputRef = useRef(null); const searchFieldRef = useRef(null); + const searchRefineRef = useRef(null); + + useEffect(() => { + const mediaQuery = window.matchMedia('(min-width: 640px)'); + const syncRefineDisclosure = (matches: boolean) => { + if (searchRefineRef.current) { + searchRefineRef.current.open = matches; + } + }; + const handleViewportChange = (event: MediaQueryListEvent) => syncRefineDisclosure(event.matches); + + syncRefineDisclosure(mediaQuery.matches); + mediaQuery.addEventListener('change', handleViewportChange); + return () => mediaQuery.removeEventListener('change', handleViewportChange); + }, []); const persistSearchHistory = useCallback((searchTerm: string) => { setSearchHistory((currentHistory) => { @@ -659,8 +675,8 @@ export default function SearchResultsClient({
-
-
+
+

Product search

{query.trim() ? `Search results for "${query.trim()}"` : "Find live catalog results without leaving BuyWhere"} @@ -670,10 +686,30 @@ export default function SearchResultsClient({

-
- {isNavigating && showSearchPrompt ? : null} + {hasActiveSearch ? ( +
+

{activeCountry.label}

+

+ {loadingInitial ? `Searching for “${debouncedQuery}”` : `${total.toLocaleString()} results for “${debouncedQuery}”`} +

+
+ ) : null} + +
+ {hasActiveSearch ? ( + + Refine search + + + ) : null} +
+ {isNavigating && showSearchPrompt ? : null} -
+
-
- Suggested: - {SUGGESTED_SEARCHES.map((suggestion) => ( - - ))} +
+ Suggested: + {SUGGESTED_SEARCHES.map((suggestion) => ( + + ))} +
-
+
+
-
+
{showSearchPrompt ? (

Start browsing

@@ -851,8 +895,8 @@ export default function SearchResultsClient({ ) : null} {!showSearchPrompt && !error ? ( -
-
+
+

{activeCountry.label} @@ -945,7 +989,7 @@ export default function SearchResultsClient({ {!loadingInitial && products.length > 0 ? ( <> -

+
{products.map((product) => ( ))}