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} -
- -
- -
- {( - [ - 'none', - 'photography', - 'gaming', - 'endurance', - 'business', - 'value_hunter', - ] as const - ).map((presetKey) => { - const isSelected = activePersona === presetKey; - return ( - - ); - })} -
+ {activePersona === 'custom' && ( + + {`: ${copy.personaLabels.custom}`} + + )} + +
- {showWeightSliders && (
-
- {( - [ - { 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((slider) => categoryCriteria.includes(slider.key)) - .map((slider) => ( -
-
- {slider.label} - - {formatPercent(customWeights[slider.key], locale)} - + {( + [ + 'none', + 'photography', + 'gaming', + 'endurance', + 'business', + 'value_hunter', + ] as const + ).map((presetKey) => { + const isSelected = activePersona === presetKey; + return ( + + ); + })} +
+ + {showWeightSliders && ( +
+
+ {( + [ + { 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((slider) => scoredCriteria.includes(slider.key)) + .map((slider) => ( +
+
+ {slider.label} + + {formatPercent(customWeights[slider.key], locale)} + +
+ + updateSingleWeight( + slider.key, + Number(e.target.value) + ) + } + className="form-range" + aria-label={slider.label} + />
- - updateSingleWeight(slider.key, Number(e.target.value)) - } - className="form-range" - aria-label={slider.label} - /> -
- ))} + ))} +
-
- )} -
+ )} + + )} {/* 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 (