From 146cd781c2c2d133cfebfa383df5cd2db94b15b3 Mon Sep 17 00:00:00 2001
From: JOY <5027251+JOY@users.noreply.github.com>
Date: Sat, 31 Oct 2026 10:55:46 +0700
Subject: [PATCH 1/3] fix(web): the product page shows specs and reviews before
prices and sellers (#1535)
* fix(web): the product page shows specs and reviews before prices and sellers
The product comes first: after the hero and scoreboard the page reads the
spec groups, then the reviews, the price history, and the sellers last.
The jump row follows the same order. It stays in the phone's first screen
(DOS/Rate#1517), as does the price strip with the best price.
The unknown-size listings stay with the sellers. The item pages share this
view, so they follow too.
Closes #1530
* test(web): the section order tests name the new order
Co-Authored-By: Claude Opus 5.5
---
.../e2e/item-model/item-product-page.spec.ts | 6 +-
apps/web/e2e/product-above-fold.spec.ts | 2 +-
apps/web/e2e/product-section-order.spec.ts | 12 +-
.../app/[locale]/smartphones/[slug]/page.tsx | 136 +++++++++---------
.../__tests__/core-rate-architecture.test.ts | 12 +-
5 files changed, 87 insertions(+), 81 deletions(-)
diff --git a/apps/web/e2e/item-model/item-product-page.spec.ts b/apps/web/e2e/item-model/item-product-page.spec.ts
index 77762d27f..5e39e9468 100644
--- a/apps/web/e2e/item-model/item-product-page.spec.ts
+++ b/apps/web/e2e/item-model/item-product-page.spec.ts
@@ -9,10 +9,10 @@ const ITEM = '/smartphones/e2e-item-phone';
const BIG = '/smartphones/e2e-item-big';
const NO_DATA_MERCHANT = '17e00000-0000-4000-8000-0000000d1484';
const ORDER = [
- 'offers-table',
- 'pdv-price-history',
'specs-section',
'jreviews-section',
+ 'pdv-price-history',
+ 'offers-table',
];
/** Clicks once the page is hydrated: a click before it does nothing. */
@@ -72,7 +72,7 @@ test('the size picker replaces the quick specs and links each size', async ({
// The lazy Item also with another size loaded on demand.
for (const path of [ITEM, BIG, `${BIG}?v=256gb`]) {
- test(`${path}: price, jump row, sellers, history, specs, reviews`, async ({
+ test(`${path}: price, jump row, specs, reviews, history, sellers`, async ({
page,
}) => {
await page.goto(path);
diff --git a/apps/web/e2e/product-above-fold.spec.ts b/apps/web/e2e/product-above-fold.spec.ts
index f258e0244..cddf57461 100644
--- a/apps/web/e2e/product-above-fold.spec.ts
+++ b/apps/web/e2e/product-above-fold.spec.ts
@@ -3,7 +3,7 @@ import { expect, test } from '@playwright/test';
// On a phone the product name, the price line and the user score are in the
// first screen, not behind a full-screen photo (DOS/Rate#605). The score value
// itself, not only its label, must fit (DOS/Rate#630). So does the whole jump row to
-// the sellers, history, specs and reviews (DOS/Rate#1517).
+// the specs, reviews, history and sellers (DOS/Rate#1517, #1530).
const PRODUCT = '/smartphones/iphone-16-pro-max-256gb';
for (const viewport of [
diff --git a/apps/web/e2e/product-section-order.spec.ts b/apps/web/e2e/product-section-order.spec.ts
index 94afd8f05..5fdf37098 100644
--- a/apps/web/e2e/product-section-order.spec.ts
+++ b/apps/web/e2e/product-section-order.spec.ts
@@ -1,19 +1,19 @@
import { expect, test } from '@playwright/test';
-// A shopper reaches the sellers first: the page reads price box, sellers,
-// price history, specs, then reviews, with a jump row to each, and phones
-// show the first three reviews until expanded (DOS/Rate#1469).
+// The product comes first: the page reads price box, specs, reviews, price
+// history, then the sellers last, with a jump row to each (DOS/Rate#1469,
+// #1530), and phones show the first three reviews until expanded.
const PRODUCT = '/smartphones/iphone-16-pro-max-256gb';
// Six seeded reviews (seed.sql, DOS/Rate#566).
const MANY_REVIEWS = '/smartphones/e2e-review-labels-phone';
const ORDER = [
- 'offers-table',
- 'pdv-price-history',
'specs-section',
'jreviews-section',
+ 'pdv-price-history',
+ 'offers-table',
];
-test('sellers come first, then price history, specs and reviews', async ({
+test('specs come first, then reviews, price history and sellers', async ({
page,
}) => {
await page.goto(PRODUCT);
diff --git a/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx b/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx
index 8acda22da..193ec94c9 100644
--- a/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx
+++ b/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx
@@ -1026,23 +1026,23 @@ export async function ProductDetailView({
)}
- {/* Where to buy comes first, then the price history, specs and
- reviews; this row jumps to each (DOS/Rate#1469). It sits under the
- price, before the scoreboard, so a phone shows it in the first
- screen (DOS/Rate#1517). */}
+ {/* The specs come first, then the reviews, the price history and
+ the sellers; this row jumps to each (DOS/Rate#1469, #1530). It
+ sits under the price, before the scoreboard, so a phone shows it
+ in the first screen (DOS/Rate#1517). */}
-
- {t('jumpNavOffers', { count: offers.length })}
-
- {t('jumpNavPriceHistory')}
{t('jumpNavSpecs')}
{t('jumpNavReviews', { count: authenticCount })}
+ {t('jumpNavPriceHistory')}
+
+ {t('jumpNavOffers', { count: offers.length })}
+
{/* #1 Core Focus Above the Fold: JReviews Dual-Rating & 5-Criteria Scoreboard (DESIGN.md v3.0) */}
@@ -1356,65 +1356,10 @@ export async function ProductDetailView({
- {/* Neutral Merchant Reference & Trust Audit Table */}
-
- {isItemGroup
- ? renderSizePanels('offers', (v) =>
- renderOffersBody(variantPanelInput(v))
- )
- : renderOffersBody({
- product,
- offers,
- priceHistory,
- radarSignal,
- emptyText: pageEmptyOffersText,
- })}
-
-
- {/* Listings whose size is not stated: their own group, never part
- of the "from" price (Q2, DOS/Rate#1154). */}
- {itemPage && itemPage.unknownOffers.length > 0 && (
-
-
- {t('unknownSizeTitle', {
- count: itemPage.unknownOffers.length,
- })}
-
-
{t('unknownSizeNote')}
-
-
- )}
-
- {/* Time-Series Price History Chart, Fake Discount Detector & Neutral
- Merchant Audit. The wrapper is the jump row's target: a lazy size
- panel has no #price-history-section until it loads (#1469). */}
-
- {isItemGroup
- ? renderSizePanels('history', (v) =>
- renderPriceHistory(variantPanelInput(v))
- )
- : renderPriceHistory({
- product,
- priceHistory,
- minPrice: currentMinPrice,
- radarSignal,
- })}
-
-
- {/* Spec groups, per category template (DOS/Rate#1142), before
- the reviews (DOS/Rate#1436) */}
+ {/* The product first: spec groups, per category template
+ (DOS/Rate#1142), then the reviews, the price history and the sellers
+ last (DOS/Rate#1530). The price strip above keeps the best price in
+ the first screen. */}
{(() => {
const specUi = getSpecUiDictionary(locale);
// Rows with no value and groups with no row are left out
@@ -1521,6 +1466,63 @@ export async function ProductDetailView({
/>
+ {/* Time-Series Price History Chart, Fake Discount Detector & Neutral
+ Merchant Audit. The wrapper is the jump row's target: a lazy size
+ panel has no #price-history-section until it loads (#1469). */}
+
+ {isItemGroup
+ ? renderSizePanels('history', (v) =>
+ renderPriceHistory(variantPanelInput(v))
+ )
+ : renderPriceHistory({
+ product,
+ priceHistory,
+ minPrice: currentMinPrice,
+ radarSignal,
+ })}
+
+
+ {/* Neutral Merchant Reference & Trust Audit Table */}
+
+ {isItemGroup
+ ? renderSizePanels('offers', (v) =>
+ renderOffersBody(variantPanelInput(v))
+ )
+ : renderOffersBody({
+ product,
+ offers,
+ priceHistory,
+ radarSignal,
+ emptyText: pageEmptyOffersText,
+ })}
+
+
+ {/* Listings whose size is not stated: their own group, never part
+ of the "from" price (Q2, DOS/Rate#1154). */}
+ {itemPage && itemPage.unknownOffers.length > 0 && (
+
+
+ {t('unknownSizeTitle', {
+ count: itemPage.unknownOffers.length,
+ })}
+
+
{t('unknownSizeNote')}
+
+
+ )}
+
{/* Interactive Value Efficiency & 2-Year Ownership Calculator (#62) */}
{(() => {
const lbScored = scoreMasterProductForLeaderboard(
diff --git a/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts b/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts
index ff8098b25..9d1c28695 100644
--- a/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts
+++ b/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts
@@ -563,8 +563,8 @@ async function runCoreArchitectureSuite() {
'earbuds'
);
- // ProductDetailView reads: sellers (#offers-table), then specs, then reviews
- // (DOS/Rate#1436, #1469).
+ // ProductDetailView reads: specs, then reviews, then the price history and
+ // the sellers (#offers-table) last (DOS/Rate#1436, #1469, #1530).
const detailPagePath = path.resolve(
__dirname,
'../../../app/[locale]/smartphones/[slug]/page.tsx'
@@ -572,10 +572,14 @@ async function runCoreArchitectureSuite() {
const detailPageSrc = fs.readFileSync(detailPagePath, 'utf8');
const idxJReviews = detailPageSrc.indexOf(' 0 && idxSpecs > idxOffers && idxJReviews > idxSpecs,
- 'ProductDetailView must render #offers-table before #specs-section, and #specs-section before '
+ idxSpecs > 0 &&
+ idxJReviews > idxSpecs &&
+ idxHistory > idxJReviews &&
+ idxOffers > idxHistory,
+ 'ProductDetailView must render #specs-section, , #pdv-price-history, then #offers-table'
);
console.log(
From b7add562e4bcc2a1603e1a4ca905e082f8b4b3ed Mon Sep 17 00:00:00 2001
From: JOY <5027251+JOY@users.noreply.github.com>
Date: Sat, 31 Oct 2026 10:56:29 +0700
Subject: [PATCH 2/3] fix(web): spec group labels show one language, not both
(#1532)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(web): spec group labels show one language, not both (#1529)
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.
* fix(web): compare group heads and exports drop numbers; cs labels one language (#1529 review)
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.
* test(web): earbud spec headings have no number in the E2E check (#1529)
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 | 11 ++-
apps/web/locales/cs.json | 12 +--
apps/web/locales/en.json | 8 +-
apps/web/locales/vi.json | 18 ++--
.../app/[locale]/smartphones/[slug]/page.tsx | 9 +-
.../components/compare/CompareWorkspace.tsx | 33 +++----
.../lib/__tests__/product-detail-copy.test.ts | 2 +-
.../src/lib/__tests__/spec-template.test.ts | 52 +++++++----
apps/web/src/lib/spec-localizer.ts | 93 +++++++++----------
9 files changed, 123 insertions(+), 115 deletions(-)
diff --git a/apps/web/e2e/category-spec-template.spec.ts b/apps/web/e2e/category-spec-template.spec.ts
index d92b1123c..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 [
@@ -62,11 +63,13 @@ 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.
- 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 42318287a..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",
@@ -1796,11 +1796,11 @@
"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ů)",
+ "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 6bc16f262..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",
@@ -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..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á",
@@ -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 193ec94c9..c7d1d2f61 100644
--- a/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx
+++ b/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx
@@ -1410,16 +1410,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..39f52b985 100644
--- a/apps/web/src/components/compare/CompareWorkspace.tsx
+++ b/apps/web/src/components/compare/CompareWorkspace.tsx
@@ -1052,10 +1052,18 @@ 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'
- );
+
+ // 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);
@@ -1479,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(',')
);
@@ -1518,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) =>
@@ -2559,18 +2567,7 @@ 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.
- const groupTitle =
- group.id in specDict.groups
- ? specGroupHeading(
- specDict,
- group.id as SpecGroupKey,
- numberedSpecGroups.indexOf(group) + 1,
- getCategorySpecTemplate(activeCategory).has5g
- )
- : group.title;
+ const groupTitle = groupHeading(group);
return (
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..c1660fc69 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,8 +216,11 @@ 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*\)/
);
+ // 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\)/);
@@ -229,12 +232,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 +263,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 +291,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..95c705fd4 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',
@@ -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',
@@ -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 4aae9ad93301d2fbca3ddfb71392003512fdc636 Mon Sep 17 00:00:00 2001
From: JOY <5027251+JOY@users.noreply.github.com>
Date: Sat, 31 Oct 2026 12:24:06 +0700
Subject: [PATCH 3/3] fix(web): ratings show as stars with a short count,
without the extra words (#1534)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(web): ratings show as stars with a short count, without the extra words
One StarRating component (components/elementary) draws every on-screen
rating: five stars filled to the value, the value at one decimal (two where
close scores are ranked) and the count in parentheses with the locale's
thousands separator. Stars are green from 4.0, orange from 3.0, red below,
judged on the printed value (lib/rating-display.ts). Screen readers get one
label, "4.6 trên 5 sao, 11.204 đánh giá".
Used on the product scoreboard, phone hero line and peer cards, product
cards, compare, versus, leaderboard, deals, watchlist, search, inspect,
merchant pages and the reviews section. "/ 5.0", "bài kiểm chứng",
"chuẩn 10 nhóm", "(có trọng số)" and "(Bayes)" are gone; their meaning
stays in tooltips. The lab score reads on 10 everywhere it shows.
Score values do not change. Exports keep their fixed form.
Closes #1528
* fix(web): star messages reach client pages and compare exports keep their form
- starRating joins the shared client message scope, so client trees
(compare, leaderboard, reviews and the rest) get the stars' label and
empty text instead of the raw key.
- The compare CSV and JSON cells keep "Lab x / 5.0" and the old review
count text; only the on-screen cells show stars and the lab score on 10.
- The versus verdict drops "/5.0". Leaderboard battle cards and each
review's stars use StarRating. Criterion scores keep a "/5" marker next
to the lab score on 10, and a criterion score of 0 reads "chưa có".
- A tooltip's meaning is also a visually hidden sibling, since aria-label
wins over title on role="img".
* fix(web): a long star rating wraps inside narrow score boxes
Checked on the live product page with the component's markup and CSS: at
the large size, "4.6 (11.204)" ran past the half-width buyer score box.
The stars, value and count now wrap as units. The product page's
leaderboard badge drops its "/5.0", and the scale check also covers
formatDecimal calls whose locale variable is named otherwise.
* fix(web): the compare diff filter reads the lab score as the screen shows it
Codex round 2: with lab scores 4.51 and 4.54 the table showed "Lab 9.0/10"
and "Lab 9.1/10", but "only differences" compared the export text, which
is "Lab 4.5 / 5.0" for both, and hid the row. A row's screenValue now feeds
both the cell and the filter; renderValue stays the export text.
* fix(web): empty star ratings and thin-data scores meet contrast
axe flagged the empty state (muted ink is under 4.5:1 on #f1f5f9 cards)
and the buyer score faded to 50% for thin data. The empty state takes the
secondary ink and the score is no longer faded; the confidence badge
already says the data is thin. The dark surface check also measures the
stars' value, count and empty text, so /leaderboard still has text to
measure.
* Merge remote-tracking branch 'origin/dev' into fix/1528-star-ratings
Co-Authored-By: Claude Opus 5.5
---
apps/web/e2e/dark-hero-contrast.spec.ts | 3 +-
apps/web/e2e/inspect-copy.spec.ts | 2 +-
apps/web/e2e/product-above-fold.spec.ts | 2 +-
apps/web/e2e/star-rating.spec.ts | 57 +++++++
apps/web/locales/cs.json | 23 ++-
apps/web/locales/en.json | 23 ++-
apps/web/locales/vi.json | 23 ++-
apps/web/package.json | 2 +-
.../src/app/[locale]/merchants/[id]/page.tsx | 24 +--
apps/web/src/app/[locale]/page.tsx | 35 +++--
.../app/[locale]/smartphones/[slug]/page.tsx | 65 ++++----
apps/web/src/app/style/scss/_custom.scss | 88 +++++++++++
apps/web/src/app/style/scss/_theme.scss | 9 ++
.../components/compare/CompareWorkspace.tsx | 109 ++++++++------
.../compare/VersusInteractiveScoreboard.tsx | 23 +--
.../components/deals/DealFinderWorkspace.tsx | 13 +-
.../src/components/elementary/StarRating.tsx | 116 +++++++++++++++
.../components/home/HeroSearchInspector.tsx | 6 +-
.../inspect/DeepInspectWorkspace.tsx | 30 ++--
.../leaderboard/LeaderboardExplorer.tsx | 82 +++++------
.../leaderboard/ProductLeaderboardBadges.tsx | 2 +-
.../leaderboard/ProductValueCalculator.tsx | 11 +-
.../merchants/MerchantDirectoryExplorer.tsx | 17 +--
.../src/components/product/ProductCard.tsx | 19 +--
.../components/reviews/JReviewsSection.tsx | 125 +++++++---------
.../watchlist/WatchlistWorkspace.tsx | 12 +-
.../src/lib/__tests__/client-messages.test.ts | 2 +
.../__tests__/leaderboard-badge-copy.test.ts | 1 -
.../web/src/lib/__tests__/plural-copy.test.ts | 29 +---
.../src/lib/__tests__/rating-display.test.ts | 139 ++++++++++++++++++
apps/web/src/lib/client-messages.ts | 2 +
apps/web/src/lib/rating-display.ts | 59 ++++++++
.../__tests__/core-rate-architecture.test.ts | 2 +-
33 files changed, 780 insertions(+), 375 deletions(-)
create mode 100644 apps/web/e2e/star-rating.spec.ts
create mode 100644 apps/web/src/components/elementary/StarRating.tsx
create mode 100644 apps/web/src/lib/__tests__/rating-display.test.ts
create mode 100644 apps/web/src/lib/rating-display.ts
diff --git a/apps/web/e2e/dark-hero-contrast.spec.ts b/apps/web/e2e/dark-hero-contrast.spec.ts
index 4675fe03e..c96e88057 100644
--- a/apps/web/e2e/dark-hero-contrast.spec.ts
+++ b/apps/web/e2e/dark-hero-contrast.spec.ts
@@ -71,7 +71,8 @@ for (const path of PAGES) {
}
const bg = Math.max(...stops);
const texts = surface.querySelectorAll(
- '[class*="text-muted"], [class*="text-secondary"], [class*="text-success"], [class*="text-danger"], [class*="text-info"], [class*="text-warning"], [class*="text-primary"]'
+ // The star ratings' value and count too (DOS/Rate#1528).
+ '[class*="text-muted"], [class*="text-secondary"], [class*="text-success"], [class*="text-danger"], [class*="text-info"], [class*="text-warning"], [class*="text-primary"], .rate-stars-value, .rate-stars-count, .rate-stars.is-empty'
);
for (const el of texts) {
// Measured against its own nearest dark surface, once.
diff --git a/apps/web/e2e/inspect-copy.spec.ts b/apps/web/e2e/inspect-copy.spec.ts
index 25079ab8e..098c45ac6 100644
--- a/apps/web/e2e/inspect-copy.spec.ts
+++ b/apps/web/e2e/inspect-copy.spec.ts
@@ -69,7 +69,7 @@ for (const [prefix, lang] of [
`${prefix}/inspect?q=${encodeURIComponent('iPhone 16 Pro Max')}`
);
await expect(page.locator('html')).toHaveAttribute('lang', lang);
- await expect(page.locator('.rate-score-badge-user').first()).toBeVisible();
+ await expect(page.locator('.rate-stars').first()).toBeVisible();
await page.locator('.rate-export-menu summary').click();
await page.getByTestId('inspect-copy-md-btn').click();
const labels = inspectLabels(lang);
diff --git a/apps/web/e2e/product-above-fold.spec.ts b/apps/web/e2e/product-above-fold.spec.ts
index cddf57461..813cd555b 100644
--- a/apps/web/e2e/product-above-fold.spec.ts
+++ b/apps/web/e2e/product-above-fold.spec.ts
@@ -36,6 +36,6 @@ for (const viewport of [
test('the repeated hero score is hidden on desktop', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto(PRODUCT);
- await expect(page.locator('.rate-pdv-score-value').first()).toBeVisible();
+ await expect(page.getByTestId('pdv-user-score')).toBeVisible();
await expect(page.getByTestId('pdv-hero-score')).toBeHidden();
});
diff --git a/apps/web/e2e/star-rating.spec.ts b/apps/web/e2e/star-rating.spec.ts
new file mode 100644
index 000000000..01fd5f8f8
--- /dev/null
+++ b/apps/web/e2e/star-rating.spec.ts
@@ -0,0 +1,57 @@
+import { expect, test } from '@playwright/test';
+
+// Ratings show as stars with the value and a short count, with no verbose
+// words around them (DOS/Rate#1528).
+const PRODUCT = '/smartphones/iphone-16-pro-max-256gb';
+
+test('the product scoreboard shows the buyer score as stars with a count', async ({
+ page,
+}) => {
+ await page.setViewportSize({ width: 1280, height: 900 });
+ expect((await page.goto(PRODUCT))?.status()).toBe(200);
+
+ const score = page.getByTestId('pdv-user-score');
+ await expect(score).toBeVisible();
+ await expect(score).toHaveAttribute('role', 'img');
+ // "4.6 trên 5 sao, 6 đánh giá": one label for screen readers.
+ await expect(score).toHaveAttribute(
+ 'aria-label',
+ /^\d\.\d trên 5 sao, [\d.]+ đánh giá$/
+ );
+ await expect(score.locator('.rate-stars-value')).toHaveText(/^\d\.\d$/);
+ await expect(score.locator('.rate-stars-count')).toHaveText(/^\([\d.]+\)$/);
+ // The band colour follows the printed value.
+ const value = Number(await score.getAttribute('data-rating'));
+ const band = value >= 4 ? 'is-good' : value >= 3 ? 'is-fair' : 'is-poor';
+ await expect(score).toHaveClass(new RegExp(`\\b${band}\\b`));
+ // The fill is the share of five stars.
+ const fill = await score
+ .locator('.rate-stars-fill')
+ .evaluate((el) => (el as HTMLElement).style.width);
+ expect(Number.parseFloat(fill)).toBeCloseTo((value / 5) * 100, 5);
+
+ const text = await page.locator('main, body').first().innerText();
+ for (const phrase of [
+ 'bài kiểm chứng',
+ 'chuẩn 10 nhóm',
+ '(có trọng số)',
+ '/ 5.0',
+ '(Bayes)',
+ ]) {
+ expect(text, phrase).not.toContain(phrase);
+ }
+});
+
+test('product cards and the leaderboard use the same stars', async ({
+ page,
+}) => {
+ expect((await page.goto('/smartphones'))?.status()).toBe(200);
+ await expect(
+ page.locator('.rate-product-card .rate-stars').first()
+ ).toBeVisible();
+ expect((await page.goto('/leaderboard'))?.status()).toBe(200);
+ await expect(page.locator('.rate-stars[role="img"]').first()).toBeVisible();
+ const text = await page.locator('body').innerText();
+ expect(text).not.toContain('/5.0');
+ expect(text).not.toContain('/ 5.0');
+});
diff --git a/apps/web/locales/cs.json b/apps/web/locales/cs.json
index 932c8f20c..f3188c6e0 100644
--- a/apps/web/locales/cs.json
+++ b/apps/web/locales/cs.json
@@ -129,11 +129,11 @@
"versusMetaDescription": "Přímé porovnání {nameA} a {nameB}: hodnocení v 5 kritériích, 10 skupin parametrů a skutečné ceny v obchodech.",
"versusHeroBadge": "Souboj 1v1 • stejná kategorie ({category})",
"viewCategoryLeaderboard": "Zobrazit žebříček kategorie",
- "versusAutoVerdict": "Automatický verdikt: {winner} vede s celkovým skóre {score}/5,0{expertSuffix}.",
+ "versusAutoVerdict": "Automatický verdikt: {winner} vede s celkovým skóre {score}{expertSuffix}.",
"mdTieLine": "Remíza ({score}/5,0)",
"tieDeltaNote": "(stejné skóre při aktuálních vahách)",
"tie1v1Badge": "Remíza 1v1",
- "versusAutoVerdictTie": "Automatický verdikt: oba modely mají stejné celkové skóre ({score}/5,0); rozhodněte podle ceny a parametrů, které potřebujete.",
+ "versusAutoVerdictTie": "Automatický verdikt: oba modely mají stejné celkové skóre ({score}); rozhodněte podle ceny a parametrů, které potřebujete.",
"versusVerdictWeights": "(Váhy: {weights}.)",
"compareMetaTitleWithNames": "Porovnání {names} - Hodnocení a 10 skupin parametrů | Rate.vn",
"compareMetaTitleWithNamesShort": "{names} | Rate.vn",
@@ -290,7 +290,7 @@
"verdictSummaryHeading": "Shrnutí výhod mezi {count} porovnávanými {category}",
"viewAllCategoryBtn": "Zobrazit vše v {category} →",
"verdictHighestRatedTitle": "Nejvyšší ověřené hodnocení",
- "verdictAiFilteredNote": "(bez falešných recenzí)",
+ "verdictAiFilteredNote": "Bez falešných recenzí",
"verdictTopHardwareTitle": "Nejlepší hardwarová výbava",
"verdictNormalized10Groups": "Standardizováno 10 skupin",
"verdictBestBatteryTitle": "Výdrž baterie a energie",
@@ -402,7 +402,6 @@
"rank2Label": "2. místo",
"rank3Label": "3. místo",
"fullTableTitle": "Kompletní žebříček kategorie",
- "verifiedReviewsSuffix": "ověřených recenzí",
"blockedSpamCount": "{count, plural, one {Zablokována # falešná či spamová recenze} few {Zablokovány # falešné či spamové recenze} other {Zablokováno # falešných či spamových recenzí}}",
"storesTrackedSuffix": "obchodů",
"viewAuditDetailBtn": "Recenze a 10 skupin parametrů →",
@@ -437,7 +436,6 @@
"categoryTabsAria": "Vyberte kategorii",
"shortCamera": "Foto",
"shortAnc": "ANC",
- "userScoreShort": "Uživatelé {score}/5,0",
"expertScoreShort": "Expert {score}/10"
},
"reviews": {
@@ -507,7 +505,6 @@
"authenticUserScoreTitle": "Skutečné hodnocení kupujících",
"basedOnAuthenticReviews": "Vypočteno z {count, plural, one {# skutečné recenze} other {# skutečných recenzí}} (Σw = {weightSum} · Vážený průměr: {avgScore}).",
"bayesianConfidenceTooltip": "Bayesovská spolehlivost c = Σw / (Σw + 3) = {pct} %",
- "validCountBadge": "✓ {count, plural, one {# platná} few {# platné} other {# platných}}",
"blockedSeedingBadge": "{count, plural, one {Odfiltrována # falešná nebo spamová recenze} few {Odfiltrovány # falešné nebo spamové recenze} other {Odfiltrováno # falešných nebo spamových recenzí}}",
"expertLabScoreTitle": "Expertní skóre Rate Lab",
"expertPendingShort": "nehodnoceno",
@@ -1019,13 +1016,11 @@
"scoreboardEyebrow": "Hodnocení bez falešných recenzí",
"authenticReviewsBadge": "{count, plural, one {# autentická recenze} few {# autentické recenze} other {# autentických recenzí}}",
"filteredSeedingBadge": "{count, plural, one {Odfiltrována # falešná nebo spamová recenze} few {Odfiltrovány # falešné nebo spamové recenze} other {Odfiltrováno # falešných nebo spamových recenzí}}",
- "userWeightedScoreLabel": "Skóre skutečných kupujících (vážené)",
+ "userWeightedScoreLabel": "Skóre kupujících",
+ "userScoreHint": "Vážený průměr bez falešných a spamových recenzí",
"notAvailableYet": "Není",
- "verifiedReviewsCount": "/ 5,0 ({count, plural, one {# ověřená recenze} few {# ověřené recenze} other {# ověřených recenzí}})",
- "verifiedReviewsCountNoScore": "({count, plural, one {# ověřená recenze} few {# ověřené recenze} other {# ověřených recenzí}})",
"expertScoreLabel": "Expertní skóre Rate Lab",
- "tenGroupStandardWithSlash": "/ 5,0 dle 10 skupin",
- "tenGroupStandard": "standard 10 skupin",
+ "expertScoreHint": "Z 10, hodnoceno podle 10 skupin parametrů",
"critSoundstage": "Zvukový projev a kvalita",
"critDisplay": "Displej a zobrazení",
"critPerformance": "Výkon a teploty",
@@ -1925,8 +1920,12 @@
"okOtpSent": "6místný OTP kód byl odeslán na váš e-mail.",
"okOtpVerified": "OTP kód byl úspěšně ověřen! Přesměrování..."
},
+ "starRating": {
+ "aria": "{value} z 5 hvězdiček",
+ "ariaWithCount": "{value} z 5 hvězdiček, {countText} hodnocení",
+ "empty": "Zatím bez hodnocení"
+ },
"productCard": {
- "reviewCount": "{count, plural, one {# recenze} few {# recenze} other {# recenzí}}",
"noReviews": "Zatím bez recenzí",
"expertScore": "Lab {score}",
"refPrice": "Orientační cena",
diff --git a/apps/web/locales/en.json b/apps/web/locales/en.json
index 2f64d7bdc..e290e6683 100644
--- a/apps/web/locales/en.json
+++ b/apps/web/locales/en.json
@@ -129,11 +129,11 @@
"versusMetaDescription": "Compare {nameA} and {nameB} head to head: review scores across 5 criteria, 10 spec groups and real store prices.",
"versusHeroBadge": "1v1 comparison • same category ({category})",
"viewCategoryLeaderboard": "View category leaderboard",
- "versusAutoVerdict": "Automatic verdict: {winner} leads with an overall score of {score}/5.0{expertSuffix}.",
+ "versusAutoVerdict": "Automatic verdict: {winner} leads with an overall score of {score}{expertSuffix}.",
"mdTieLine": "Tie ({score}/5.0)",
"tieDeltaNote": "(same score with the current weights)",
"tie1v1Badge": "1v1 Tie",
- "versusAutoVerdictTie": "Automatic verdict: the two models are level on overall score ({score}/5.0); choose on price and the specs you need.",
+ "versusAutoVerdictTie": "Automatic verdict: the two models are level on overall score ({score}); choose on price and the specs you need.",
"versusVerdictWeights": "(Weights: {weights}.)",
"compareMetaTitleWithNames": "Compare {names} - Ratings and 10 spec groups | Rate.vn",
"compareMetaTitleWithNamesShort": "{names} | Rate.vn",
@@ -290,7 +290,7 @@
"verdictSummaryHeading": "Advantages across {count} compared {category}",
"viewAllCategoryBtn": "View all {category} →",
"verdictHighestRatedTitle": "Highest genuine buyer rating",
- "verdictAiFilteredNote": "(fake reviews filtered)",
+ "verdictAiFilteredNote": "Fake reviews filtered out",
"verdictTopHardwareTitle": "Leading hardware",
"verdictNormalized10Groups": "Standardized across 10 groups",
"verdictBestBatteryTitle": "Battery and endurance",
@@ -402,7 +402,6 @@
"rank2Label": "Runner-Up #2",
"rank3Label": "Top #3",
"fullTableTitle": "Complete category leaderboard",
- "verifiedReviewsSuffix": "authentic reviews",
"blockedSpamCount": "{count, plural, one {# fake/spam review blocked} other {# fake/spam reviews blocked}}",
"storesTrackedSuffix": "stores compared",
"viewAuditDetailBtn": "Reviews & 10 spec groups →",
@@ -437,7 +436,6 @@
"categoryTabsAria": "Choose a category",
"shortCamera": "Camera",
"shortAnc": "ANC",
- "userScoreShort": "User {score}/5.0",
"expertScoreShort": "Expert {score}/10"
},
"reviews": {
@@ -507,7 +505,6 @@
"authenticUserScoreTitle": "Genuine buyer rating",
"basedOnAuthenticReviews": "Computed across {count, plural, one {# authentic review} other {# authentic reviews}} (Σw = {weightSum} · Weighted avg: {avgScore}).",
"bayesianConfidenceTooltip": "Bayesian confidence c = Σw / (Σw + 3) = {pct}%",
- "validCountBadge": "✓ {count} Valid",
"blockedSeedingBadge": "{count, plural, one {Filtered # fake or spam review} other {Filtered # fake or spam reviews}}",
"expertLabScoreTitle": "Rate Lab expert score",
"expertPendingShort": "pending",
@@ -1019,13 +1016,11 @@
"scoreboardEyebrow": "Scores with fake reviews filtered out",
"authenticReviewsBadge": "{count, plural, one {# authentic buyer review} other {# authentic buyer reviews}}",
"filteredSeedingBadge": "{count, plural, one {Filtered # fake or spam review} other {Filtered # fake or spam reviews}}",
- "userWeightedScoreLabel": "Genuine buyer score (weighted)",
+ "userWeightedScoreLabel": "Buyer score",
+ "userScoreHint": "Weighted average, with fake and spam reviews filtered out",
"notAvailableYet": "N/A",
- "verifiedReviewsCount": "/ 5.0 ({count, plural, one {# verified review} other {# verified reviews}})",
- "verifiedReviewsCountNoScore": "({count, plural, one {# verified review} other {# verified reviews}})",
"expertScoreLabel": "Rate Lab expert score",
- "tenGroupStandardWithSlash": "/ 5.0 across 10 groups",
- "tenGroupStandard": "10-group standard",
+ "expertScoreHint": "Out of 10, scored across 10 spec groups",
"critSoundstage": "Soundstage & audio quality",
"critDisplay": "Display & visuals",
"critPerformance": "Performance & thermals",
@@ -1925,8 +1920,12 @@
"okOtpSent": "A 6-digit OTP code has been sent to your email. Enter it below to verify.",
"okOtpVerified": "OTP verified successfully! Redirecting..."
},
+ "starRating": {
+ "aria": "{value} out of 5 stars",
+ "ariaWithCount": "{value} out of 5 stars, {countText} {count, plural, one {review} other {reviews}}",
+ "empty": "No reviews yet"
+ },
"productCard": {
- "reviewCount": "{count, plural, one {# review} other {# reviews}}",
"noReviews": "No reviews yet",
"expertScore": "Lab {score}",
"refPrice": "Reference price",
diff --git a/apps/web/locales/vi.json b/apps/web/locales/vi.json
index 12be947d1..fec693473 100644
--- a/apps/web/locales/vi.json
+++ b/apps/web/locales/vi.json
@@ -129,11 +129,11 @@
"versusMetaDescription": "So sánh trực tiếp {nameA} và {nameB}: điểm đánh giá theo 5 tiêu chí, 10 nhóm thông số kỹ thuật và giá thật trên các sàn.",
"versusHeroBadge": "So sánh 1v1 • cùng danh mục ({category})",
"viewCategoryLeaderboard": "Xem bảng xếp hạng cùng ngành",
- "versusAutoVerdict": "Kết luận tự động: {winner} dẫn trước với điểm tổng hợp {score}/5.0{expertSuffix}.",
+ "versusAutoVerdict": "Kết luận tự động: {winner} dẫn trước với điểm tổng hợp {score}{expertSuffix}.",
"mdTieLine": "Ngang điểm ({score}/5.0)",
"tieDeltaNote": "(cùng điểm theo trọng số hiện tại)",
"tie1v1Badge": "Ngang điểm 1v1",
- "versusAutoVerdictTie": "Kết luận tự động: hai mẫu ngang điểm tổng hợp ({score}/5.0), chọn theo giá và thông số bạn cần.",
+ "versusAutoVerdictTie": "Kết luận tự động: hai mẫu ngang điểm tổng hợp ({score}), chọn theo giá và thông số bạn cần.",
"versusVerdictWeights": "(Theo trọng số: {weights}.)",
"compareMetaTitleWithNames": "So sánh {names} - Điểm đánh giá và 10 nhóm thông số | Rate.vn",
"compareMetaTitleWithNamesShort": "{names} | Rate.vn",
@@ -290,7 +290,7 @@
"verdictSummaryHeading": "Tóm tắt ưu thế giữa {count} {category} đang so sánh",
"viewAllCategoryBtn": "Xem toàn bộ {category} →",
"verdictHighestRatedTitle": "Điểm đánh giá thực chất cao nhất",
- "verdictAiFilteredNote": "(đã lọc đánh giá ảo)",
+ "verdictAiFilteredNote": "Đã lọc đánh giá ảo",
"verdictTopHardwareTitle": "Cấu hình phần cứng nổi bật",
"verdictNormalized10Groups": "Chuẩn hóa 10 nhóm",
"verdictBestBatteryTitle": "Thời lượng pin và năng lượng",
@@ -402,7 +402,6 @@
"rank2Label": "Á quân #2",
"rank3Label": "Hạng #3",
"fullTableTitle": "Bảng xếp hạng toàn diện theo ngành hàng",
- "verifiedReviewsSuffix": "đánh giá thực chất",
"blockedSpamCount": "Đã chặn {count} đánh giá ảo/spam",
"storesTrackedSuffix": "nơi bán đối chiếu",
"viewAuditDetailBtn": "Xem đánh giá & 10 nhóm thông số →",
@@ -437,7 +436,6 @@
"categoryTabsAria": "Chọn ngành hàng",
"shortCamera": "Camera",
"shortAnc": "Chống ồn",
- "userScoreShort": "Người mua {score}/5.0",
"expertScoreShort": "Chuyên gia {score}/10"
},
"reviews": {
@@ -507,7 +505,6 @@
"authenticUserScoreTitle": "Điểm người mua thực chất",
"basedOnAuthenticReviews": "Tính trên {count} đánh giá thực chất (Σw = {weightSum} · TB trọng số: {avgScore}).",
"bayesianConfidenceTooltip": "Độ tin cậy Bayes c = Σw / (Σw + 3) = {pct}%",
- "validCountBadge": "✓ {count} Hợp lệ",
"blockedSeedingBadge": "Đã lọc {count} đánh giá ảo hoặc spam",
"expertLabScoreTitle": "Điểm chuyên gia Rate Lab",
"expertPendingShort": "chưa có",
@@ -1019,13 +1016,11 @@
"scoreboardEyebrow": "Điểm số đã lọc đánh giá ảo",
"authenticReviewsBadge": "{count} đánh giá thực chất",
"filteredSeedingBadge": "Đã lọc {count} đánh giá ảo hoặc spam",
- "userWeightedScoreLabel": "Điểm người mua thực chất (có trọng số)",
+ "userWeightedScoreLabel": "Điểm người mua",
+ "userScoreHint": "Trung bình có trọng số, đã lọc đánh giá ảo và spam",
"notAvailableYet": "Chưa có",
- "verifiedReviewsCount": "/ 5.0 ({count} bài kiểm chứng)",
- "verifiedReviewsCountNoScore": "({count} bài kiểm chứng)",
"expertScoreLabel": "Điểm chuyên gia Rate Lab",
- "tenGroupStandardWithSlash": "/ 5.0 chuẩn 10 nhóm",
- "tenGroupStandard": "chuẩn 10 nhóm",
+ "expertScoreHint": "Thang 10, chấm theo 10 nhóm thông số",
"critSoundstage": "Âm trường & chất âm",
"critDisplay": "Màn hình & hiển thị",
"critPerformance": "Hiệu năng & nhiệt độ",
@@ -1925,8 +1920,12 @@
"okOtpSent": "Đã gửi mã OTP 6 chữ số tới email của bạn. Vui lòng nhập mã bên dưới để hoàn tất xác thực.",
"okOtpVerified": "Xác thực OTP thành công! Đang chuyển hướng về trang chủ..."
},
+ "starRating": {
+ "aria": "{value} trên 5 sao",
+ "ariaWithCount": "{value} trên 5 sao, {countText} đánh giá",
+ "empty": "Chưa có đánh giá"
+ },
"productCard": {
- "reviewCount": "{count, number} đánh giá",
"noReviews": "Chưa có đánh giá",
"expertScore": "Lab {score}",
"refPrice": "Giá tham khảo",
diff --git a/apps/web/package.json b/apps/web/package.json
index bf306d0fb..078da370f 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -8,7 +8,7 @@
"lint": "eslint . --max-warnings 0",
"preview": "next build && next start",
"start": "next start",
- "test": "pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/core-rate-architecture.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/item-model.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/item-directory.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/write-paths.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/spec-scope.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/item-fields.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/logger.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/review-display.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/concurrency.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/merchant-profile-reads.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/merchant-review-risk.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/item-directory.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/watchlist-sizes.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/item-compare.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-variant-links.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/compare-params.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/catalog-listing.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/discount-verdict-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-detail-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-competitors.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/analyzer-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sold-count.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/content-lang.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/image-url.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/client-messages.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/seo.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/offer-plausibility.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-market-stats.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sitemap-lastmod.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/leaderboard-badge-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/focus-trap.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/display-truth.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/spec-template.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-spec-rows.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sale-state.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/csp.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/device-images.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/render-timing.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/model-match.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/developers-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/plural-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/locale-format.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/paged-reads.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/server-timing.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/hero-products.test.ts",
+ "test": "pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/core-rate-architecture.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/item-model.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/item-directory.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/write-paths.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/spec-scope.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/item-fields.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/logger.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/review-display.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/concurrency.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/merchant-profile-reads.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/merchant-review-risk.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/item-directory.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/watchlist-sizes.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/item-compare.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-variant-links.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/compare-params.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/catalog-listing.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/discount-verdict-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-detail-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-competitors.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/analyzer-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sold-count.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/content-lang.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/image-url.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/client-messages.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/seo.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/offer-plausibility.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-market-stats.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sitemap-lastmod.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/leaderboard-badge-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/rating-display.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/focus-trap.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/display-truth.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/spec-template.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-spec-rows.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sale-state.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/csp.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/device-images.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/render-timing.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/model-match.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/developers-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/plural-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/locale-format.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/paged-reads.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/server-timing.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/hero-products.test.ts",
"typecheck": "tsc --noEmit"
},
"browserslist": [
diff --git a/apps/web/src/app/[locale]/merchants/[id]/page.tsx b/apps/web/src/app/[locale]/merchants/[id]/page.tsx
index bd65d7870..ae7aa86f2 100644
--- a/apps/web/src/app/[locale]/merchants/[id]/page.tsx
+++ b/apps/web/src/app/[locale]/merchants/[id]/page.tsx
@@ -32,7 +32,6 @@ import {
BsPeopleFill,
BsShieldCheck,
BsShop,
- BsStarFill,
} from 'react-icons/bs';
import { isPendingReview } from '@/lib/review-display';
import {
@@ -53,6 +52,7 @@ import {
formatPlainNumber,
formatPriceVnd,
} from '@/lib/locale-format';
+import StarRating from '@/components/elementary/StarRating';
// Cached after the first request, refreshed every 5 minutes or when the
// worker revalidates its cache tags (DOS/Rate#491).
@@ -360,13 +360,12 @@ export default async function MerchantProfilePage({
{/* Items wrap: "Hodnocení tržiště: Neaktualizováno" is wider
than a 320px header column (DOS/Rate#1084). */}
-
{t('platformRatingLabel')}{' '}
-
- {merchant.rating_star
- ? `${formatDecimal(merchant.rating_star, locale, 2)} / ${formatDecimal(5, locale, 1)}`
- : t('notUpdatedYet')}
-
+
@@ -866,9 +865,7 @@ export default async function MerchantProfilePage({
)}
-
- ★ {rev.rating_star}/5
-
+
)}
- ★ {formatDecimal(item.rating_star, locale, 1)}{' '}
- ({item.rating_count})
+
diff --git a/apps/web/src/app/[locale]/page.tsx b/apps/web/src/app/[locale]/page.tsx
index 029731400..ce5e903d2 100644
--- a/apps/web/src/app/[locale]/page.tsx
+++ b/apps/web/src/app/[locale]/page.tsx
@@ -74,6 +74,7 @@ import {
formatPlainNumber,
formatPriceVnd,
} from '@/lib/locale-format';
+import StarRating from '@/components/elementary/StarRating';
// Cached after the first request, refreshed every 5 minutes or when the
// worker revalidates its cache tags (DOS/Rate#491).
@@ -655,12 +656,11 @@ export default async function Home({
{battle.productA.name}
- ★{' '}
- {formatDecimal(
- battle.productA.combinedScore5,
- locale,
- 2
- )}
+
@@ -671,12 +671,11 @@ export default async function Home({
{battle.productB.name}
- ★{' '}
- {formatDecimal(
- battle.productB.combinedScore5,
- locale,
- 2
- )}
+
@@ -1043,9 +1042,15 @@ export default async function Home({
{tHome('ratingsAndProductsLabel')}
- {merchant.rating_star != null
- ? `★ ${formatDecimal(merchant.rating_star, locale, 1)} · `
- : ''}
+ {merchant.rating_star != null && (
+ <>
+
+ {' · '}
+ >
+ )}
{merchant.product_count}{' '}
{tHome('productsShortUnit')}
diff --git a/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx b/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx
index c7d1d2f61..7ee4e5a3f 100644
--- a/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx
+++ b/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx
@@ -55,7 +55,6 @@ import {
BsPhone,
BsShieldCheck,
BsShieldExclamation,
- BsStarFill,
} from 'react-icons/bs';
import RadarBadge from '@/components/radar/RadarBadge';
import { getRadarSignal, type RadarIndex } from '@/lib/deal-radar';
@@ -132,6 +131,7 @@ import {
formatPriceVnd,
} from '@/lib/locale-format';
import { versusHref } from '@/lib/item-compare';
+import StarRating from '@/components/elementary/StarRating';
// Cached after the first request, refreshed every 5 minutes or when the
// worker revalidates its cache tags (DOS/Rate#491).
@@ -1013,16 +1013,14 @@ export async function ProductDetailView({
className="rate-pdv-hero-score d-flex d-lg-none align-items-center mb-3 gap-1"
data-testid="pdv-hero-score"
>
-
{t('userWeightedScoreLabel')}:
-
- {formatDecimal(displayUserRating, locale, 1)}
-
-
- {t('verifiedReviewsCount', { count: authenticCount })}
-
+
)}
@@ -1093,25 +1091,15 @@ export async function ProductDetailView({
{t('userWeightedScoreLabel')}
- {displayUserRating > 0 ? (
-
-
- {formatDecimal(displayUserRating, locale, 1)}
-
-
- {t('verifiedReviewsCount', {
- count: authenticCount,
- })}
-
-
- ) : (
-
- {t('notAvailableYet')} ·{' '}
- {t('verifiedReviewsCountNoScore', {
- count: authenticCount,
- })}
-
- )}
+
+ 0 ? authenticCount : null}
+ size="lg"
+ title={t('userScoreHint')}
+ testId="pdv-user-score"
+ />
+
{expertRating10 != null ? (
-
+
{formatDecimal(expertRating10, locale, 1)}
-
- {t('tenGroupStandardWithSlash')}
+ /10
+
+ {t('expertScoreHint')}
) : (
- {t('notAvailableYet')} · {t('tenGroupStandard')}
+ {t('notAvailableYet')}
)}
@@ -1632,12 +1624,11 @@ export async function ProductDetailView({
{peer.brand}
-
- ★{' '}
- {peerRating > 0
- ? formatDecimal(peerRating, locale, 1)
- : t('peerNoRating')}
-
+
.input-group-text {
}
}
}
+
+// One rating display (DOS/Rate#1528, components/elementary/StarRating.tsx):
+// five stars filled to the value, the value and the count. The stars take the
+// band colour; the value and count keep the text colour.
+.rate-stars {
+ display: inline-flex;
+ flex-wrap: wrap;
+ align-items: center;
+ max-width: 100%;
+ gap: 0.15em 0.35em;
+ white-space: nowrap;
+ font-variant-numeric: tabular-nums;
+ line-height: 1.2;
+ vertical-align: middle;
+
+ &.is-good {
+ --rate-star-on: var(--rate-star-good);
+ }
+ &.is-fair {
+ --rate-star-on: var(--rate-star-fair);
+ }
+ &.is-poor {
+ --rate-star-on: var(--rate-star-poor);
+ }
+ &.is-empty {
+ // Secondary, not muted: muted is under 4.5:1 on #f1f5f9 cards.
+ color: var(--rate-fg-secondary);
+ font-weight: 400;
+ }
+}
+
+.rate-stars-icons {
+ position: relative;
+ display: inline-flex;
+ flex: none;
+
+ svg {
+ flex: none;
+ width: 1em;
+ height: 1em;
+ }
+}
+
+.rate-stars-track {
+ display: inline-flex;
+ color: var(--rate-star-empty);
+}
+
+.rate-stars-fill {
+ position: absolute;
+ inset: 0 auto 0 0;
+ display: inline-flex;
+ overflow: hidden;
+ color: var(--rate-star-on);
+}
+
+.rate-stars-value {
+ font-weight: 700;
+ color: var(--rate-fg-primary);
+}
+
+.rate-stars-count {
+ color: var(--rate-fg-secondary);
+ font-weight: 400;
+}
+
+.rate-stars-sm {
+ font-size: var(--rate-text-min);
+}
+.rate-stars-md {
+ font-size: 1rem;
+}
+.rate-stars-lg {
+ font-size: 1.5rem;
+
+ .rate-stars-count {
+ font-size: 1rem;
+ }
+}
diff --git a/apps/web/src/app/style/scss/_theme.scss b/apps/web/src/app/style/scss/_theme.scss
index 526a32b16..2cb344337 100644
--- a/apps/web/src/app/style/scss/_theme.scss
+++ b/apps/web/src/app/style/scss/_theme.scss
@@ -27,6 +27,10 @@
--rate-score-user-bg: rgba(45, 186, 105, 0.14);
--rate-score-expert-bg: rgba(99, 102, 241, 0.14);
--rate-score-star-bg: rgba(245, 158, 11, 0.14);
+ --rate-star-good: #4ade80;
+ --rate-star-fair: #fb923c;
+ --rate-star-poor: #f87171;
+ --rate-star-empty: rgba(255, 255, 255, 0.2);
--rate-risk-danger-bg: rgba(244, 63, 94, 0.14);
--rate-status-good-fg: #4ade80;
--rate-status-good-bg: rgba(34, 197, 94, 0.14);
@@ -371,6 +375,11 @@
--rate-score-user-bg: #ecfdf5;
--rate-score-expert-bg: #eef2ff;
--rate-score-star-bg: #fffbeb;
+ // Rating stars by band (DOS/Rate#1528): at least 3:1 on white and #f1f5f9.
+ --rate-star-good: #15803d;
+ --rate-star-fair: #c2410c;
+ --rate-star-poor: #dc2626;
+ --rate-star-empty: #cbd5e1;
--rate-risk-danger-bg: #fff1f2;
--rate-crimson-text: #d83030;
--rate-focus-ring: #d83030;
diff --git a/apps/web/src/components/compare/CompareWorkspace.tsx b/apps/web/src/components/compare/CompareWorkspace.tsx
index 39f52b985..b933db5ca 100644
--- a/apps/web/src/components/compare/CompareWorkspace.tsx
+++ b/apps/web/src/components/compare/CompareWorkspace.tsx
@@ -41,6 +41,7 @@ import {
resolveCompareDisplaySizeInch,
resolveCompareEffectiveUserRating,
resolveCompareExpertRating5,
+ resolveCompareExpertRating10,
resolveCompareMemoryScore,
resolveCompareRamGb,
resolveCompareStorageGb,
@@ -84,6 +85,7 @@ import {
productFromProductAnswer,
versusHref,
} from '@/lib/item-compare';
+import StarRating from '@/components/elementary/StarRating';
import {
formatDecimal,
formatNumber,
@@ -114,6 +116,8 @@ interface SpecRowDef {
renderValue: (p: MasterProduct) => string;
/** The cell with its spec values marked (specPart), for the matrix. */
renderCell?: (p: MasterProduct) => string;
+ /** A screen-only cell text that differs from the export (DOS/Rate#1528). */
+ screenValue?: (p: MasterProduct) => string;
numericValue?: (p: MasterProduct) => number | null;
higherIsBetter?: boolean;
/** Whether the category's spec template has this row (DOS/Rate#1166). */
@@ -322,6 +326,13 @@ export function buildSpecGroups(
? `Lab ${formatDecimal(exp5, locale, 1)} / ${formatDecimal(5, locale, 1)}`
: '-';
},
+ // On screen the lab score reads on 10, as the product page shows it.
+ screenValue: (p) => {
+ const exp10 = resolveCompareExpertRating10(p);
+ return exp10 != null
+ ? `Lab ${formatDecimal(exp10, locale, 1)}/10`
+ : '-';
+ },
numericValue: (p) => resolveCompareExpertRating5(p),
higherIsBetter: true,
},
@@ -1045,7 +1056,10 @@ export default function CompareWorkspace({
// The text the cells show, where "N/A" becomes "-", localized
// part by part as the cells do (DOS/Rate#1392).
products.map((p) =>
- compareCellShownText((row.renderCell ?? row.renderValue)(p), locale)
+ compareCellShownText(
+ (row.screenValue ?? row.renderCell ?? row.renderValue)(p),
+ locale
+ )
),
showDiffOnly
)
@@ -1809,12 +1823,11 @@ export default function CompareWorkspace({
{item.brand}
-
- ★{' '}
- {rating > 0
- ? formatDecimal(rating, locale, 1)
- : t('unratedShort')}
-
+
{battle.productA.name}
-
- ★{' '}
- {formatDecimal(
- battle.productA.combinedScore5,
- locale,
- 2
- )}
-
+
@@ -2072,14 +2082,11 @@ export default function CompareWorkspace({
{battle.productB.name}
-
- ★{' '}
- {formatDecimal(
- battle.productB.combinedScore5,
- locale,
- 2
- )}
-
+
{headToHeadSummary.bestRated.name}
-
- ★{' '}
- {formatDecimal(
- getEffectiveRating(headToHeadSummary.bestRated),
- locale,
- 1
- )}{' '}
- / {formatDecimal(5, locale, 1)} {t('verdictAiFilteredNote')}
-
+
)}
@@ -2358,7 +2361,7 @@ export default function CompareWorkspace({
const minPrice = Number(p.min_price || 0);
const minPriceFormatted = formatNumber(minPrice, locale);
const userRating = getEffectiveRating(p);
- const expertRating = resolveCompareExpertRating5(p);
+ const expertRating = resolveCompareExpertRating10(p);
const reviewCount =
p.authentic_review_count || p.total_reviews || 0;
@@ -2427,15 +2430,15 @@ export default function CompareWorkspace({
{/* Core Rating Badges First (Per DESIGN.md v3.0) */}
-
- ★{' '}
- {userRating > 0
- ? `${formatDecimal(userRating, locale, 1)} (${reviewCount})`
- : t('unratedShort')}
-
+
{expertRating != null && (
- Lab {formatDecimal(expertRating, locale, 1)}
+ Lab {formatDecimal(expertRating, locale, 1)}/10
)}
@@ -2627,10 +2630,28 @@ export default function CompareWorkspace({
: 'text-dark'
}`}
>
-
+ {/* Screen-only cells: the stars, and the
+ lab score on 10 as the product page shows
+ it; exports keep renderValue (#1528). */}
+ {row.screenValue ? (
+ row.screenValue(p)
+ ) : row.id === 'rating_weighted' ? (
+
+ ) : (
+
+ )}
{isWinner && (
- {scoreA.brand}: {formatDecimal(weightedResult.finalA, locale, 2)}/
- {formatDecimal(5, locale, 1)} • {scoreB.brand}:{' '}
- {formatDecimal(weightedResult.finalB, locale, 2)}/
- {formatDecimal(5, locale, 1)}
+ {scoreA.brand}: {formatDecimal(weightedResult.finalA, locale, 2)} •{' '}
+ {scoreB.brand}: {formatDecimal(weightedResult.finalB, locale, 2)}
@@ -659,10 +658,7 @@ export default function VersusInteractiveScoreboard({
{scoreA.name}
-
- ★ {formatDecimal(weightedResult.finalA, locale, 2)}/
- {formatDecimal(5, locale, 1)}
-
+
{scoreA.expertScore10 != null
? `Lab ${formatDecimal(scoreA.expertScore10, locale, 1)}/10`
@@ -683,10 +679,7 @@ export default function VersusInteractiveScoreboard({
{scoreB.name}
-
- ★ {formatDecimal(weightedResult.finalB, locale, 2)}/
- {formatDecimal(5, locale, 1)}
-
+
{scoreB.expertScore10 != null
? `Lab ${formatDecimal(scoreB.expertScore10, locale, 1)}/10`
@@ -714,8 +707,7 @@ export default function VersusInteractiveScoreboard({
aWins ? 'text-success' : 'text-white-50'
}`}
>
- {formatDecimal(row.aVal, locale, 2)}/
- {formatDecimal(5, locale, 1)} {aWins ? '✓' : ''}
+ {formatDecimal(row.aVal, locale, 2)}/5 {aWins ? '✓' : ''}
{bWins ? '✓ ' : ''}
- {formatDecimal(row.bVal, locale, 2)}/
- {formatDecimal(5, locale, 1)}
+ {formatDecimal(row.bVal, locale, 2)}/5
diff --git a/apps/web/src/components/deals/DealFinderWorkspace.tsx b/apps/web/src/components/deals/DealFinderWorkspace.tsx
index 91fa17bcb..fe486a201 100644
--- a/apps/web/src/components/deals/DealFinderWorkspace.tsx
+++ b/apps/web/src/components/deals/DealFinderWorkspace.tsx
@@ -39,6 +39,7 @@ import {
formatPlainNumber,
formatPriceVnd,
} from '@/lib/locale-format';
+import StarRating from '@/components/elementary/StarRating';
interface DealFinderWorkspaceProps {
readonly products: MasterProduct[];
@@ -891,12 +892,12 @@ export default function DealFinderWorkspace({
{t('rankDealBadge', { rank: deal.rank })}
{deal.brand}
-
- ★{' '}
- {deal.personaScore5 > 0
- ? `${formatDecimal(deal.personaScore5, locale, 2)}/${formatDecimal(5, locale, 1)}`
- : t('noScore')}
-
+
{deal.expertScore10 != null
? `Lab ${formatDecimal(deal.expertScore10, locale, 1)}/10`
diff --git a/apps/web/src/components/elementary/StarRating.tsx b/apps/web/src/components/elementary/StarRating.tsx
new file mode 100644
index 000000000..6df141924
--- /dev/null
+++ b/apps/web/src/components/elementary/StarRating.tsx
@@ -0,0 +1,116 @@
+import { useLocale, useTranslations } from 'next-intl';
+import { BsStarFill } from 'react-icons/bs';
+import {
+ displayRating,
+ formatRatingCount,
+ formatRatingValue,
+ ratingFillPercent,
+ ratingTone,
+} from '@/lib/rating-display';
+
+export interface StarRatingProps {
+ /** A 0-5 rating; 0, null or anything not finite shows the empty state. */
+ readonly value: number | null | undefined;
+ /** Review count, shown in parentheses. Omitted when null or undefined. */
+ readonly count?: number | null;
+ /** Decimals of the printed value: 1, or 2 where close scores are ranked. */
+ readonly digits?: 1 | 2;
+ readonly size?: 'sm' | 'md' | 'lg';
+ /** Short text when there is no rating; "Chưa có đánh giá" by default. */
+ readonly emptyLabel?: string;
+ /** Tooltip that keeps the longer meaning (how the score is computed). */
+ readonly title?: string;
+ readonly className?: string;
+ readonly testId?: string;
+}
+
+const STARS = [0, 1, 2, 3, 4];
+
+/**
+ * The one way a rating is shown (DOS/Rate#1528): five stars filled to the
+ * value, the value at one decimal and the count in parentheses, e.g.
+ * `★★★★½ 4.6 (11.204)`. The stars are green from 4.0, orange from 3.0 and
+ * red below (`ratingTone`). Screen readers get one label, "4.6 trên 5 sao,
+ * 11.204 đánh giá".
+ */
+export default function StarRating({
+ value,
+ count,
+ digits = 1,
+ size = 'md',
+ emptyLabel,
+ title,
+ className,
+ testId,
+}: StarRatingProps) {
+ const t = useTranslations('starRating');
+ const locale = useLocale();
+ const rating = displayRating(value);
+ const classes = ['rate-stars', `rate-stars-${size}`, className]
+ .filter(Boolean)
+ .join(' ');
+
+ if (rating == null) {
+ return (
+
+ {emptyLabel ?? t('empty')}
+ {title && . {title} }
+
+ );
+ }
+
+ const valueText = formatRatingValue(rating, locale, digits);
+ const hasCount = typeof count === 'number' && Number.isFinite(count);
+ const countText = hasCount ? formatRatingCount(count, locale) : '';
+ const label = hasCount
+ ? t('ariaWithCount', { value: valueText, count, countText })
+ : t('aria', { value: valueText });
+
+ const stars = (
+
+
+
+ {STARS.map((i) => (
+
+ ))}
+
+
+ {STARS.map((i) => (
+
+ ))}
+
+
+
+ {valueText}
+
+ {hasCount && (
+
+ ({countText})
+
+ )}
+
+ );
+ if (!title) return stars;
+ // aria-label wins over title on role="img", so screen readers get the
+ // tooltip's meaning from a hidden sibling.
+ return (
+ <>
+ {stars}
+ {title}
+ >
+ );
+}
diff --git a/apps/web/src/components/home/HeroSearchInspector.tsx b/apps/web/src/components/home/HeroSearchInspector.tsx
index 7ce74f9d5..73f26c62f 100644
--- a/apps/web/src/components/home/HeroSearchInspector.tsx
+++ b/apps/web/src/components/home/HeroSearchInspector.tsx
@@ -41,11 +41,11 @@ import {
import ContentText from '@/components/elementary/ContentText';
import { firstImageUrl, productImageUrl } from '@/lib/rate-domain-core';
import {
- formatDecimal,
formatNumber,
formatPlainNumber,
formatPriceVnd,
} from '@/lib/locale-format';
+import StarRating from '@/components/elementary/StarRating';
interface HeroSearchInspectorProps {
initialProducts: HeroProductSuggestion[];
@@ -521,9 +521,7 @@ export default function HeroSearchInspector({
)}
{rating > 0 && (
-
- ★ {formatDecimal(rating, locale, 1)}
-
+
)}
-
-
-
- {masterUserRating > 0
- ? `${formatDecimal(masterUserRating, locale, 1)}/${formatDecimal(5, locale, 1)}`
- : t('userNoScore')}
-
-
+ 0
+ ? master.authentic_review_count ||
+ master.total_reviews ||
+ 0
+ : null
+ }
+ size="sm"
+ emptyLabel={t('userNoScore')}
+ />
{t('expertLabel')}{' '}
@@ -819,14 +823,6 @@ export default function DeepInspectWorkspace({
-
- {t('verifiedReviewsCount', {
- count:
- master.authentic_review_count ||
- master.total_reviews ||
- 0,
- })}
-
-
- {formatDecimal(entry.activeScore5, locale, 2)}
-
- /{formatDecimal(5, locale, 1)}
-
-
+
@@ -1296,12 +1295,12 @@ export default function LeaderboardExplorer({
-
- ★{' '}
- {entry.userScore5 > 0
- ? `${formatDecimal(entry.userScore5, locale, 2)}/${formatDecimal(5, locale, 1)}`
- : t('unratedShort')}
-
+
{entry.expertScore10 != null
? t('expertScoreShort', {
@@ -1458,7 +1457,7 @@ export default function LeaderboardExplorer({
#{iA + 1} vs #{iB + 1}
- Δ {delta} / {formatDecimal(5, locale, 1)}
+ Δ {delta}
@@ -1467,9 +1466,12 @@ export default function LeaderboardExplorer({
{a.product.name}
-
- ★ {formatDecimal(a.activeScore5, locale, 2)}
-
+
VS
@@ -1478,9 +1480,12 @@ export default function LeaderboardExplorer({
{b.product.name}
-
- ★ {formatDecimal(b.activeScore5, locale, 2)}
-
+
@@ -1566,10 +1571,6 @@ export default function LeaderboardExplorer({
{entry.product.brand}
-
-
- {entry.authenticCount} {copy.verifiedReviewsSuffix}
-
{entry.ignoredCount > 0 && (
-
-
-
- {entry.userScore5 > 0
- ? t('userScoreShort', {
- score: formatDecimal(
- entry.userScore5,
- locale,
- 2
- ),
- })
- : t('userUnrated')}
-
-
+
{entry.expertScore10 != null
@@ -1706,12 +1699,11 @@ export default function LeaderboardExplorer({
{copy.critLabels[activeCriterion]}
-
- {formatDecimal(entry.activeScore5, locale, 2)}
-
- /{formatDecimal(5, locale, 1)}
-
-
+
{minPriceText} ₫ ({entry.product.merchant_count || 0}{' '}
{copy.storesTrackedSuffix})
diff --git a/apps/web/src/components/leaderboard/ProductLeaderboardBadges.tsx b/apps/web/src/components/leaderboard/ProductLeaderboardBadges.tsx
index 1903f8530..44435b1dc 100644
--- a/apps/web/src/components/leaderboard/ProductLeaderboardBadges.tsx
+++ b/apps/web/src/components/leaderboard/ProductLeaderboardBadges.tsx
@@ -177,7 +177,7 @@ export default async function ProductLeaderboardBadges({
fontSize: '0.8125rem',
}}
>
- ({formatDecimal(b.score5, lang, 2)}/{formatDecimal(5, lang, 1)}) ↗
+ ({formatDecimal(b.score5, lang, 2)}) ↗
);
diff --git a/apps/web/src/components/leaderboard/ProductValueCalculator.tsx b/apps/web/src/components/leaderboard/ProductValueCalculator.tsx
index 0c52418fd..9e507540b 100644
--- a/apps/web/src/components/leaderboard/ProductValueCalculator.tsx
+++ b/apps/web/src/components/leaderboard/ProductValueCalculator.tsx
@@ -11,11 +11,11 @@ import {
import { escapeCsvCell } from '@/lib/csv-safe';
import { MAX_VALID_VND_PRICE } from '@/lib/rate-domain';
import {
- formatDecimal,
formatNumber,
formatPlainNumber,
formatPriceVnd,
} from '@/lib/locale-format';
+import StarRating from '@/components/elementary/StarRating';
interface ProductValueCalculatorProps {
readonly productName: string;
@@ -413,10 +413,11 @@ export default function ProductValueCalculator({
{t('valueCalcCardValueScoreTitle')}
-
- {safeValueCriterionScore5 > 0
- ? `★ ${formatDecimal(safeValueCriterionScore5, locale, 1)}/${formatDecimal(5, locale, 1)}`
- : t('valueCalcNotAvailable')}
+
+
{metrics.maxSpreadSavingsVnd > 0
diff --git a/apps/web/src/components/merchants/MerchantDirectoryExplorer.tsx b/apps/web/src/components/merchants/MerchantDirectoryExplorer.tsx
index dd3715d37..122b048d4 100644
--- a/apps/web/src/components/merchants/MerchantDirectoryExplorer.tsx
+++ b/apps/web/src/components/merchants/MerchantDirectoryExplorer.tsx
@@ -46,7 +46,6 @@ import {
BsShieldExclamation,
BsShop,
BsSortDown,
- BsStarFill,
} from 'react-icons/bs';
import {
@@ -55,11 +54,11 @@ import {
sanitizeMerchantScore100,
} from '@/components/merchants/MerchantShareAuditButton';
import {
- formatDecimal,
formatNumber,
formatPlainNumber,
formatPriceVnd,
} from '@/lib/locale-format';
+import StarRating from '@/components/elementary/StarRating';
// Rows per request when an export collects every matching store.
const EXPORT_PAGE_SIZE = 500;
@@ -875,18 +874,14 @@ export default function MerchantDirectoryExplorer({
so the card stays short on phones (DOS/Rate#635). */}
-
{labels.platformRating}:
-
- {merchant.rating_star
- ? `${formatDecimal(merchant.rating_star, locale, 2)} / ${formatDecimal(5, locale, 1)}`
- : labels.noRating}
-
+
{merchant.follower_count > 0 && (
diff --git a/apps/web/src/components/product/ProductCard.tsx b/apps/web/src/components/product/ProductCard.tsx
index 09059b534..cf846ffc6 100644
--- a/apps/web/src/components/product/ProductCard.tsx
+++ b/apps/web/src/components/product/ProductCard.tsx
@@ -10,6 +10,7 @@ import { contentLang } from '@/lib/content-lang';
import { MAX_VALID_VND_PRICE } from '@/lib/rate-domain';
import { formatDecimal, formatPriceVnd } from '@/lib/locale-format';
import { deviceImageSrcSet } from '@/lib/device-images';
+import StarRating from '@/components/elementary/StarRating';
export interface ProductCardSpec {
readonly key: string;
@@ -181,18 +182,12 @@ export default function ProductCard({
- {safeUserScore != null ? (
- <>
-
- ★ {formatDecimal(safeUserScore, locale, 1)}
-
-
- {t('reviewCount', { count: safeReviewCount })}
-
- >
- ) : (
- {t('noReviews')}
- )}
+
{safeExpertScore != null && (
{t('expertScore', {
diff --git a/apps/web/src/components/reviews/JReviewsSection.tsx b/apps/web/src/components/reviews/JReviewsSection.tsx
index f5ce81c1b..39ecd6ed3 100644
--- a/apps/web/src/components/reviews/JReviewsSection.tsx
+++ b/apps/web/src/components/reviews/JReviewsSection.tsx
@@ -4,6 +4,7 @@ import React, { useEffect, useId, useMemo, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import ExportMenu from '@/components/elementary/ExportMenu';
import PhoneCollapsible from '@/components/product/PhoneCollapsible';
+import StarRating from '@/components/elementary/StarRating';
import {
BsBarChart,
BsCheckCircle,
@@ -999,7 +1000,7 @@ export default function JReviewsSection({
locale,
1
)}{' '}
- / {formatDecimal(5, locale, 1)} ★
+ ★
{t('overallScoreTitle')}
-
-
- {liveScores.overall > 0
- ? formatDecimal(liveScores.overall, locale, 1)
- : 'N/A'}
-
-
- / {formatDecimal(5, locale, 1)}
-
-
-
- {'★'.repeat(Math.round(liveScores.overall || 5))}
-
- {'★'.repeat(
- Math.max(0, 5 - Math.round(liveScores.overall || 5))
- )}
-
+
+
@@ -1066,46 +1056,33 @@ export default function JReviewsSection({
-
-
- {t('authenticUserScoreTitle')}
-
-
- ★{' '}
- {liveScores.user != null
- ? formatDecimal(liveScores.user, locale, 2)
- : 'N/A'}
-
-
-
-
- {liveScores.bayesianUser != null
- ? formatDecimal(liveScores.bayesianUser, locale, 2)
- : 'N/A'}
-
-
- / {formatDecimal(5, locale, 1)} (Bayes)
-
+
+ {t('authenticUserScoreTitle')}
-
- {t('basedOnAuthenticReviews', {
- count: counts.authentic,
- // The cs decimal comma, as avgScore (DOS/Rate#1411).
- weightSum: formatPlainNumber(
- liveScores.userWeightSum,
- locale
- ),
- avgScore:
- liveScores.user != null
- ? formatDecimal(liveScores.user, locale, 2)
- : 'N/A',
- })}
+ {/* No fading for thin data: the confidence badge below says
+ so, and faded text fails contrast (DOS/Rate#1528). */}
+
+
@@ -1133,9 +1110,6 @@ export default function JReviewsSection({
)}
)
-
- {t('validCountBadge', { count: counts.authentic })}
-
{counts.ignored > 0 && (
{t('blockedSeedingBadge', { count: counts.ignored })}
@@ -1153,22 +1127,24 @@ export default function JReviewsSection({
{t('expertLabScoreTitle')}
-
- Expert{' '}
- {liveScores.expert != null
- ? `${formatDecimal(liveScores.expert, locale, 1)}/${formatDecimal(5, locale, 1)}`
- : t('expertPendingShort')}
-
+ {liveScores.expert == null && (
+
+ {t('expertPendingShort')}
+
+ )}
{liveScores.expert != null ? (
<>
+ {/* On 10, as the scoreboard above shows it. */}
- {formatDecimal(liveScores.expert, locale, 1)}
-
-
- / {formatDecimal(5, locale, 1)}
+ {formatDecimal(
+ Number((liveScores.expert * 2).toFixed(1)),
+ locale,
+ 1
+ )}
+ /10
{expertReviewSummary ? (
@@ -1226,7 +1202,7 @@ export default function JReviewsSection({
{crit.label}
{crit.score != null
- ? `${formatDecimal(crit.score, locale, 1)}/${formatDecimal(5, locale, 1)}`
+ ? `${formatDecimal(crit.score, locale, 1)}/5`
: t('notAvailableShort')}
@@ -1605,9 +1581,10 @@ export default function JReviewsSection({
)}
-
- {'★'.repeat(Math.max(1, Math.min(5, rev.rating_star)))}
-
+
{/* Second Row: Flag Category Badge + JReviews Weight Badge */}
diff --git a/apps/web/src/components/watchlist/WatchlistWorkspace.tsx b/apps/web/src/components/watchlist/WatchlistWorkspace.tsx
index 9cc0ef3de..46fb9bf25 100644
--- a/apps/web/src/components/watchlist/WatchlistWorkspace.tsx
+++ b/apps/web/src/components/watchlist/WatchlistWorkspace.tsx
@@ -46,11 +46,11 @@ import {
} from '@/lib/watchlist-sizes';
import {
formatDate,
- formatDecimal,
formatNumber,
formatPlainNumber,
formatPriceVnd,
} from '@/lib/locale-format';
+import StarRating from '@/components/elementary/StarRating';
interface WatchlistWorkspaceProps {
catalogProducts: MasterProduct[];
@@ -752,12 +752,10 @@ export default function WatchlistWorkspace({
{t('kpiAvgRatingLabel')}
-
- ★{' '}
- {avgPortfolioRating > 0
- ? `${formatDecimal(avgPortfolioRating, locale, 2)} / ${formatDecimal(5, locale, 1)}`
- : t('kpiNoRating')}
-
+
diff --git a/apps/web/src/lib/__tests__/client-messages.test.ts b/apps/web/src/lib/__tests__/client-messages.test.ts
index 939dafa79..3f198e01c 100644
--- a/apps/web/src/lib/__tests__/client-messages.test.ts
+++ b/apps/web/src/lib/__tests__/client-messages.test.ts
@@ -43,6 +43,7 @@ function main() {
'deals',
'errors',
'rate',
+ 'starRating',
'watchlist',
]);
assert.deepEqual(Object.keys(shared.rate as object), ['nav']);
@@ -96,6 +97,7 @@ function main() {
'deals',
'errors',
'rate',
+ 'starRating',
'watchlist',
]);
diff --git a/apps/web/src/lib/__tests__/leaderboard-badge-copy.test.ts b/apps/web/src/lib/__tests__/leaderboard-badge-copy.test.ts
index 13c0dfc6f..c3ba6176d 100644
--- a/apps/web/src/lib/__tests__/leaderboard-badge-copy.test.ts
+++ b/apps/web/src/lib/__tests__/leaderboard-badge-copy.test.ts
@@ -49,7 +49,6 @@ for (const locale of ['vi', 'en', 'cs'] as const) {
'shortValue',
'userUnrated',
'expertUnrated',
- 'userScoreShort',
'expertScoreShort',
]) {
assert.ok(lb[key], `vi: missing leaderboard.${key}`);
diff --git a/apps/web/src/lib/__tests__/plural-copy.test.ts b/apps/web/src/lib/__tests__/plural-copy.test.ts
index e08e46610..383b675ca 100644
--- a/apps/web/src/lib/__tests__/plural-copy.test.ts
+++ b/apps/web/src/lib/__tests__/plural-copy.test.ts
@@ -41,22 +41,6 @@ function main() {
{ count: 1 },
'Odhalena 1 nabídka s uměle navýšenou původní cenou',
],
- // Czech scores use a decimal comma (DOS/Rate#1369).
- [
- 'productDetail.verifiedReviewsCount',
- { count: 4 },
- '/ 5,0 (4 ověřené recenze)',
- ],
- [
- 'productDetail.verifiedReviewsCountNoScore',
- { count: 1 },
- '(1 ověřená recenze)',
- ],
- [
- 'productDetail.verifiedReviewsCountNoScore',
- { count: 15 },
- '(15 ověřených recenzí)',
- ],
[
'productDetail.authenticReviewsBadge',
{ count: 2 },
@@ -125,11 +109,6 @@ function main() {
'2 listings currently in genuine deal zone',
],
['catalog.page.radarAnalyzedSub', { count: 1 }, 'Checked 1 store listing'],
- [
- 'productDetail.verifiedReviewsCountNoScore',
- { count: 1 },
- '(1 verified review)',
- ],
[
'catalog.meta.laptops.titleCount',
{ count: 1 },
@@ -191,7 +170,6 @@ function main() {
{ count: 5 },
'Neuvedené úložiště (5 zdrojů)',
],
- ['reviews.validCountBadge', { count: 1 }, '✓ 1 platná'],
['reviews.expertReviewsCountNote', { count: 2 }, '2 expertní hodnocení'],
['reviews.expertReviewsCountNote', { count: 6 }, '6 expertních hodnocení'],
['catalog.page.foundSuffix', { total: 3 }, '/ 3 normalizované produkty'],
@@ -421,15 +399,12 @@ function main() {
// Vietnamese has one form and keeps its plain placeholders.
const viMessages = read('vi');
- for (const key of [
- 'verifiedReviewsCountNoScore',
- 'priceHistoryGenuineDealBadge',
- ]) {
+ for (const key of ['priceHistoryGenuineDealBadge']) {
assert.doesNotMatch(viMessages.productDetail[key], /plural/, `vi ${key}`);
}
assert.ok(
vi(
- 'productDetail.verifiedReviewsCountNoScore' as never,
+ 'inspect.verifiedReviewsCount' as never,
{ count: 1 } as never
).startsWith('(1 ')
);
diff --git a/apps/web/src/lib/__tests__/rating-display.test.ts b/apps/web/src/lib/__tests__/rating-display.test.ts
new file mode 100644
index 000000000..cf20f51da
--- /dev/null
+++ b/apps/web/src/lib/__tests__/rating-display.test.ts
@@ -0,0 +1,139 @@
+import assert from 'node:assert/strict';
+import { readdirSync, readFileSync, statSync } from 'node:fs';
+import { join } from 'node:path';
+import {
+ displayRating,
+ formatRatingCount,
+ formatRatingValue,
+ ratingFillPercent,
+ ratingTone,
+} from '../rating-display';
+import { CLIENT_MESSAGE_SCOPES } from '../client-messages';
+
+// Ratings show as stars with the value and a short count (DOS/Rate#1528):
+// green from 4.0, orange from 3.0, red below, judged on the printed value.
+assert.equal(ratingTone(4.6), 'good');
+assert.equal(ratingTone(4.0), 'good');
+assert.equal(ratingTone(3.96), 'good', '3.96 prints as 4.0, so it is green');
+assert.equal(ratingTone(3.94), 'fair');
+assert.equal(ratingTone(3.0), 'fair');
+assert.equal(ratingTone(2.96), 'fair', '2.96 prints as 3.0');
+assert.equal(ratingTone(2.9), 'poor');
+assert.equal(ratingTone(1), 'poor');
+assert.equal(ratingTone(3.996, 2), 'good');
+assert.equal(ratingTone(3.994, 2), 'fair');
+
+// The fill follows the printed value.
+assert.equal(ratingFillPercent(5), 100);
+assert.equal(ratingFillPercent(4.6), 92);
+assert.equal(ratingFillPercent(2.5), 50);
+assert.equal(ratingFillPercent(4.96), 100);
+
+// No rating is no rating, never "0.0" stars; values over 5 are capped.
+for (const v of [0, -1, null, undefined, Number.NaN, Infinity, 'x']) {
+ assert.equal(displayRating(v), null, String(v));
+}
+assert.equal(displayRating(4.62), 4.62);
+assert.equal(displayRating(7), 5);
+assert.equal(displayRating('4.5'), 4.5);
+
+// One decimal by default, the locale's decimal mark; the value is unchanged.
+assert.equal(formatRatingValue(4.62, 'vi'), '4.6');
+assert.equal(formatRatingValue(4.62, 'en'), '4.6');
+assert.equal(formatRatingValue(4.62, 'cs'), '4,6');
+assert.equal(formatRatingValue(4.625, 'vi', 2), '4.63');
+
+// The count takes the locale's thousands separator.
+assert.equal(formatRatingCount(11204, 'vi'), '11.204');
+assert.equal(formatRatingCount(11204, 'en'), '11,204');
+assert.equal(formatRatingCount(11204, 'cs'), '11 204');
+assert.equal(formatRatingCount(6, 'vi'), '6');
+assert.equal(formatRatingCount(-3, 'vi'), '0');
+
+// The verbose words are gone from every on-screen rating message, and each
+// locale has the component's messages.
+const localesDir = join(__dirname, '..', '..', '..', 'locales');
+for (const locale of ['vi', 'en', 'cs'] as const) {
+ const raw = readFileSync(join(localesDir, `${locale}.json`), 'utf8');
+ const messages = JSON.parse(raw);
+ for (const key of ['aria', 'ariaWithCount', 'empty']) {
+ assert.ok(messages.starRating?.[key], `${locale}: starRating.${key}`);
+ }
+ for (const key of [
+ 'userWeightedScoreLabel',
+ 'userScoreHint',
+ 'expertScoreHint',
+ ]) {
+ assert.ok(messages.productDetail[key], `${locale}: productDetail.${key}`);
+ }
+ assert.doesNotMatch(messages.productDetail.userWeightedScoreLabel, /\(/);
+ // The versus verdict is on screen; the compare CSV/JSON cell keeps its form.
+ for (const key of ['versusAutoVerdict', 'versusAutoVerdictTie']) {
+ assert.doesNotMatch(
+ messages.compare[key],
+ /\/ ?5[.,]0/,
+ `${locale} ${key}`
+ );
+ }
+}
+// Client trees get the component's messages (StarRating has no 'use client'
+// of its own, so the client-messages scan does not see it).
+assert.ok(CLIENT_MESSAGE_SCOPES.shared.includes('starRating'));
+const vi = readFileSync(join(localesDir, 'vi.json'), 'utf8');
+for (const phrase of ['bài kiểm chứng', 'chuẩn 10 nhóm']) {
+ assert.ok(!vi.includes(phrase), `vi.json still has "${phrase}"`);
+}
+
+// Rendered ratings go through the one component: no screen code writes the
+// "/5.0" scale next to a rating any more. Markdown, CSV and JSON exports keep
+// their fixed machine-readable form, so they are skipped.
+const srcDir = join(__dirname, '..', '..');
+const screenFiles: string[] = [];
+const walk = (dir: string) => {
+ for (const name of readdirSync(dir)) {
+ const p = join(dir, name);
+ if (name === '__tests__' || name === 'node_modules') continue;
+ if (statSync(p).isDirectory()) walk(p);
+ else if (p.endsWith('.tsx')) screenFiles.push(p);
+ }
+};
+walk(srcDir);
+// A JSX child `{formatDecimal(5, …)}`; template strings (`${…}`) are export
+// text such as the compare CSV cell, which keeps its form.
+const SCALE_IN_JSX = /(?
+ readFileSync(f, 'utf8').includes(
+ "import StarRating from '@/components/elementary/StarRating'"
+ )
+ )
+);
+
+// The compare table: the screen shows the lab score on 10 and the "only
+// differences" filter reads that same text, while the CSV/JSON export keeps
+// "Lab x / 5.0" (Codex, PR #1534).
+void (async () => {
+ const { buildSpecGroups } =
+ await import('../../components/compare/CompareWorkspace');
+ const t = ((key: string) => key) as unknown as Parameters<
+ typeof buildSpecGroups
+ >[0];
+ const row = buildSpecGroups(t, 'vi')
+ .flatMap((g) => g.rows)
+ .find((r) => r.id === 'expert_rating')!;
+ const product = (expert: number) =>
+ ({ expert_rating: expert }) as unknown as Parameters<
+ typeof row.renderValue
+ >[0];
+ assert.equal(row.renderValue(product(4.51)), 'Lab 4.5 / 5.0');
+ assert.equal(row.screenValue?.(product(4.51)), 'Lab 9.0/10');
+ assert.equal(row.screenValue?.(product(4.54)), 'Lab 9.1/10');
+ console.warn(' ✅ Ratings show as stars with a short count (#1528)');
+})().catch((error: unknown) => {
+ console.error(error);
+ process.exit(1);
+});
diff --git a/apps/web/src/lib/client-messages.ts b/apps/web/src/lib/client-messages.ts
index 0ec719af0..af649e75e 100644
--- a/apps/web/src/lib/client-messages.ts
+++ b/apps/web/src/lib/client-messages.ts
@@ -17,6 +17,8 @@ export const CLIENT_MESSAGE_SCOPES = {
'compare.quickToggle*',
'watchlist.quickToggle*',
'deals.radarBadge*',
+ // StarRating, used by server and client components (DOS/Rate#1528).
+ 'starRating',
],
home: ['heroSearch', 'priceVerdict'],
contact: ['staticPages.quarantine*', 'heroSearch', 'priceVerdict'],
diff --git a/apps/web/src/lib/rating-display.ts b/apps/web/src/lib/rating-display.ts
new file mode 100644
index 000000000..0f2ca29be
--- /dev/null
+++ b/apps/web/src/lib/rating-display.ts
@@ -0,0 +1,59 @@
+// How a 0-5 rating is shown on screen (DOS/Rate#1528): stars with a
+// fractional fill, the value at one decimal and the count in parentheses.
+// The value itself is never changed here, only rounded for display.
+
+import { formatDecimal, toFormatLocale } from './locale-format';
+
+/** The colour band of a rating: green, orange or red stars. */
+export type RatingTone = 'good' | 'fair' | 'poor';
+
+/** At or above this the stars are green. */
+export const RATING_GOOD_MIN = 4.0;
+/** At or above this (and below good) the stars are orange; below, red. */
+export const RATING_FAIR_MIN = 3.0;
+
+/** A finite rating above 0, kept within 0-5; anything else is no rating. */
+export function displayRating(value: unknown): number | null {
+ const n = typeof value === 'number' ? value : Number(value);
+ if (!Number.isFinite(n) || n <= 0) return null;
+ return Math.min(5, n);
+}
+
+/**
+ * The band of the value as it is printed, so a 3.96 shown as "4.0" is
+ * green, never an orange "4.0".
+ */
+export function ratingTone(value: number, digits = 1): RatingTone {
+ const shown = Number(value.toFixed(digits));
+ if (shown >= RATING_GOOD_MIN) return 'good';
+ if (shown >= RATING_FAIR_MIN) return 'fair';
+ return 'poor';
+}
+
+/** Filled share of the five stars, 0-100, as printed. */
+export function ratingFillPercent(value: number, digits = 1): number {
+ const shown = Number(value.toFixed(digits));
+ return Math.max(0, Math.min(100, (shown / 5) * 100));
+}
+
+/** The value as printed: `4.6` (vi, en), `4,6` (cs). */
+export function formatRatingValue(
+ value: number,
+ locale: string | null | undefined,
+ digits = 1
+): string {
+ return formatDecimal(value, locale, digits);
+}
+
+/**
+ * A review count with the locale's thousands separator: `11.204` (vi),
+ * `11,204` (en), `11 204` (cs, non-breaking).
+ */
+export function formatRatingCount(
+ count: number,
+ locale: string | null | undefined
+): string {
+ const n = Math.max(0, Math.round(count));
+ const tag = { vi: 'vi-VN', en: 'en-US', cs: 'cs-CZ' }[toFormatLocale(locale)];
+ return n.toLocaleString(tag, { maximumFractionDigits: 0 });
+}
diff --git a/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts b/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts
index 9d1c28695..62cf4040c 100644
--- a/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts
+++ b/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts
@@ -676,7 +676,7 @@ async function runCoreArchitectureSuite() {
);
assert.ok(
leaderboardExplorerSource.includes('rate-scoreboard-dark') &&
- leaderboardExplorerSource.includes('rate-score-badge-user') &&
+ leaderboardExplorerSource.includes('