From a265625801f8d52a80b6e57ca3d3e7945b1c0ee7 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Sat, 24 Oct 2026 12:58:14 +0700 Subject: [PATCH 01/12] fix(web): fit the phone header in 320px and keep excluded reviews legible (#809, #811) (#812) At 320px the header needed 327px of 296, so every page scrolled sideways and the menu toggle was cut off; the review sort select also overflowed. Tighten the header spacing below 360px, slim the compact language switcher and let the sort select shrink. Excluded review cards were dimmed with opacity-75, which dropped all their text to 3.1-3.9:1. Set them apart by background and border only. Co-authored-by: JOY Co-authored-by: Claude Opus 5.5 --- apps/web/e2e/reflow-contrast.spec.ts | 61 +++++++++++++++++++ apps/web/e2e/seed.sql | 28 +++++++++ .../src/app/[locale]/merchants/[id]/page.tsx | 4 +- apps/web/src/app/style/scss/_custom.scss | 21 +++++++ .../components/elementary/LocaleSwitcher.tsx | 8 ++- .../components/reviews/JReviewsSection.tsx | 9 ++- 6 files changed, 126 insertions(+), 5 deletions(-) create mode 100644 apps/web/e2e/reflow-contrast.spec.ts diff --git a/apps/web/e2e/reflow-contrast.spec.ts b/apps/web/e2e/reflow-contrast.spec.ts new file mode 100644 index 000000000..f9f79d7b1 --- /dev/null +++ b/apps/web/e2e/reflow-contrast.spec.ts @@ -0,0 +1,61 @@ +import AxeBuilder from '@axe-core/playwright'; +import { expect, test } from '@playwright/test'; + +// At 320px no page scrolls sideways and the phone menu toggle stays inside +// the viewport (DOS/Rate#809, WCAG 1.4.10 Reflow). +const PHONE_PAGES = [ + '/', + '/smartphones', + '/smartphones/iphone-16-pro-max-256gb', + '/merchants', + '/compare', +]; + +for (const path of PHONE_PAGES) { + test(`${path} fits a 320px screen`, async ({ page }) => { + await page.setViewportSize({ width: 320, height: 640 }); + expect((await page.goto(path))?.status()).toBe(200); + await page.evaluate(() => document.fonts.ready); + const { scrollWidth, toggleRight } = await page.evaluate(() => ({ + scrollWidth: document.documentElement.scrollWidth, + toggleRight: + document.querySelector('.nav-toggle')?.getBoundingClientRect().right ?? + Infinity, + })); + expect(scrollWidth).toBeLessThanOrEqual(320); + expect(toggleRight).toBeLessThanOrEqual(320); + }); +} + +test('the review sort control fits a 320px screen', async ({ page }) => { + await page.setViewportSize({ width: 320, height: 640 }); + expect( + (await page.goto('/smartphones/iphone-16-pro-max-256gb'))?.status() + ).toBe(200); + // Measured in page coordinates: scrolling it into view could also scroll + // the page sideways and hide the overflow. + const right = await page + .locator('#review-sort-select') + .evaluate((el) => el.getBoundingClientRect().right + window.scrollX); + expect(right).toBeLessThanOrEqual(320); +}); + +// Reviews excluded from the score are set apart without dimming their text +// below 4.5:1 (DOS/Rate#811). +for (const path of [ + '/smartphones/e2e-excluded-review-phone', + '/merchants/excluded-review-store', +]) { + test(`excluded review text meets contrast on ${path}`, async ({ page }) => { + expect((await page.goto(path))?.status()).toBe(200); + const excluded = page.getByText('shop uy tín, giao nhanh nhận xu').first(); + await expect(excluded).toBeVisible(); + await page.waitForLoadState('networkidle'); + const { violations } = await new AxeBuilder({ page }) + .withRules(['color-contrast']) + .analyze(); + expect( + violations.flatMap((v) => v.nodes.map((n) => n.target.join(' '))) + ).toEqual([]); + }); +} diff --git a/apps/web/e2e/seed.sql b/apps/web/e2e/seed.sql index 64c8ca405..163a3733e 100644 --- a/apps/web/e2e/seed.sql +++ b/apps/web/e2e/seed.sql @@ -277,3 +277,31 @@ VALUES ('cccccccc-cccc-4ccc-8ccc-ccccccc07974', 'ffffffff-ffff-4fff-8fff-ffffffff7972', 'shopee', 'item_bait_4', 'E2E Bait Price Phone', 'c0000000-0000-4000-8000-000000000001', 3690000, 'https://shopee.vn/bait-4', '22222222-2222-4222-8222-222222220797'); + +-- A phone with one review excluded from the score as coin-farming spam, shown +-- set apart on the product page: its text must still meet 4.5:1 contrast +-- (DOS/Rate#811). Its rating is never refreshed, so it stays off the +-- leaderboard, and its reviews are older than every other review. +INSERT INTO rate.merchants (id, platform, platform_shop_id, name, slug, is_official) +VALUES ('ffffffff-ffff-4fff-8fff-ffffffff0811', 'shopee', 'shop_excluded_review', 'Excluded Review Store', 'excluded-review-store', false); +INSERT INTO rate.master_products + (id, category_id, brand, model, model_key, name, slug, min_price, max_price, merchant_count, status, metadata) +VALUES ('22222222-2222-4222-8222-222222220811', 'c0000000-0000-4000-8000-000000000001', 'E2E', 'Excluded Review Phone', + 'e2e_excluded_review_phone', 'E2E Excluded Review Phone', 'e2e-excluded-review-phone', 3000000, 3000000, 1, 'published', + '{"category_slug": "smartphones"}'); +INSERT INTO rate.products (id, merchant_id, platform, platform_item_id, title, category_id, price_current, url, cluster_id) +VALUES ('cccccccc-cccc-4ccc-8ccc-cccccccc0811', 'ffffffff-ffff-4fff-8fff-ffffffff0811', 'shopee', 'item_excluded_review', + 'E2E Excluded Review Phone', 'c0000000-0000-4000-8000-000000000001', 3000000, 'https://shopee.vn/item-excluded-review', + '22222222-2222-4222-8222-222222220811'); +INSERT INTO rate.reviews (id, product_id, author_name, rating_star, comment, reviewer_type, platform_created_at, created_at) +VALUES + ('44444444-4444-4444-8444-444444440811', 'cccccccc-cccc-4ccc-8ccc-cccccccc0811', 'Excluded 1', 5, + 'Sản phẩm tốt tuyệt vời, shop uy tín, giao nhanh nhận xu.', 'user', now() - interval '100 days', now() - interval '100 days'), + ('44444444-4444-4444-8444-444444440812', 'cccccccc-cccc-4ccc-8ccc-cccccccc0811', 'Excluded 2', 4, + 'Máy chạy ổn, pin dùng được một ngày, màn hình hơi tối ngoài nắng.', 'user', now() - interval '100 days', now() - interval '100 days'); +INSERT INTO rate.review_flags (review_id, verdict, fake_probability, detector, detector_version, input_hash, include_in_rating, flag_category, reason) +VALUES + ('44444444-4444-4444-8444-444444440811', 'fake', 0.9, 'e2e', '1', 'e2e-0811', false, 'seeding_fake', + 'Bình luận mang tính chất nhận xu, không mô tả trải nghiệm.'), + ('44444444-4444-4444-8444-444444440812', 'authentic', 0.05, 'e2e', '1', 'e2e-0812', true, null, + 'Đánh giá chi tiết, hợp lệ từ người dùng thực tế.'); diff --git a/apps/web/src/app/[locale]/merchants/[id]/page.tsx b/apps/web/src/app/[locale]/merchants/[id]/page.tsx index 62d0a6b2b..70d864fad 100644 --- a/apps/web/src/app/[locale]/merchants/[id]/page.tsx +++ b/apps/web/src/app/[locale]/merchants/[id]/page.tsx @@ -693,9 +693,11 @@ export default async function MerchantProfilePage({ return (
+ {/* Set apart by background, not opacity, which dropped + the text below 4.5:1 (DOS/Rate#811). */}
diff --git a/apps/web/src/app/style/scss/_custom.scss b/apps/web/src/app/style/scss/_custom.scss index 33cc9004f..925a50185 100644 --- a/apps/web/src/app/style/scss/_custom.scss +++ b/apps/web/src/app/style/scss/_custom.scss @@ -1500,3 +1500,24 @@ body .input-group > .input-group-text { .filterFlex .btn { white-space: nowrap; } + +// Phone header at 320px: brand, icon group and menu toggle need 327px of +// the 296px available, so the page scrolled sideways and the toggle was cut +// off. Tighten the spacing below 360px only (DOS/Rate#809). +@media (max-width: 359.98px) { + .navigation .nav-brand { + margin-right: 0.5rem; + } + + .navigation .mobile_nav { + margin-right: 34px; + } + + .navigation .mobile_nav ul { + gap: 0.25rem !important; + } + + .navigation .mobile_nav ul > li.me-1 { + margin-right: 0 !important; + } +} diff --git a/apps/web/src/components/elementary/LocaleSwitcher.tsx b/apps/web/src/components/elementary/LocaleSwitcher.tsx index f2aee7443..b73c9252c 100644 --- a/apps/web/src/components/elementary/LocaleSwitcher.tsx +++ b/apps/web/src/components/elementary/LocaleSwitcher.tsx @@ -162,10 +162,14 @@ export function LocaleSwitcher({