From f8c969acd11e48c446f6f1adf5d4f5f9174ac64f Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Sat, 31 Oct 2026 09:35:42 +0700 Subject: [PATCH 1/3] fix(web): spec group labels show one language, not both (#1529) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Vietnamese spec group headings carried their English name in brackets and a decorative number ("1. Màn hình (Display)"). Every locale now shows its own label only, without the number: "Màn hình" on vi, "Display & panel" on en. The same pattern is gone from the vi field labels (Vi xử lý, Camera trước), the core-count values (8 nhân), and the catalogue filter, table and about-page labels in vi, en and cs. Acronyms (GPU, ROM, SAR, MSRP), names (DOSafe, JReviews, Official Mall) and units (VND) stay. Co-Authored-By: Claude Opus 5.5 --- apps/web/e2e/category-spec-template.spec.ts | 4 +- apps/web/locales/cs.json | 4 +- apps/web/locales/en.json | 4 +- apps/web/locales/vi.json | 16 ++-- .../app/[locale]/smartphones/[slug]/page.tsx | 9 +- .../components/compare/CompareWorkspace.tsx | 9 +- .../lib/__tests__/product-detail-copy.test.ts | 2 +- .../src/lib/__tests__/spec-template.test.ts | 49 +++++++---- apps/web/src/lib/spec-localizer.ts | 87 +++++++++---------- 9 files changed, 92 insertions(+), 92 deletions(-) diff --git a/apps/web/e2e/category-spec-template.spec.ts b/apps/web/e2e/category-spec-template.spec.ts index d92b1123c..68a5b215b 100644 --- a/apps/web/e2e/category-spec-template.spec.ts +++ b/apps/web/e2e/category-spec-template.spec.ts @@ -62,7 +62,9 @@ test('a watch page never shows 5G', async ({ page }) => { await expect(meta).toContainText('Apple'); await expect(meta).not.toContainText('5G'); const specs = page.locator('#specs-section'); - await expect(specs).toContainText('Màn hình (Display)'); + await expect(specs).toContainText('Màn hình'); + // One language per page (DOS/Rate#1529). + await expect(specs).not.toContainText('(Display)'); // The seeded watch has no network value left once 5G is dropped, so the // cellular group is hidden (DOS/Rate#1186); the 4G-only bands label is // covered by product-spec-rows.test.ts. diff --git a/apps/web/locales/cs.json b/apps/web/locales/cs.json index 42318287a..785307057 100644 --- a/apps/web/locales/cs.json +++ b/apps/web/locales/cs.json @@ -1796,9 +1796,9 @@ "jreviewsRatingHeader": "Hodnocení (JReviews)", "ratingAll": "Vše", "categoryHeader": "Kategorie zařízení", - "brandHeader": "Výrobce (Brand)", + "brandHeader": "Výrobce", "priceRangeHeader": "Cenové rozpětí (Miliony VND)", - "ramHeader": "Kapacita RAM (Hardware)", + "ramHeader": "Kapacita RAM", "storageHeader": "Interní úložiště (ROM)", "featuresHeader": "Klíčové funkce (10 skupin parametrů)", "filteringBtn": "Filtrování dat...", diff --git a/apps/web/locales/en.json b/apps/web/locales/en.json index 6bc16f262..1cd234be8 100644 --- a/apps/web/locales/en.json +++ b/apps/web/locales/en.json @@ -1796,9 +1796,9 @@ "jreviewsRatingHeader": "Rating (JReviews)", "ratingAll": "All", "categoryHeader": "Device category", - "brandHeader": "Manufacturer (Brand)", + "brandHeader": "Manufacturer", "priceRangeHeader": "Price range (million VND)", - "ramHeader": "RAM Capacity (Hardware)", + "ramHeader": "RAM Capacity", "storageHeader": "Internal storage (ROM)", "featuresHeader": "Key features", "filteringBtn": "Filtering data...", diff --git a/apps/web/locales/vi.json b/apps/web/locales/vi.json index 874985541..2538649d3 100644 --- a/apps/web/locales/vi.json +++ b/apps/web/locales/vi.json @@ -219,7 +219,7 @@ "rowDisplayResolution": "Độ phân giải và mật độ điểm ảnh", "rowDisplayRefresh": "Tần số quét", "rowDisplayProtection": "Kính bảo vệ và tính năng hiển thị", - "rowHwChipset": "Vi xử lý (Chipset)", + "rowHwChipset": "Vi xử lý", "rowHwCpuGpu": "Nhân CPU và đồ họa GPU", "rowHwRam": "Dung lượng RAM", "rowHwStorage": "Bộ nhớ trong (ROM)", @@ -229,7 +229,7 @@ "rowCamSecond": "Camera thứ 2 (góc siêu rộng / phụ)", "rowCamThirdFourth": "Camera thứ 3 và thứ 4 (tele / tiềm vọng)", "rowCamVideo": "Quay video và đèn flash", - "rowCamFront": "Camera trước (selfie)", + "rowCamFront": "Camera trước", "rowBatCapacity": "Dung lượng pin", "rowBatFastCharge": "Công suất sạc nhanh", "rowBatDetails": "Công nghệ pin, sạc không dây và sạc ngược", @@ -946,7 +946,7 @@ "listingsSectionSubtitle": "Đối chiếu giá ghi nhận tại {name} với mức giá thấp nhất thị trường và trung vị lịch sử giá", "noListingsYet": "Chưa có sản phẩm nào được liên kết với gian hàng này.", "colListedProduct": "Sản phẩm niêm yết", - "colMasterProduct": "Dòng máy chuẩn hóa (master product)", + "colMasterProduct": "Dòng máy chuẩn hóa", "colRecordedPrice": "Giá ghi nhận", "colPromoAudit": "Khuyến mãi (theo lịch sử giá)", "colAuditAndSource": "Kiểm tra và nguồn", @@ -1423,7 +1423,7 @@ "pgPreset13Title": "Spec OpenAPI 3.1.0 (/openapi.json)", "pgPreset13Desc": "Trả về spec OpenAPI 3.1.0 dạng JSON cho các endpoint REST API v1 của Rate.vn.", "authPublic": "Công khai (CORS)", - "authOptIn": "Cần người dùng đồng ý (opt-in)", + "authOptIn": "Cần người dùng đồng ý", "authBearer": "Bearer token (Supabase Auth)", "cacheNoStoreQueue": "no-store (đưa vào hàng đợi rate.crawl_jobs)", "cacheNoStoreRpc": "no-store (RPC rate.submit_community_review)", @@ -1478,7 +1478,7 @@ "aboutQuickLinksTitle": "Liên kết nhanh trên hệ thống", "aboutLinkSmartphones": "Danh mục điện thoại thông minh", "aboutLinkLaptops": "Danh mục laptop & ultrabook", - "aboutLinkTablets": "Danh mục máy tính bảng (tablet)", + "aboutLinkTablets": "Danh mục máy tính bảng", "aboutLinkFaq": "Câu hỏi thường gặp về phương pháp tính điểm", "privacySeoTitle": "Chính sách bảo mật và dữ liệu | Rate.vn", "privacySeoDesc": "Rate.vn không cần tài khoản để tra cứu. Khay so sánh lưu trên trình duyệt, bình chọn gắn với DOS ID và thông tin riêng của gian hàng không công khai.", @@ -1796,11 +1796,11 @@ "jreviewsRatingHeader": "Điểm đánh giá (JReviews)", "ratingAll": "Tất cả", "categoryHeader": "Danh mục thiết bị", - "brandHeader": "Hãng sản xuất (Brand)", + "brandHeader": "Hãng sản xuất", "priceRangeHeader": "Khoảng giá (Triệu VNĐ)", - "ramHeader": "Dung lượng RAM (Hardware)", + "ramHeader": "Dung lượng RAM", "storageHeader": "Bộ nhớ trong (ROM)", - "featuresHeader": "Tính năng nổi bật (10 field groups)", + "featuresHeader": "Tính năng nổi bật", "filteringBtn": "Đang lọc dữ liệu...", "applyFiltersBtn": "Áp dụng bộ lọc", "clearAllFiltersBtn": "Xóa tất cả bộ lọc", diff --git a/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx b/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx index 8acda22da..d836a67aa 100644 --- a/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx +++ b/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx @@ -1465,16 +1465,11 @@ export async function ProductDetailView({ openOnHash="#specs-section" collapsible={specGroups.length > 3} > - {specGroups.map(({ group, rows }, i) => ( + {specGroups.map(({ group, rows }) => (

- {specGroupHeading( - specUi, - group, - i + 1, - specTemplate.has5g - )} + {specGroupHeading(specUi, group, specTemplate.has5g)}

{rows.map(({ label, value, lang }) => ( diff --git a/apps/web/src/components/compare/CompareWorkspace.tsx b/apps/web/src/components/compare/CompareWorkspace.tsx index cb626d889..30d71c792 100644 --- a/apps/web/src/components/compare/CompareWorkspace.tsx +++ b/apps/web/src/components/compare/CompareWorkspace.tsx @@ -1052,10 +1052,6 @@ export default function CompareWorkspace({ ), })) .filter((group) => group.rows.length > 0); - // Spec groups are numbered from 1 after the market group. - const numberedSpecGroups = visibleSpecGroups.filter( - (group) => group.id !== 'market_trust' - ); const searchInputRef = useRef(null); const searchBoxRef = useRef(null); @@ -2559,15 +2555,12 @@ export default function CompareWorkspace({ 1 + (products.length < MAX_COMPARE_ITEMS ? 1 : 0); - // Numbered in the category's order, and a watch's - // cellular heading without "5G" (DOS/Rate#1166). The - // market group comes first, so spec groups start at 1. + // A watch's cellular heading has no "5G" (DOS/Rate#1166). const groupTitle = group.id in specDict.groups ? specGroupHeading( specDict, group.id as SpecGroupKey, - numberedSpecGroups.indexOf(group) + 1, getCategorySpecTemplate(activeCategory).has5g ) : group.title; diff --git a/apps/web/src/lib/__tests__/product-detail-copy.test.ts b/apps/web/src/lib/__tests__/product-detail-copy.test.ts index b65fcc7ca..e086e28cb 100644 --- a/apps/web/src/lib/__tests__/product-detail-copy.test.ts +++ b/apps/web/src/lib/__tests__/product-detail-copy.test.ts @@ -190,7 +190,7 @@ function main() { 'Hexa-core (2x4.05 GHz performance + 4x2.42 GHz efficiency)', 'vi' ), - '6 nhân (Hexa-core) (2x4.05 GHz hiệu năng + 4x2.42 GHz tiết kiệm điện)' + '6 nhân (2x4.05 GHz hiệu năng + 4x2.42 GHz tiết kiệm điện)' ); assert.equal( localizeSpecValue( diff --git a/apps/web/src/lib/__tests__/spec-template.test.ts b/apps/web/src/lib/__tests__/spec-template.test.ts index 763772f3a..9111f55de 100644 --- a/apps/web/src/lib/__tests__/spec-template.test.ts +++ b/apps/web/src/lib/__tests__/spec-template.test.ts @@ -18,8 +18,8 @@ const readSource = (path: string) => const headings = (category: string, locale: string) => { const ui = getSpecUiDictionary(locale); const template = getCategorySpecTemplate(category); - return template.groups.map((group: SpecGroupKey, i: number) => - specGroupHeading(ui, group, i + 1, template.has5g) + return template.groups.map((group: SpecGroupKey) => + specGroupHeading(ui, group, template.has5g) ); }; @@ -216,7 +216,7 @@ async function testSurfaces() { // Compare headings come from the template too: no "5G" heading for a watch. assert.match( compare, - /specGroupHeading\(\s*specDict,\s*group\.id as SpecGroupKey,\s*numberedSpecGroups\.indexOf\(group\) \+ 1,\s*getCategorySpecTemplate\(activeCategory\)\.has5g\s*\)/ + /specGroupHeading\(\s*specDict,\s*group\.id as SpecGroupKey,\s*getCategorySpecTemplate\(activeCategory\)\.has5g\s*\)/ ); const detail = readSource('../../app/[locale]/smartphones/[slug]/page.tsx'); assert.match(detail, /categorySlug=\{categorySlug\}/); @@ -229,12 +229,25 @@ async function testSurfaces() { async function main() { console.warn('🧪 Category spec template suite (DOS/Rate#1142)'); - // Phones keep the ten numbered groups, worded as before. + // Phones keep the ten groups, each in the page's language only: no + // number and no English name in brackets (DOS/Rate#1529). const phone = headings('smartphones', 'vi'); assert.equal(phone.length, 10); - assert.equal(phone[0], '1. Màn hình (Display)'); - assert.equal(phone[5], '6. Mạng Di động & 5G (Cellular)'); - assert.equal(phone[9], '10. Pháp lý, Chứng nhận & SAR (Regulatory)'); + assert.equal(phone[0], 'Màn hình'); + assert.equal(phone[1], 'Phần cứng & Hiệu năng'); + assert.equal(phone[5], 'Mạng Di động & 5G'); + assert.equal(phone[9], 'Pháp lý, Chứng nhận & SAR'); + assert.equal(headings('smartphones', 'en')[0], 'Display & panel'); + for (const locale of ['vi', 'en', 'cs']) { + for (const h of [ + ...headings('smartphones', locale), + getSpecUiDictionary(locale).cellularNo5gGroup, + ]) { + assert.doesNotMatch(h, /^\d/, `${locale}: ${h}`); + // Acronyms such as (MSRP) may stay; a Vietnamese label has none left. + if (locale === 'vi') assert.doesNotMatch(h, /\(/, h); + } + } assert.deepEqual(headings('tablets', 'en'), headings('smartphones', 'en')); assert.equal( specSectionBadge(getSpecUiDictionary('vi'), 10), @@ -247,25 +260,23 @@ async function main() { const ui = getSpecUiDictionary(locale); const buds = headings('earbuds', locale); assert.equal(buds.length, 7); - assert.ok(buds[0]!.startsWith('1. ')); - assert.ok(buds[0]!.endsWith(ui.groups.multimedia.replace(/^\d+\.\s*/, ''))); + assert.equal(buds[0], ui.groups.multimedia); for (const group of ['display', 'camera', 'cellular'] as const) { - const label = ui.groups[group].replace(/^\d+\.\s*/, ''); assert.ok( - !buds.some((h: string) => h.endsWith(label)), + !buds.includes(ui.groups[group]), `earbuds (${locale}) must not show ${group}` ); } assert.equal(specSectionBadge(ui, buds.length).startsWith('7 '), true); } assert.deepEqual(headings('earbuds', 'en'), [ - '1. Audio & multimedia', - '2. Battery & charging', - '3. Connectivity & sensors', - '4. Hardware & performance', - '5. Design & durability', - '6. Availability & MSRP', - '7. Regulatory, certifications & SAR', + 'Audio & multimedia', + 'Battery & charging', + 'Connectivity & sensors', + 'Hardware & performance', + 'Design & durability', + 'Availability & MSRP', + 'Regulatory, certifications & SAR', ]); // Smartwatches: no camera group and no "5G" anywhere in the headings. @@ -277,7 +288,7 @@ async function main() { watch.join(' | ') ); } - assert.equal(headings('smartwatches', 'vi')[4], '5. Mạng Di động (Cellular)'); + assert.equal(headings('smartwatches', 'vi')[4], 'Mạng Di động'); // The card chips read only what the template leaves: an earbud has no // "1GB RAM · 64GB" and a cellular watch no "5G". diff --git a/apps/web/src/lib/spec-localizer.ts b/apps/web/src/lib/spec-localizer.ts index f146d3fc3..dbf80c840 100644 --- a/apps/web/src/lib/spec-localizer.ts +++ b/apps/web/src/lib/spec-localizer.ts @@ -94,18 +94,18 @@ const SPEC_UI_DICTS: Record = { no: 'Không', supported: 'Có hỗ trợ', has5gYes: 'Có (5G Sub-6 / mmWave)', - cellularNo5gGroup: '6. Mạng Di động (Cellular)', + cellularNo5gGroup: 'Mạng Di động', groups: { - display: '1. Màn hình (Display)', - hardware: '2. Phần cứng & Hiệu năng (Hardware)', - camera: '3. Máy ảnh & Quang học (Camera - PhoneArena Split)', - battery: '4. Pin & Công nghệ Sạc (Battery)', - design: '5. Thiết kế & Độ bền (Design)', - cellular: '6. Mạng Di động & 5G (Cellular)', - connectivity: '7. Kết nối & Cảm biến (Connectivity)', - multimedia: '8. Âm thanh & Đa phương tiện (Multimedia)', - availability: '9. Phát hành & Giá niêm yết (Availability)', - regulatory: '10. Pháp lý, Chứng nhận & SAR (Regulatory)', + display: 'Màn hình', + hardware: 'Phần cứng & Hiệu năng', + camera: 'Máy ảnh & Quang học', + battery: 'Pin & Công nghệ Sạc', + design: 'Thiết kế & Độ bền', + cellular: 'Mạng Di động & 5G', + connectivity: 'Kết nối & Cảm biến', + multimedia: 'Âm thanh & Đa phương tiện', + availability: 'Phát hành & Giá niêm yết', + regulatory: 'Pháp lý, Chứng nhận & SAR', }, fields: { screenSize: 'Kích thước màn hình', @@ -115,7 +115,7 @@ const SPEC_UI_DICTS: Record = { ppi: 'Mật độ điểm ảnh', protection: 'Kính bảo vệ', displayFeatures: 'Tính năng hiển thị', - chipset: 'Vi xử lý (Chipset)', + chipset: 'Vi xử lý', cpu: 'Nhân CPU', gpu: 'Đồ họa (GPU)', ram: 'Bộ nhớ RAM', @@ -127,7 +127,7 @@ const SPEC_UI_DICTS: Record = { secondCamera: 'Camera phụ 2 (Góc siêu rộng)', thirdCamera: 'Camera phụ 3 (Tele / Zoom)', fourthCamera: 'Camera phụ 4 (Tiềm vọng / Cảm biến)', - frontCamera: 'Camera trước (Selfie)', + frontCamera: 'Camera trước', videoAndFlash: 'Quay video & Đèn Flash', batteryCapacity: 'Dung lượng pin', batteryType: 'Loại pin', @@ -174,18 +174,18 @@ const SPEC_UI_DICTS: Record = { no: 'No', supported: 'Supported', has5gYes: 'Yes (5G Sub-6 / mmWave)', - cellularNo5gGroup: '6. Cellular network', + cellularNo5gGroup: 'Cellular network', groups: { - display: '1. Display & panel', - hardware: '2. Hardware & performance', - camera: '3. Camera system (PhoneArena Split)', - battery: '4. Battery & charging', - design: '5. Design & durability', - cellular: '6. Cellular & 5G network', - connectivity: '7. Connectivity & sensors', - multimedia: '8. Audio & multimedia', - availability: '9. Availability & MSRP', - regulatory: '10. Regulatory, certifications & SAR', + display: 'Display & panel', + hardware: 'Hardware & performance', + camera: 'Camera system', + battery: 'Battery & charging', + design: 'Design & durability', + cellular: 'Cellular & 5G network', + connectivity: 'Connectivity & sensors', + multimedia: 'Audio & multimedia', + availability: 'Availability & MSRP', + regulatory: 'Regulatory, certifications & SAR', }, fields: { screenSize: 'Screen size', @@ -254,18 +254,18 @@ const SPEC_UI_DICTS: Record = { no: 'Ne', supported: 'Podporováno', has5gYes: 'Ano (5G Sub-6 / mmWave)', - cellularNo5gGroup: '6. Mobilní sítě', + cellularNo5gGroup: 'Mobilní sítě', groups: { - display: '1. Displej a obrazovka', - hardware: '2. Hardware a výkon', - camera: '3. Fotoaparát a optika (PhoneArena Split)', - battery: '4. Baterie a nabíjení', - design: '5. Konstrukce a odolnost', - cellular: '6. Mobilní sítě a 5G', - connectivity: '7. Konektivita a senzory', - multimedia: '8. Zvuk a multimédia', - availability: '9. Dostupnost a cena (MSRP)', - regulatory: '10. Certifikace a hodnota SAR', + display: 'Displej a obrazovka', + hardware: 'Hardware a výkon', + camera: 'Fotoaparát a optika', + battery: 'Baterie a nabíjení', + design: 'Konstrukce a odolnost', + cellular: 'Mobilní sítě a 5G', + connectivity: 'Konektivita a senzory', + multimedia: 'Zvuk a multimédia', + availability: 'Dostupnost a cena (MSRP)', + regulatory: 'Certifikace a hodnota SAR', }, fields: { screenSize: 'Úhlopříčka displeje', @@ -333,18 +333,17 @@ export function getSpecUiDictionary(locale?: string): SpecUiDictionary { } /** - * A group heading numbered by its place in the category's template, so an - * earbud page that starts with audio reads "1. Audio" (DOS/Rate#1142). + * A group heading in the page's language, without a number or a second + * language (DOS/Rate#1529); a watch's cellular group has no "5G". */ export function specGroupHeading( ui: SpecUiDictionary, group: SpecGroupKey, - position: number, has5g = true ): string { - const label = - group === 'cellular' && !has5g ? ui.cellularNo5gGroup : ui.groups[group]; - return `${position}. ${label.replace(/^\d+\.\s*/, '')}`; + return group === 'cellular' && !has5g + ? ui.cellularNo5gGroup + : ui.groups[group]; } /** The "10 spec groups" badge with the category's own group count. */ @@ -445,9 +444,9 @@ const VI_SPEC_REPLACEMENTS: Array<[RegExp, string]> = [ [/\bperformance\b/gi, 'hiệu năng'], [/\befficiency\b/gi, 'tiết kiệm điện'], [/\(photo\/panorama\)/gi, '(ảnh/toàn cảnh)'], - [/\bOcta-core\b/gi, '8 nhân (Octa-core)'], - [/\bHexa-core\b/gi, '6 nhân (Hexa-core)'], - [/\bDeca-core\b/gi, '10 nhân (Deca-core)'], + [/\bOcta-core\b/gi, '8 nhân'], + [/\bHexa-core\b/gi, '6 nhân'], + [/\bDeca-core\b/gi, '10 nhân'], ]; // A letter only Vietnamese uses among the spec languages. From 3965d71781ffd7d8e816c168fc51263dfb9ffd82 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Sat, 31 Oct 2026 09:39:47 +0700 Subject: [PATCH 2/3] fix(web): compare group heads and exports drop numbers; cs labels one language (#1529 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The compare table's market group no longer reads "0. ...", and the compare CSV and JSON exports use the same localized group heading as the table instead of the numbered English title. The Czech camera, model and filter labels lose their English or count suffixes, en drops its "(master product)" gloss, and "Mạng di động" and "Công nghệ sạc" take sentence case. Co-Authored-By: Claude Opus 5.5 --- apps/web/e2e/category-spec-template.spec.ts | 2 +- apps/web/locales/cs.json | 8 +++--- apps/web/locales/en.json | 4 +-- apps/web/locales/vi.json | 2 +- .../components/compare/CompareWorkspace.tsx | 26 +++++++++++-------- .../src/lib/__tests__/spec-template.test.ts | 7 +++-- apps/web/src/lib/spec-localizer.ts | 12 ++++----- 7 files changed, 34 insertions(+), 27 deletions(-) diff --git a/apps/web/e2e/category-spec-template.spec.ts b/apps/web/e2e/category-spec-template.spec.ts index 68a5b215b..aecb54c23 100644 --- a/apps/web/e2e/category-spec-template.spec.ts +++ b/apps/web/e2e/category-spec-template.spec.ts @@ -68,7 +68,7 @@ test('a watch page never shows 5G', async ({ page }) => { // The seeded watch has no network value left once 5G is dropped, so the // cellular group is hidden (DOS/Rate#1186); the 4G-only bands label is // covered by product-spec-rows.test.ts. - await expect(specs).not.toContainText('Mạng Di động'); + await expect(specs).not.toContainText('Mạng di động'); await expect(specs).not.toContainText('5G'); await expect(specs).not.toContainText('Camera'); }); diff --git a/apps/web/locales/cs.json b/apps/web/locales/cs.json index 785307057..932c8f20c 100644 --- a/apps/web/locales/cs.json +++ b/apps/web/locales/cs.json @@ -201,7 +201,7 @@ "catEarbudsUnit": "modelů sluchátek", "boolYes": "Ano", "boolNo": "Ne", - "group0Title": "0. Hodnocení a důvěryhodnost", + "group0Title": "Hodnocení a důvěryhodnost", "rowRatingWeighted": "Skutečné hodnocení kupujících (bez falešných recenzí)", "valAuthenticReviews": "★ {rating} / 5,0 ({count, plural, one {# ověřená recenze} few {# ověřené recenze} other {# ověřených recenzí}})", "valNoReviews": "Zatím bez hodnocení", @@ -229,7 +229,7 @@ "rowCamSecond": "Druhý fotoaparát (Ultraširokoúhlý)", "rowCamThirdFourth": "Třetí a čtvrtý fotoaparát (Tele / Periskop)", "rowCamVideo": "Záznam videa a blesk", - "rowCamFront": "Přední kamera (Selfie)", + "rowCamFront": "Přední kamera", "rowBatCapacity": "Kapacita baterie", "rowBatFastCharge": "Výkon rychlého nabíjení", "rowBatDetails": "Technologie baterie, bezdrátové a reverzní nabíjení", @@ -946,7 +946,7 @@ "listingsSectionSubtitle": "Porovnání cen u prodejce {name} s nejnižší tržní cenou a historickým mediánem", "noListingsYet": "K tomuto obchodu zatím nejsou přiřazeny žádné produkty.", "colListedProduct": "Nabízený produkt", - "colMasterProduct": "Standardizovaný model (Master Product)", + "colMasterProduct": "Standardizovaný model", "colRecordedPrice": "Zaznamenaná cena", "colPromoAudit": "Sleva (podle historie cen)", "colAuditAndSource": "Kontrola a zdroj", @@ -1800,7 +1800,7 @@ "priceRangeHeader": "Cenové rozpětí (Miliony VND)", "ramHeader": "Kapacita RAM", "storageHeader": "Interní úložiště (ROM)", - "featuresHeader": "Klíčové funkce (10 skupin parametrů)", + "featuresHeader": "Klíčové funkce", "filteringBtn": "Filtrování dat...", "applyFiltersBtn": "Použít filtry", "clearAllFiltersBtn": "Vymazat všechny filtry", diff --git a/apps/web/locales/en.json b/apps/web/locales/en.json index 1cd234be8..2f64d7bdc 100644 --- a/apps/web/locales/en.json +++ b/apps/web/locales/en.json @@ -201,7 +201,7 @@ "catEarbudsUnit": "earbud models", "boolYes": "Yes", "boolNo": "No", - "group0Title": "0. Review scores and trust", + "group0Title": "Review scores and trust", "rowRatingWeighted": "Verified buyer score (fake reviews filtered)", "valAuthenticReviews": "★ {rating} / 5.0 ({count, plural, one {# verified review} other {# verified reviews}})", "valNoReviews": "No reviews yet", @@ -946,7 +946,7 @@ "listingsSectionSubtitle": "Comparing prices recorded at {name} against the market lowest price and historical median", "noListingsYet": "No product listings linked to this merchant yet.", "colListedProduct": "Listed product", - "colMasterProduct": "Canonical model (master product)", + "colMasterProduct": "Canonical model", "colRecordedPrice": "Recorded price", "colPromoAudit": "Discount (by price history)", "colAuditAndSource": "Check and source", diff --git a/apps/web/locales/vi.json b/apps/web/locales/vi.json index 2538649d3..12be947d1 100644 --- a/apps/web/locales/vi.json +++ b/apps/web/locales/vi.json @@ -201,7 +201,7 @@ "catEarbudsUnit": "mẫu tai nghe", "boolYes": "Có", "boolNo": "Không", - "group0Title": "0. Điểm đánh giá và độ tin cậy", + "group0Title": "Điểm đánh giá và độ tin cậy", "rowRatingWeighted": "Điểm người mua thật (đã lọc đánh giá ảo)", "valAuthenticReviews": "★ {rating} / 5.0 ({count} review thật)", "valNoReviews": "Chưa có đánh giá", diff --git a/apps/web/src/components/compare/CompareWorkspace.tsx b/apps/web/src/components/compare/CompareWorkspace.tsx index 30d71c792..39f52b985 100644 --- a/apps/web/src/components/compare/CompareWorkspace.tsx +++ b/apps/web/src/components/compare/CompareWorkspace.tsx @@ -1053,6 +1053,18 @@ export default function CompareWorkspace({ })) .filter((group) => group.rows.length > 0); + // A spec group's heading in the page's language, for the table and the + // exports alike (DOS/Rate#1529); a watch's cellular heading has no "5G" + // (DOS/Rate#1166). + const groupHeading = (group: { id: string; title: string }) => + group.id in specDict.groups + ? specGroupHeading( + specDict, + group.id as SpecGroupKey, + getCategorySpecTemplate(activeCategory).has5g + ) + : group.title; + const searchInputRef = useRef(null); const searchBoxRef = useRef(null); const restoredTrayRef = useRef(false); @@ -1475,7 +1487,7 @@ export default function CompareWorkspace({ const vals = products.map((p) => row.renderValue(p)); if (vals.every((v) => !v || v === '-')) continue; rows.push( - [group.title, row.label, ...vals] + [groupHeading(group), row.label, ...vals] .map((c) => escapeCsvCell(c)) .join(',') ); @@ -1514,7 +1526,7 @@ export default function CompareWorkspace({ }), matrix: specGroups.map((group) => ({ groupId: group.id, - groupTitle: group.title, + groupTitle: groupHeading(group), // Like the CSV, no row that every product leaves empty. rows: group.rows .filter((row) => @@ -2555,15 +2567,7 @@ export default function CompareWorkspace({ 1 + (products.length < MAX_COMPARE_ITEMS ? 1 : 0); - // A watch's cellular heading has no "5G" (DOS/Rate#1166). - const groupTitle = - group.id in specDict.groups - ? specGroupHeading( - specDict, - group.id as SpecGroupKey, - getCategorySpecTemplate(activeCategory).has5g - ) - : group.title; + const groupTitle = groupHeading(group); return ( diff --git a/apps/web/src/lib/__tests__/spec-template.test.ts b/apps/web/src/lib/__tests__/spec-template.test.ts index 9111f55de..c1660fc69 100644 --- a/apps/web/src/lib/__tests__/spec-template.test.ts +++ b/apps/web/src/lib/__tests__/spec-template.test.ts @@ -218,6 +218,9 @@ async function testSurfaces() { compare, /specGroupHeading\(\s*specDict,\s*group\.id as SpecGroupKey,\s*getCategorySpecTemplate\(activeCategory\)\.has5g\s*\)/ ); + // The CSV and JSON exports use the same heading as the table. + assert.match(compare, /\[groupHeading\(group\), row\.label/); + assert.match(compare, /groupTitle: groupHeading\(group\)/); const detail = readSource('../../app/[locale]/smartphones/[slug]/page.tsx'); assert.match(detail, /categorySlug=\{categorySlug\}/); assert.match(detail, /buildKeySpecLine\(product, categorySlug\)/); @@ -235,7 +238,7 @@ async function main() { assert.equal(phone.length, 10); assert.equal(phone[0], 'Màn hình'); assert.equal(phone[1], 'Phần cứng & Hiệu năng'); - assert.equal(phone[5], 'Mạng Di động & 5G'); + assert.equal(phone[5], 'Mạng di động & 5G'); assert.equal(phone[9], 'Pháp lý, Chứng nhận & SAR'); assert.equal(headings('smartphones', 'en')[0], 'Display & panel'); for (const locale of ['vi', 'en', 'cs']) { @@ -288,7 +291,7 @@ async function main() { watch.join(' | ') ); } - assert.equal(headings('smartwatches', 'vi')[4], 'Mạng Di động'); + assert.equal(headings('smartwatches', 'vi')[4], 'Mạng di động'); // The card chips read only what the template leaves: an earbud has no // "1GB RAM · 64GB" and a cellular watch no "5G". diff --git a/apps/web/src/lib/spec-localizer.ts b/apps/web/src/lib/spec-localizer.ts index dbf80c840..95c705fd4 100644 --- a/apps/web/src/lib/spec-localizer.ts +++ b/apps/web/src/lib/spec-localizer.ts @@ -94,14 +94,14 @@ const SPEC_UI_DICTS: Record = { no: 'Không', supported: 'Có hỗ trợ', has5gYes: 'Có (5G Sub-6 / mmWave)', - cellularNo5gGroup: 'Mạng Di động', + cellularNo5gGroup: 'Mạng di động', groups: { display: 'Màn hình', hardware: 'Phần cứng & Hiệu năng', camera: 'Máy ảnh & Quang học', - battery: 'Pin & Công nghệ Sạc', + battery: 'Pin & Công nghệ sạc', design: 'Thiết kế & Độ bền', - cellular: 'Mạng Di động & 5G', + cellular: 'Mạng di động & 5G', connectivity: 'Kết nối & Cảm biến', multimedia: 'Âm thanh & Đa phương tiện', availability: 'Phát hành & Giá niêm yết', @@ -283,11 +283,11 @@ const SPEC_UI_DICTS: Record = { storageExpansion: 'Rozšíření paměti', os: 'Operační systém', rearSetup: 'Sestava zadního fotoaparátu', - mainCamera: 'Hlavní snímač (Wide)', - secondCamera: 'Druhý snímač (Ultrawide)', + mainCamera: 'Hlavní snímač (širokoúhlý)', + secondCamera: 'Druhý snímač (ultraširokoúhlý)', thirdCamera: 'Třetí snímač (Teleobjektiv)', fourthCamera: 'Čtvrtý snímač (Periskop / ToF)', - frontCamera: 'Přední kamera (Selfie)', + frontCamera: 'Přední kamera', videoAndFlash: 'Záznam videa a blesk', batteryCapacity: 'Kapacita baterie', batteryType: 'Typ baterie', From fd7d46f18b1d330723a58a8d2a709d539148025b Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Sat, 31 Oct 2026 10:09:05 +0700 Subject: [PATCH 3/3] test(web): earbud spec headings have no number in the E2E check (#1529) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The earbud page E2E still expected "1. Âm thanh" and "2. Pin"; the headings are now "Âm thanh & Đa phương tiện" and "Pin & Công nghệ sạc". Co-Authored-By: Claude Opus 5.5 --- apps/web/e2e/category-spec-template.spec.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/web/e2e/category-spec-template.spec.ts b/apps/web/e2e/category-spec-template.spec.ts index aecb54c23..06c4b9e3d 100644 --- a/apps/web/e2e/category-spec-template.spec.ts +++ b/apps/web/e2e/category-spec-template.spec.ts @@ -10,12 +10,13 @@ test('earbud page shows audio specs and no phone fields', async ({ page }) => { await page.goto(BUD); const specs = page.locator('#specs-section'); const headings = specs.locator('h3'); - await expect(headings.first()).toHaveText(/^1\. Âm thanh/); + // One language and no number (DOS/Rate#1529). + await expect(headings.first()).toHaveText(/^Âm thanh/); // Groups with no value are hidden (DOS/Rate#1186) and no sale status is // invented (DOS/Rate#1193): the seeded bud has audio, battery and // Bluetooth, nothing else. await expect(headings).toHaveCount(3); - await expect(headings.nth(1)).toHaveText(/^2\. Pin/); + await expect(headings.nth(1)).toHaveText(/^Pin/); await expect(specs).toContainText('E2E 10.5mm driver'); await expect(specs).toContainText('E2E ANC'); for (const text of [