From 0bc001d8906589f1153958b2f74928ce25f9a775 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Sat, 31 Oct 2026 09:50:42 +0700 Subject: [PATCH 1/5] fix(web): ratings show as stars with a short count, without the extra words MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Co-Authored-By: Claude Opus 5.5 --- 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 | 21 ++- apps/web/locales/en.json | 21 ++- apps/web/locales/vi.json | 21 ++- 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 | 64 ++++----- apps/web/src/app/style/scss/_custom.scss | 85 ++++++++++++ apps/web/src/app/style/scss/_theme.scss | 9 ++ .../components/compare/CompareWorkspace.tsx | 97 +++++++------- .../compare/VersusInteractiveScoreboard.tsx | 23 +--- .../components/deals/DealFinderWorkspace.tsx | 13 +- .../src/components/elementary/StarRating.tsx | 106 +++++++++++++++ .../components/home/HeroSearchInspector.tsx | 6 +- .../inspect/DeepInspectWorkspace.tsx | 30 ++--- .../leaderboard/LeaderboardExplorer.tsx | 59 +++------ .../leaderboard/ProductValueCalculator.tsx | 11 +- .../merchants/MerchantDirectoryExplorer.tsx | 17 +-- .../src/components/product/ProductCard.tsx | 19 +-- .../components/reviews/JReviewsSection.tsx | 122 ++++++++---------- .../watchlist/WatchlistWorkspace.tsx | 12 +- .../__tests__/leaderboard-badge-copy.test.ts | 1 - .../web/src/lib/__tests__/plural-copy.test.ts | 33 +---- .../src/lib/__tests__/rating-display.test.ts | 103 +++++++++++++++ apps/web/src/lib/rating-display.ts | 59 +++++++++ .../__tests__/core-rate-architecture.test.ts | 2 +- 29 files changed, 690 insertions(+), 366 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/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 f258e0244..62b64340c 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 42318287a..d151dd5f7 100644 --- a/apps/web/locales/cs.json +++ b/apps/web/locales/cs.json @@ -203,7 +203,7 @@ "boolNo": "Ne", "group0Title": "0. 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í}})", + "valAuthenticReviews": "★ {rating} ({count, number})", "valNoReviews": "Zatím bez hodnocení", "rowExpertRating": "Expertní skóre Rate Lab", "rowMinPrice": "Nejnižší ověřená referenční cena", @@ -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 6bc16f262..06d1022a2 100644 --- a/apps/web/locales/en.json +++ b/apps/web/locales/en.json @@ -203,7 +203,7 @@ "boolNo": "No", "group0Title": "0. Review scores and trust", "rowRatingWeighted": "Verified buyer score (fake reviews filtered)", - "valAuthenticReviews": "★ {rating} / 5.0 ({count, plural, one {# verified review} other {# verified reviews}})", + "valAuthenticReviews": "★ {rating} ({count, number})", "valNoReviews": "No reviews yet", "rowExpertRating": "Rate Lab expert score", "rowMinPrice": "Lowest checked reference price", @@ -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 874985541..6c8dd1b63 100644 --- a/apps/web/locales/vi.json +++ b/apps/web/locales/vi.json @@ -203,7 +203,7 @@ "boolNo": "Không", "group0Title": "0. Đ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)", + "valAuthenticReviews": "★ {rating} ({count, number})", "valNoReviews": "Chưa có đánh giá", "rowExpertRating": "Điểm chuyên gia Rate Lab", "rowMinPrice": "Giá tham khảo thấp nhất (đã đối chiếu)", @@ -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 - )} +