diff --git a/apps/web/e2e/selector-state.spec.ts b/apps/web/e2e/selector-state.spec.ts
index 6fceab0a..1edca3bc 100644
--- a/apps/web/e2e/selector-state.spec.ts
+++ b/apps/web/e2e/selector-state.spec.ts
@@ -10,7 +10,6 @@ test('leaderboard selectors expose their state', async ({ page }) => {
const groups = [
page.getByRole('group', { name: 'Chọn ngành hàng' }),
page.getByRole('group', { name: /Xếp hạng theo tiêu chí/ }),
- page.getByRole('group', { name: /Gợi ý theo nhu cầu/ }),
page.getByRole('group', { name: 'Lọc theo tầm giá' }),
];
for (const group of groups) {
@@ -22,33 +21,35 @@ test('leaderboard selectors expose their state', async ({ page }) => {
);
// Choosing a price segment moves the pressed state.
- const price = groups[3]!;
+ const price = groups[2]!;
const unpressed = price.getByRole('button', { pressed: false }).first();
const label = (await unpressed.innerText()).trim();
await unpressed.click();
await expect(price.getByRole('button', { pressed: true })).toHaveText(label);
+});
- // The customize button reports the weight panel it opens.
- const customize = page.locator(
- 'button[aria-controls="leaderboard-weight-sliders"]'
- );
- await expect(customize).toHaveAttribute('aria-expanded', 'false');
- await customize.click();
- await expect(customize).toHaveAttribute('aria-expanded', 'true');
- await expect(page.locator('#leaderboard-weight-sliders')).toBeVisible();
-
- // Moving a slider makes the weights custom: no preset is pressed, and the
- // customize button, coloured for it, says so in its name.
- await expect(customize).not.toHaveAccessibleName(/Trọng số cá nhân hóa/);
- const slider = page
- .locator('#leaderboard-weight-sliders input[type="range"]')
- .first();
- await slider.focus();
- await page.keyboard.press('ArrowRight');
- await expect(customize).toHaveAccessibleName(/Trọng số cá nhân hóa/);
- await expect(groups[2]!.getByRole('button', { pressed: true })).toHaveCount(
- 0
+// No seeded product has a criterion score, as on rate.vn today: the criterion
+// tabs, the persona presets and the weight sliders would only empty the list,
+// so they are not shown, and a criterion URL opens on the combined score
+// (DOS/Rate#1553).
+test('criteria with no scored product get no tab or persona', async ({
+ page,
+}) => {
+ expect(
+ (await page.goto('/leaderboard?cat=smartphones&criterion=camera'))?.status()
+ ).toBe(200);
+ const criteria = page.getByRole('group', { name: /Xếp hạng theo tiêu chí/ });
+ await expect(criteria.getByRole('button')).toHaveCount(3);
+ await expect(criteria.getByRole('button', { pressed: true })).toContainText(
+ 'Điểm tổng hợp'
);
+ await expect(page).toHaveURL(/[?&]criterion=combined$/);
+ await expect(
+ page.getByRole('group', { name: /Gợi ý theo nhu cầu/ })
+ ).toHaveCount(0);
+ await expect(
+ page.locator('button[aria-controls="leaderboard-weight-sliders"]')
+ ).toHaveCount(0);
});
test('the compare category switcher exposes its state', async ({ page }) => {
diff --git a/apps/web/locales/cs.json b/apps/web/locales/cs.json
index afe70d07..c791a0c6 100644
--- a/apps/web/locales/cs.json
+++ b/apps/web/locales/cs.json
@@ -518,7 +518,7 @@
"updatingLabTestsDesc": "Dokud není k dispozici expertní skóre, celkové skóre vychází jen ze skutečných hodnocení kupujících (bez falešných recenzí).",
"expertReviewsCountNote": "{count, plural, one {# expertní hodnocení} few {# expertní hodnocení} other {# expertních hodnocení}}",
"zeroAdDilutionNote": "Nezkresleno reklamními recenzemi",
- "criteriaMatrixHeading": "Podrobné skóre v 5 oblastech zkušeností",
+ "criteriaMatrixHeading": "Podrobné skóre podle oblastí zkušeností",
"normalizedByHelpfulnessBadge": "Normalizováno podle váhy užitečnosti",
"notAvailableShort": "N/A",
"aiAuthenticityHeading": "AI analýza důvěryhodnosti a souhrn zkušeností",
diff --git a/apps/web/locales/en.json b/apps/web/locales/en.json
index 1e56646d..16f12778 100644
--- a/apps/web/locales/en.json
+++ b/apps/web/locales/en.json
@@ -518,7 +518,7 @@
"updatingLabTestsDesc": "Until the expert score is ready, the combined score uses only genuine buyer ratings (fake reviews filtered out).",
"expertReviewsCountNote": "{count, plural, one {# expert evaluation} other {# expert evaluations}}",
"zeroAdDilutionNote": "Undiluted by sponsored or affiliate reviews",
- "criteriaMatrixHeading": "Scores across 5 experience criteria",
+ "criteriaMatrixHeading": "Scores by experience criterion",
"normalizedByHelpfulnessBadge": "Normalized by helpfulness weight",
"notAvailableShort": "N/A",
"aiAuthenticityHeading": "AI review authenticity check and experience summary",
diff --git a/apps/web/locales/vi.json b/apps/web/locales/vi.json
index 8fcb5d83..f9b34768 100644
--- a/apps/web/locales/vi.json
+++ b/apps/web/locales/vi.json
@@ -518,7 +518,7 @@
"updatingLabTestsDesc": "Khi chưa có điểm chuyên gia, điểm tổng hợp chỉ dựa trên đánh giá của người mua thực chất (đã lọc đánh giá ảo).",
"expertReviewsCountNote": "{count} bài đánh giá từ chuyên gia",
"zeroAdDilutionNote": "Chưa bị pha loãng bởi đánh giá quảng cáo",
- "criteriaMatrixHeading": "Điểm chi tiết theo 5 tiêu chí trải nghiệm",
+ "criteriaMatrixHeading": "Điểm chi tiết theo tiêu chí trải nghiệm",
"normalizedByHelpfulnessBadge": "Đã chuẩn hóa theo trọng số hữu ích",
"notAvailableShort": "Chưa có",
"aiAuthenticityHeading": "AI kiểm tra đánh giá thật hay ảo và tóm tắt trải nghiệm",
diff --git a/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx b/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx
index ca8039ba..9addaf3f 100644
--- a/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx
+++ b/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx
@@ -120,6 +120,7 @@ import {
import {
firstImageUrl,
foreignCategoryProductHref,
+ isCanonicalCategorySlug,
usableImageUrls,
productImageUrl,
storeImageSrcSet,
@@ -1434,6 +1435,9 @@ export async function ProductDetailView({
applyCategorySpecTemplate(activeCategory, p));
}, [products, activeCategory]);
+ // The category's criteria that at least one product has a real score
+ // for. A criterion with none gets no tab, no weight and no row cell, not
+ // an empty list (DOS/Rate#1553).
+ const scoredCriteria = useMemo(() => {
+ const scored = new Set();
+ const own = leaderboardCriteriaForCategory(activeCategory);
+ for (const item of categoryProducts) {
+ const { criteria } = computeProductScores(
+ item,
+ 'combined',
+ filteredReviewCounts
+ );
+ for (const c of own) {
+ if ((criteria[c] ?? 0) > 0) scored.add(c);
+ }
+ if (scored.size === own.length) break;
+ }
+ return own.filter((c) => scored.has(c));
+ }, [activeCategory, categoryProducts, filteredReviewCounts]);
+ const hasScoredCriteria = scoredCriteria.length > 0;
+
const availableBrands = useMemo(() => {
const brands = new Set();
for (const p of categoryProducts) {
@@ -766,15 +788,39 @@ export default function LeaderboardExplorer({
setActiveCriterion(crit);
}, []);
// A URL such as ?cat=earbuds&criterion=screen opens on the combined tab.
+ // So does a criterion no product has a score for yet (DOS/Rate#1553).
+ // Before paint, so an empty list never flashes, and the URL follows.
+ useLayoutEffect(() => {
+ let crit = criterionForCategory(activeCategory, activeCriterion);
+ if (
+ crit !== 'combined' &&
+ crit !== 'user' &&
+ crit !== 'expert' &&
+ !scoredCriteria.includes(crit)
+ ) {
+ crit = 'combined';
+ }
+ if (crit === activeCriterion) return;
+ setActiveCriterion(crit);
+ window.history.replaceState(
+ null,
+ '',
+ `${window.location.pathname}?cat=${activeCategory}&criterion=${crit}`
+ );
+ }, [activeCategory, activeCriterion, scoredCriteria]);
useEffect(() => {
- const crit = criterionForCategory(activeCategory, activeCriterion);
- if (crit !== activeCriterion) setActiveCriterion(crit);
- }, [activeCategory, activeCriterion]);
-
- // The criteria the active category has: no screen tab for earbuds
- // (DOS/Rate#1143).
- const categoryCriteria: readonly LeaderboardCriterionKey[] =
- leaderboardCriteriaForCategory(activeCategory);
+ if (hasScoredCriteria || activePersona === 'none') return;
+ setActivePersona('none');
+ setShowWeightSliders(false);
+ setCustomWeights({
+ screen: 20,
+ performance: 20,
+ camera: 20,
+ battery: 20,
+ value: 20,
+ });
+ }, [hasScoredCriteria, activePersona]);
+
const allCriterionOptions: Array<{
key: LeaderboardCriterionKey;
label: string;
@@ -826,7 +872,7 @@ export default function LeaderboardExplorer({
opt.key === 'combined' ||
opt.key === 'user' ||
opt.key === 'expert' ||
- categoryCriteria.includes(opt.key)
+ scoredCriteria.includes(opt.key)
);
return (
@@ -967,115 +1013,123 @@ export default function LeaderboardExplorer({
{/* 3. Smart Use-Case Persona Recommender & Custom 5-Criteria Weight Sliders (Issue #52) */}
-
-
-
- {copy.personaTitle}
-
-
+ )}
{/* Search + Price Segment + Brand Filter Bar */}
@@ -1356,63 +1410,69 @@ export default function LeaderboardExplorer({
{/* Mini 5-Criteria Bars */}
-
+ {hasScoredCriteria && (
- {t('criteriaSourceExpert')}
-
- {(
- [
- { key: 'screen', label: copy.critLabels.screen },
- {
- key: 'performance',
- label: copy.critLabels.performance,
- },
- { key: 'camera', label: copy.critLabels.camera },
- { key: 'battery', label: copy.critLabels.battery },
- { key: 'value', label: copy.critLabels.value },
- ] as const
- )
- .filter((c) => categoryCriteria.includes(c.key))
- .map((c) => {
- const val = entry.criteria[c.key];
- const pct = sanitizeLeaderboardBarPercentage(val);
- return (
-
-
- {c.label}
-
- {val != null
- ? formatDecimal(val, locale, 2)
- : t('criterionNoData')}
-
-
- {val != null && (
+
+ {t('criteriaSourceExpert')}
+
+ {(
+ [
+ { key: 'screen', label: copy.critLabels.screen },
+ {
+ key: 'performance',
+ label: copy.critLabels.performance,
+ },
+ { key: 'camera', label: copy.critLabels.camera },
+ {
+ key: 'battery',
+ label: copy.critLabels.battery,
+ },
+ { key: 'value', label: copy.critLabels.value },
+ ] as const
+ )
+ .filter((c) => scoredCriteria.includes(c.key))
+ .map((c) => {
+ const val = entry.criteria[c.key];
+ const pct = sanitizeLeaderboardBarPercentage(val);
+ return (
+
-
+
{c.label}
+
+ {val != null
+ ? formatDecimal(val, locale, 2)
+ : t('criterionNoData')}
+
- )}
-
- );
- })}
-
+ {val != null && (
+
+ )}
+
+ );
+ })}
+
+ )}
{keySpecs.chip && (
@@ -1710,7 +1770,7 @@ export default function LeaderboardExplorer({
{ key: 'value', short: t('shortValue') },
] as const
)
- .filter((c) => categoryCriteria.includes(c.key))
+ .filter((c) => scoredCriteria.includes(c.key))
.map((c) => (
{
+ if (!categorySlug) return criteriaMeta;
+ const own: readonly string[] = leaderboardCriteriaForCategory(categorySlug);
+ return criteriaMeta.filter((c) => own.includes(c.key));
+ }, [criteriaMeta, categorySlug]);
const flagMetaMap = useMemo<
Record<
@@ -326,7 +338,7 @@ export default function JReviewsSection({
});
const starAnomaly = detectStarDistributionAnomaly(reviews);
- const criteriaAverages = criteriaMeta.map((crit) => {
+ const criteriaAverages = shownCriteriaMeta.map((crit) => {
let wSum = 0;
let valSum = 0;
for (const r of validUserReviews) {
@@ -367,7 +379,7 @@ export default function JReviewsSection({
averageRating,
userRatingWeighted,
expertRating,
- criteriaMeta,
+ shownCriteriaMeta,
t,
]);
@@ -1632,7 +1644,7 @@ export default function JReviewsSection({
{rev.criteria_ratings &&
Object.keys(rev.criteria_ratings).length > 0 && (
- {criteriaMeta.map((crit) => {
+ {shownCriteriaMeta.map((crit) => {
const val = rev.criteria_ratings?.[crit.key];
if (val == null) return null;
return (