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/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/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..813cd555b 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 [ @@ -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/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/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..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", @@ -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í", @@ -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", @@ -946,7 +943,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", @@ -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", @@ -1796,11 +1791,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", @@ -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..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", @@ -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", @@ -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", @@ -946,7 +943,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", @@ -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", @@ -1796,9 +1791,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...", @@ -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..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", @@ -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", @@ -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ó", @@ -946,7 +943,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", @@ -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 độ", @@ -1423,7 +1418,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 +1473,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 +1791,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", @@ -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 - )} +