diff --git a/apps/web/e2e/category-spec-template.spec.ts b/apps/web/e2e/category-spec-template.spec.ts index bf811d4e..6839b797 100644 --- a/apps/web/e2e/category-spec-template.spec.ts +++ b/apps/web/e2e/category-spec-template.spec.ts @@ -11,7 +11,10 @@ test('earbud page shows audio specs and no phone fields', async ({ page }) => { const specs = page.locator('#specs-section'); const headings = specs.locator('h3'); await expect(headings.first()).toHaveText(/^1\. Âm thanh/); - await expect(headings).toHaveCount(7); + // Groups with no value are hidden (DOS/Rate#1186): the seeded bud has + // audio, battery, Bluetooth and the default sale status, nothing else. + await expect(headings).toHaveCount(4); + await expect(headings.nth(1)).toHaveText(/^2\. Pin/); await expect(specs).toContainText('E2E 10.5mm driver'); await expect(specs).toContainText('E2E ANC'); for (const text of [ @@ -24,6 +27,8 @@ test('earbud page shows audio specs and no phone fields', async ({ page }) => { 'E2E Mic Array', 'E2E Audio Engine', '5G', + 'Phần cứng', + 'Pháp lý', ]) { await expect(specs).not.toContainText(text); } @@ -53,9 +58,11 @@ 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ạng Di động (Cellular)'); - // The bands row stays, as 4G only. - await expect(specs).toContainText('Băng tần 4G'); + await expect(specs).toContainText('Màn hình (Display)'); + // The seeded phone 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('5G'); await expect(specs).not.toContainText('Camera'); }); diff --git a/apps/web/e2e/product-specs-collapse.spec.ts b/apps/web/e2e/product-specs-collapse.spec.ts index 8129e840..4c664f2d 100644 --- a/apps/web/e2e/product-specs-collapse.spec.ts +++ b/apps/web/e2e/product-specs-collapse.spec.ts @@ -27,9 +27,18 @@ test('phone shows three spec groups and expands to all', async ({ page }) => { }); }).toPass(); await expect(groups.nth(total - 1)).toBeVisible(); - // Collapsed, the section is far shorter than the full ten-group stack. + // Collapsed, the section is shorter by the groups it hid. Empty groups are + // left out (DOS/Rate#1186), so the seeded phone has five short groups, not + // ten full ones, and a fixed ratio no longer says anything. const expandedHeight = (await section.boundingBox())?.height ?? 0; - expect(collapsedHeight).toBeLessThan(expandedHeight * 0.6); + let hiddenHeight = 0; + for (let i = 3; i < total; i++) { + hiddenHeight += (await groups.nth(i).boundingBox())?.height ?? 0; + } + expect(hiddenHeight).toBeGreaterThan(0); + expect(expandedHeight - collapsedHeight).toBeGreaterThanOrEqual( + hiddenHeight * 0.9 + ); }); test('desktop shows every spec group and no toggle', async ({ page }) => { diff --git a/apps/web/package.json b/apps/web/package.json index cdcb9403..68ae5ccc 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/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/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__/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", + "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/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/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__/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", "typecheck": "tsc --noEmit" }, "browserslist": [ diff --git a/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx b/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx index 55d27fcc..e9b67c67 100644 --- a/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx +++ b/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx @@ -1,5 +1,5 @@ import type { Metadata } from 'next'; -import { Fragment, type ReactNode } from 'react'; +import { Fragment } from 'react'; import { getTranslations, setRequestLocale } from 'next-intl/server'; import { Link } from '@/lib/navigation'; import { notFound } from 'next/navigation'; @@ -57,12 +57,12 @@ import type { KeySpecs, ProductSpecifications } from '@repo/shared-data'; import { normalizeRateLocale } from '@/lib/rate-i18n'; import { getSpecUiDictionary, - localizeSpecValue, specGroupHeading, specSectionBadge, } from '@/lib/spec-localizer'; import { contentLang } from '@/lib/content-lang'; import ContentText from '@/components/elementary/ContentText'; +import { buildProductSpecGroups } from '@/lib/product-spec-rows'; import { computeProductStandingInCategory, scoreMasterProductForLeaderboard, @@ -76,8 +76,6 @@ import ClientMessages from '@/components/providers/ClientMessages'; import { applyCategorySpecTemplate, getCategorySpecTemplate, - specFieldApplies, - type SpecGroupKey, } from '@repo/shared-data/rules'; import { isOfferUnavailable, @@ -963,583 +961,16 @@ export async function ProductDetailView({ {/* SECTION #2: Spec groups, per category template (DOS/Rate#1142) */} {(() => { const specUi = getSpecUiDictionary(locale); - const loc = (v: string | number | boolean | null | undefined) => ( - - ); - const groupHeading = (group: SpecGroupKey) => - specGroupHeading( - specUi, - group, - specTemplate.groups.indexOf(group) + 1, - specTemplate.has5g - ); - const shows = (group: SpecGroupKey, field: string) => - specFieldApplies(specTemplate, group, field); - const groupNodes: Record = { - display: ( -
-
-

- {groupHeading('display')} -

-
-
- {specUi.fields.screenSize} -
-
- {specs?.display?.size_inch - ? `${specs.display.size_inch} inch` - : product.screen_size_inch - ? `${product.screen_size_inch} inch` - : '-'} -
-
- {specUi.fields.panelType} -
-
- {loc(specs?.display?.type || keySpecs?.screen)} -
-
- {specUi.fields.resolution} -
-
- {loc(specs?.display?.resolution)} -
-
- {specUi.fields.refreshRate} -
-
- {specs?.display?.refresh_rate_hz - ? `${specs.display.refresh_rate_hz} Hz` - : '-'} -
-
{specUi.fields.ppi}
-
- {specs?.display?.ppi ? `${specs.display.ppi} ppi` : '-'} -
-
- {specUi.fields.protection} -
-
- {loc(specs?.display?.protection)} -
-
- {specUi.fields.displayFeatures} -
-
- {loc(specs?.display?.features)} -
-
-
-
- ), - hardware: ( -
-
-

- {groupHeading('hardware')} -

-
-
- {specUi.fields.chipset} -
-
- {loc(specs?.hardware?.chipset || keySpecs?.chip)} -
- {shows('hardware', 'cpu') && ( - <> -
- {specUi.fields.cpu} -
-
- {loc(specs?.hardware?.cpu)} -
- - )} - {shows('hardware', 'gpu') && ( - <> -
- {specUi.fields.gpu} -
-
- {loc(specs?.hardware?.gpu)} -
- - )} - {shows('hardware', 'ram_gb') && ( - <> -
- {specUi.fields.ram} -
-
- {specs?.hardware?.ram_gb - ? `${specs.hardware.ram_gb} GB` - : product.ram_gb - ? `${product.ram_gb} GB` - : '-'} -
- - )} - {shows('hardware', 'storage_gb') && ( - <> -
- {specUi.fields.storage} -
-
- {specs?.hardware?.storage_gb - ? `${specs.hardware.storage_gb} GB` - : product.storage_gb - ? `${product.storage_gb} GB` - : '-'} -
- - )} - {shows('hardware', 'storage_expansion') && ( - <> -
- {specUi.fields.storageExpansion} -
-
- {loc(specs?.hardware?.storage_expansion)} -
- - )} - {shows('hardware', 'os') && ( - <> -
- {specUi.fields.os} -
-
- {loc(specs?.hardware?.os)} -
- - )} -
-
-
- ), - camera: ( -
-
-

- {groupHeading('camera')} -

-
-
- {specUi.fields.rearSetup} -
-
- {loc(specs?.camera?.rear_setup)} -
-
- {specUi.fields.mainCamera} -
-
- {loc(specs?.camera?.main_camera || keySpecs?.camera)} -
-
- {specUi.fields.secondCamera} -
-
- {loc(specs?.camera?.second_camera)} -
-
- {specUi.fields.thirdCamera} -
-
- {loc(specs?.camera?.third_camera)} -
- {specs?.camera?.fourth_camera && ( - <> -
- {specUi.fields.fourthCamera} -
-
- {loc(specs.camera.fourth_camera)} -
- - )} -
- {specUi.fields.frontCamera} -
-
- {loc(specs?.camera?.front_camera)} -
-
- {specUi.fields.videoAndFlash} -
-
- {loc( - [specs?.camera?.video_recording, specs?.camera?.flash] - .filter(Boolean) - .join(' | ') - )} -
-
-
-
- ), - battery: ( -
-
-

- {groupHeading('battery')} -

-
-
- {specUi.fields.batteryCapacity} -
-
- {specs?.battery?.capacity_mah - ? `${specs.battery.capacity_mah} mAh` - : product.battery_mah - ? `${product.battery_mah} mAh` - : loc(keySpecs?.battery)} -
-
- {specUi.fields.batteryType} -
-
- {loc(specs?.battery?.type)} -
-
- {specUi.fields.chargingTech} -
-
- {loc(specs?.battery?.charging)} -
-
- {specUi.fields.fastCharging} -
-
- {specs?.battery?.fast_charging_w - ? `${specs.battery.fast_charging_w} W` - : '-'} -
-
- {specUi.fields.reverseCharging} -
-
- {typeof specs?.battery?.reverse_charging === 'string' - ? loc(specs.battery.reverse_charging) - : specs?.battery?.reverse_charging === true - ? specUi.supported - : specs?.battery?.reverse_charging === false - ? specUi.no - : '-'} -
-
-
-
- ), - design: ( -
-
-

- {groupHeading('design')} -

-
-
- {specUi.fields.dimensions} -
-
- {loc(specs?.design?.dimensions)} -
-
- {specUi.fields.weight} -
-
- {specs?.design?.weight_g - ? `${specs.design.weight_g} g` - : '-'} -
-
- {specUi.fields.materialsAndBuild} -
-
- {loc( - [specs?.design?.materials, specs?.design?.build] - .filter(Boolean) - .join(' | ') - )} -
-
- {specUi.fields.resistance} -
-
- {loc(specs?.design?.resistance)} -
- {shows('design', 'biometrics') && ( - <> -
- {specUi.fields.biometrics} -
-
- {loc(specs?.design?.biometrics)} -
- - )} -
- {specUi.fields.physicalKeys} -
-
- {loc(specs?.design?.keys)} -
-
-
-
- ), - cellular: ( -
-
-

- {groupHeading('cellular')} -

-
- {shows('cellular', 'has_5g') && ( - <> -
- {specUi.fields.support5g} -
-
- {(specs?.cellular?.has_5g ?? product.has_5g) - ? specUi.has5gYes - : specUi.no} -
- - )} -
- {specUi.fields.networkTech} -
-
- {loc(specs?.cellular?.technology)} -
-
- {specUi.fields.simType} -
-
- {loc(specs?.cellular?.sim_type)} -
-
- {specUi.fields.dataSpeed} -
-
- {loc(specs?.cellular?.data_speed)} -
-
- {shows('cellular', 'bands_5g') - ? specUi.fields.bands5g4g - : specUi.fields.bands4g} -
-
- {loc( - [ - shows('cellular', 'bands_5g') - ? specs?.cellular?.bands_5g - : null, - specs?.cellular?.bands_4g, - ] - .filter(Boolean) - .join(' | ') - )} -
-
- {specUi.fields.volteVowifi} -
-
- {specs?.cellular?.volte || specs?.cellular?.vowifi - ? [ - specs?.cellular?.volte ? 'VoLTE' : null, - specs?.cellular?.vowifi ? 'VoWiFi' : null, - ] - .filter(Boolean) - .join(' + ') - : '-'} -
-
-
-
- ), - connectivity: ( -
-
-

- {groupHeading('connectivity')} -

-
- {shows('connectivity', 'wlan') && ( - <> -
- {specUi.fields.wlan} -
-
- {loc(specs?.connectivity?.wlan)} -
- - )} -
- {specUi.fields.bluetooth} -
-
- {loc(specs?.connectivity?.bluetooth)} -
-
- {specUi.fields.usbPort} -
-
- {loc(specs?.connectivity?.usb)} -
- {shows('connectivity', 'nfc') && ( - <> -
- {specUi.fields.nfc} -
-
- {specs?.connectivity?.nfc === true - ? specUi.yes - : specs?.connectivity?.nfc === false - ? specUi.no - : '-'} -
- - )} - {shows('connectivity', 'location') && ( - <> -
- {specUi.fields.locationGps} -
-
- {loc(specs?.connectivity?.location)} -
- - )} -
- {specUi.fields.sensorsAndOther} -
-
- {loc( - [ - specs?.connectivity?.sensors, - specs?.connectivity?.other, - ] - .filter(Boolean) - .join(' | ') - )} -
-
-
-
- ), - multimedia: ( -
-
-

- {groupHeading('multimedia')} -

-
-
- {specUi.fields.loudspeaker} -
-
- {loc(specs?.multimedia?.loudspeaker)} -
- {shows('multimedia', 'headphone_jack_35mm') && ( - <> -
- {specUi.fields.jack35mm} -
-
- {specs?.multimedia?.headphone_jack_35mm === true - ? specUi.yes - : specs?.multimedia?.headphone_jack_35mm === false - ? specUi.no - : '-'} -
- - )} -
- {specUi.fields.audioFeatures} -
-
- {loc(specs?.multimedia?.audio_features)} -
-
-
-
- ), - availability: ( -
-
-

- {groupHeading('availability')} -

-
-
- {specUi.fields.announcedDate} -
-
- {loc(specs?.availability?.announced)} -
-
- {specUi.fields.commercialStatus} -
-
- {specs?.availability?.status - ? loc(specs.availability.status) - : specUi.availableStatus} -
-
- {specUi.fields.msrpPrice} -
-
- {loc(specs?.availability?.prices_msrp)} -
-
-
-
- ), - regulatory: ( -
-
-

- {groupHeading('regulatory')} -

-
-
- {specUi.fields.certifications} -
-
- {loc(specs?.regulatory?.certifications)} -
-
- {specUi.fields.fccApproval} -
-
- {loc(specs?.regulatory?.fcc_approval)} -
-
- {specUi.fields.sarLevel} -
-
- {[ - specs?.regulatory?.sar_head - ? `${specUi.fields.sarHeadPrefix}: ${specs.regulatory.sar_head}` - : null, - specs?.regulatory?.sar_body - ? `${specUi.fields.sarBodyPrefix}: ${specs.regulatory.sar_body}` - : null, - ] - .filter(Boolean) - .join(' | ') || '-'} -
-
-
-
- ), - }; + // Rows with no value and groups with no row are left out + // (DOS/Rate#1186). + const specGroups = buildProductSpecGroups({ + specs, + keySpecs, + product, + template: specTemplate, + ui: specUi, + locale, + }); return (
- {specSectionBadge(specUi, specTemplate.groups.length)} + {specSectionBadge(specUi, specGroups.length)}
@@ -1576,9 +1007,31 @@ export async function ProductDetailView({ showMoreLabel={t('specsShowAll')} showLessLabel={t('specsShowLess')} openOnHash="#specs-section" + collapsible={specGroups.length > 3} > - {specTemplate.groups.map((group) => ( - {groupNodes[group]} + {specGroups.map(({ group, rows }, i) => ( +
+
+

+ {specGroupHeading( + specUi, + group, + i + 1, + specTemplate.has5g + )} +

+
+ {rows.map(({ label, value }) => ( + +
{label}
+
+ +
+
+ ))} +
+
+
))} diff --git a/apps/web/src/components/compare/CompareWorkspace.tsx b/apps/web/src/components/compare/CompareWorkspace.tsx index 01d8e9eb..3aa09adb 100644 --- a/apps/web/src/components/compare/CompareWorkspace.tsx +++ b/apps/web/src/components/compare/CompareWorkspace.tsx @@ -67,6 +67,7 @@ import { localizeSpecValue, specGroupHeading, } from '@/lib/spec-localizer'; +import { compareRowIsShown } from '@/lib/product-spec-rows'; import ContentText from '@/components/elementary/ContentText'; import { productImageUrl } from '@/lib/rate-domain-core'; @@ -862,6 +863,25 @@ export default function CompareWorkspace({ [allSpecGroups, activeCategory] ); + // No row that every column leaves empty, and with `showDiffOnly` no row + // where every column agrees (DOS/Rate#1186). + const visibleSpecGroups = specGroups + .map((group) => ({ + ...group, + rows: group.rows.filter((row) => + compareRowIsShown( + // The cells show the localized text, where "N/A" becomes "-". + products.map((p) => localizeSpecValue(row.renderValue(p), locale)), + showDiffOnly + ) + ), + })) + .filter((group) => group.rows.length > 0); + // Spec groups are numbered from 1 after the market group. + const numberedSpecGroups = visibleSpecGroups.filter( + (group) => group.id !== 'market_trust' + ); + const searchInputRef = useRef(null); const searchBoxRef = useRef(null); const restoredTrayRef = useRef(false); @@ -1274,13 +1294,21 @@ export default function CompareWorkspace({ matrix: specGroups.map((group) => ({ groupId: group.id, groupTitle: group.title, - rows: group.rows.map((row) => ({ - id: row.id, - label: row.label, - values: Object.fromEntries( - products.map((p) => [p.slug, row.renderValue(p)]) - ), - })), + // Like the CSV, no row that every product leaves empty. + rows: group.rows + .filter((row) => + compareRowIsShown( + products.map((p) => row.renderValue(p)), + false + ) + ) + .map((row) => ({ + id: row.id, + label: row.label, + values: Object.fromEntries( + products.map((p) => [p.slug, row.renderValue(p)]) + ), + })), })), }; const blob = new Blob([JSON.stringify(payload, null, 2)], { @@ -2257,20 +2285,8 @@ export default function CompareWorkspace({ - )} - {specGroups.map((group, groupIndex) => { - // Filter rows when `showDiffOnly` is enabled - const visibleRows = group.rows.filter((row) => { - if (!showDiffOnly || products.length < 2) return true; - const rendered = products.map((p) => - row.renderValue(p).trim() - ); - const uniqueVals = new Set(rendered); - return uniqueVals.size > 1; - }); - - if (visibleRows.length === 0) { - return null; - } + {visibleSpecGroups.map((group) => { + const visibleRows = group.rows; const totalCols = products.length + @@ -2285,7 +2301,7 @@ export default function CompareWorkspace({ ? specGroupHeading( specDict, group.id as SpecGroupKey, - groupIndex, + numberedSpecGroups.indexOf(group) + 1, getCategorySpecTemplate(activeCategory).has5g ) : group.title; diff --git a/apps/web/src/components/product/PhoneCollapsible.tsx b/apps/web/src/components/product/PhoneCollapsible.tsx index fdf34e9e..9edba20f 100644 --- a/apps/web/src/components/product/PhoneCollapsible.tsx +++ b/apps/web/src/components/product/PhoneCollapsible.tsx @@ -12,6 +12,7 @@ export default function PhoneCollapsible({ showMoreLabel, showLessLabel, openOnHash, + collapsible = true, children, }: { className: string; @@ -19,6 +20,11 @@ export default function PhoneCollapsible({ showLessLabel: string; /** Expand when the page is opened or navigated to this hash, e.g. "#specs". */ openOnHash?: string; + /** + * False when there is nothing past the first three children: the list then + * shows whole, with no toggle (DOS/Rate#1186). + */ + collapsible?: boolean; children: ReactNode; }) { const [open, setOpen] = useState(false); @@ -46,6 +52,12 @@ export default function PhoneCollapsible({ } }; + if (!collapsible) { + return ( +
{children}
+ ); + } + return ( <>
, + keySpecs: Record = {}, + product: Record = {} +) => + buildProductSpecGroups({ + specs: specs as ProductSpecifications, + keySpecs: keySpecs as KeySpecs, + product, + template: getCategorySpecTemplate(category), + ui: getSpecUiDictionary('vi'), + locale: 'vi', + }); + +const labelsOf = (groups: ReturnType, group: string) => + groups.find((g) => g.group === group)?.rows.map((r) => r.label) ?? null; + +function testProductPage() { + const ui = getSpecUiDictionary('vi'); + + // A GPS watch: no cellular value at all, so no cellular group; empty rows + // of other groups are left out, filled ones kept. + const watch = groupsFor('smartwatches', { + display: { size_inch: 1.9, type: 'LTPO OLED', resolution: '' }, + hardware: { chipset: 'Apple S11' }, + battery: { capacity_mah: 327 }, + cellular: { technology: '', data_speed: null, bands_4g: '' }, + connectivity: { bluetooth: '5.3' }, + }); + assert.equal(labelsOf(watch, 'cellular'), null); + assert.deepEqual(labelsOf(watch, 'display'), [ + ui.fields.screenSize, + ui.fields.panelType, + ]); + assert.deepEqual(labelsOf(watch, 'hardware'), [ui.fields.chipset]); + // Availability keeps its default commercial status. + assert.deepEqual(labelsOf(watch, 'availability'), [ + ui.fields.commercialStatus, + ]); + // A cellular watch keeps its 4G bands under the 4G-only label. + const lte = groupsFor('smartwatches', { + cellular: { has_5g: true, bands_4g: 'B1/B3', bands_5g: 'n78' }, + }); + assert.deepEqual(lte.find((g) => g.group === 'cellular')?.rows, [ + { label: ui.fields.bands4g, value: 'B1/B3' }, + ]); + for (const { rows } of watch) { + for (const { value } of rows) { + assert.ok(!isEmptySpecValue(value), `empty row: ${value}`); + } + } + + // Earbuds: an empty FCC row is gone, certifications stay; the hardware + // card keeps only the audio chip. + const buds = groupsFor( + 'earbuds', + { + hardware: { chipset: 'H2' }, + regulatory: { certifications: 'CE', fcc_approval: '-' }, + multimedia: { audio_features: 'ANC' }, + }, + { battery: '6 giờ' } + ); + assert.deepEqual(labelsOf(buds, 'regulatory'), [ui.fields.certifications]); + assert.deepEqual(labelsOf(buds, 'hardware'), [ui.fields.chipset]); + assert.deepEqual( + buds.map((g) => g.group), + ['multimedia', 'battery', 'hardware', 'availability', 'regulatory'] + ); + + // A group with nothing but empty values disappears. + const bare = groupsFor('earbuds', { + design: { dimensions: '', weight_g: 0, keys: null }, + }); + assert.equal(labelsOf(bare, 'design'), null); + + // Phones keep their filled rows and flat-column fallbacks. + const phone = groupsFor( + 'smartphones', + { hardware: { chipset: 'A18' } }, + {}, + { ram_gb: 8, storage_gb: 128, has_5g: true } + ); + assert.deepEqual( + phone.find((g) => g.group === 'hardware')?.rows.map((r) => r.value), + ['A18', '8 GB', '128 GB'] + ); + assert.equal( + phone.find((g) => g.group === 'cellular')?.rows[0]?.value, + ui.has5gYes + ); + console.warn(' ✅ Product page leaves out empty rows and groups'); +} + +function testCompare() { + assert.equal(compareRowIsShown(['-', '-'], false), false); + assert.equal(compareRowIsShown(['', ' - '], true), false); + // "-" stays where another column has a value. + assert.equal(compareRowIsShown(['-', 'LTE'], false), true); + assert.equal(compareRowIsShown(['-', 'LTE'], true), true); + assert.equal(compareRowIsShown(['LTE', 'LTE'], true), false); + assert.equal(compareRowIsShown(['LTE', 'LTE'], false), true); + assert.equal(compareRowIsShown(['LTE'], true), true); + assert.equal(compareRowIsShown(['-'], false), false); + console.warn(' ✅ Compare keeps "-" only beside a value'); +} + +function main() { + console.warn('🧪 Product spec rows suite (DOS/Rate#1186)'); + testProductPage(); + testCompare(); +} + +main(); diff --git a/apps/web/src/lib/__tests__/spec-template.test.ts b/apps/web/src/lib/__tests__/spec-template.test.ts index 94aa3dba..763772f3 100644 --- a/apps/web/src/lib/__tests__/spec-template.test.ts +++ b/apps/web/src/lib/__tests__/spec-template.test.ts @@ -216,7 +216,7 @@ async function testSurfaces() { // Compare headings come from the template too: no "5G" heading for a watch. assert.match( compare, - /specGroupHeading\(\s*specDict,\s*group\.id as SpecGroupKey,\s*groupIndex,\s*getCategorySpecTemplate\(activeCategory\)\.has5g\s*\)/ + /specGroupHeading\(\s*specDict,\s*group\.id as SpecGroupKey,\s*numberedSpecGroups\.indexOf\(group\) \+ 1,\s*getCategorySpecTemplate\(activeCategory\)\.has5g\s*\)/ ); const detail = readSource('../../app/[locale]/smartphones/[slug]/page.tsx'); assert.match(detail, /categorySlug=\{categorySlug\}/); @@ -304,7 +304,10 @@ async function main() { /const product = applyCategorySpecTemplate\(categorySlug, storedProduct\)/ ); assert.match(detail, /applyCategorySpecTemplate\(categorySlug, peer\)/); - assert.match(detail, /specTemplate\.groups\.map\(/); + assert.match( + detail, + /buildProductSpecGroups\(\{[^}]*template: specTemplate,/ + ); assert.doesNotMatch(detail, /specUi\.groups\.\w+\}/); const catalog = readSource('../../app/[locale]/smartphones/page.tsx'); assert.match( diff --git a/apps/web/src/lib/product-spec-rows.ts b/apps/web/src/lib/product-spec-rows.ts new file mode 100644 index 00000000..119aee69 --- /dev/null +++ b/apps/web/src/lib/product-spec-rows.ts @@ -0,0 +1,275 @@ +import type { KeySpecs, ProductSpecifications } from '@repo/shared-data'; +import { + specFieldApplies, + type CategorySpecTemplate, + type SpecGroupKey, +} from '@repo/shared-data/rules'; +import { localizeSpecValue, type SpecUiDictionary } from './spec-localizer'; + +export interface ProductSpecRow { + label: string; + value: string; +} + +export interface ProductSpecGroup { + group: SpecGroupKey; + rows: ProductSpecRow[]; +} + +/** The flat columns the spec section falls back to. */ +export interface ProductSpecFallbacks { + screen_size_inch?: number | null; + ram_gb?: number | null; + storage_gb?: number | null; + battery_mah?: number | null; + has_5g?: boolean | null; +} + +/** A value the page has nothing to show for: empty or a lone dash. */ +export function isEmptySpecValue(value: string | null | undefined): boolean { + const text = (value ?? '').trim(); + return text === '' || text === '-'; +} + +/** + * Whether compare shows a row (DOS/Rate#1186): not when no compared product + * has a value, so "-" stays only where another column has one. With + * `diffOnly`, rows where every column agrees are left out too. + */ +export function compareRowIsShown( + values: readonly string[], + diffOnly: boolean +): boolean { + if (values.length === 0) return true; + if (values.every(isEmptySpecValue)) return false; + if (!diffOnly || values.length < 2) return true; + return new Set(values.map((v) => v.trim())).size > 1; +} + +const joined = (parts: unknown[], separator: string): string => + parts.filter(Boolean).join(separator); + +/** + * The product page's spec groups in the category's template order, with the + * rows that have no value left out and a group with no row left out + * (DOS/Rate#1186). The page used to print those rows as "-". + */ +export function buildProductSpecGroups({ + specs, + keySpecs, + product, + template, + ui, + locale, +}: { + specs: ProductSpecifications | null | undefined; + keySpecs: KeySpecs | null | undefined; + product: ProductSpecFallbacks; + template: CategorySpecTemplate; + ui: SpecUiDictionary; + locale: string; +}): ProductSpecGroup[] { + const s = specs ?? ({} as ProductSpecifications); + const k = keySpecs ?? ({} as KeySpecs); + const f = ui.fields; + const loc = (v: string | number | boolean | null | undefined) => + localizeSpecValue(v, locale); + const shows = (group: SpecGroupKey, field: string) => + specFieldApplies(template, group, field); + const withUnit = (value: unknown, unit: string) => + value ? `${value} ${unit}` : ''; + const yesNo = (value: unknown, yes = ui.yes) => + value === true ? yes : value === false ? ui.no : ''; + const showBands5g = shows('cellular', 'bands_5g'); + + const groups: Record = { + display: [ + { + label: f.screenSize, + value: + withUnit(s.display?.size_inch, 'inch') || + withUnit(product.screen_size_inch, 'inch'), + }, + { label: f.panelType, value: loc(s.display?.type || k.screen) }, + { label: f.resolution, value: loc(s.display?.resolution) }, + { + label: f.refreshRate, + value: withUnit(s.display?.refresh_rate_hz, 'Hz'), + }, + { label: f.ppi, value: withUnit(s.display?.ppi, 'ppi') }, + { label: f.protection, value: loc(s.display?.protection) }, + { label: f.displayFeatures, value: loc(s.display?.features) }, + ], + hardware: [ + { label: f.chipset, value: loc(s.hardware?.chipset || k.chip) }, + shows('hardware', 'cpu') && { label: f.cpu, value: loc(s.hardware?.cpu) }, + shows('hardware', 'gpu') && { label: f.gpu, value: loc(s.hardware?.gpu) }, + shows('hardware', 'ram_gb') && { + label: f.ram, + value: + withUnit(s.hardware?.ram_gb, 'GB') || withUnit(product.ram_gb, 'GB'), + }, + shows('hardware', 'storage_gb') && { + label: f.storage, + value: + withUnit(s.hardware?.storage_gb, 'GB') || + withUnit(product.storage_gb, 'GB'), + }, + shows('hardware', 'storage_expansion') && { + label: f.storageExpansion, + value: loc(s.hardware?.storage_expansion), + }, + shows('hardware', 'os') && { label: f.os, value: loc(s.hardware?.os) }, + ], + camera: [ + { label: f.rearSetup, value: loc(s.camera?.rear_setup) }, + { + label: f.mainCamera, + value: loc(s.camera?.main_camera || k.camera), + }, + { label: f.secondCamera, value: loc(s.camera?.second_camera) }, + { label: f.thirdCamera, value: loc(s.camera?.third_camera) }, + { label: f.fourthCamera, value: loc(s.camera?.fourth_camera) }, + { label: f.frontCamera, value: loc(s.camera?.front_camera) }, + { + label: f.videoAndFlash, + value: loc(joined([s.camera?.video_recording, s.camera?.flash], ' | ')), + }, + ], + battery: [ + { + label: f.batteryCapacity, + value: + withUnit(s.battery?.capacity_mah, 'mAh') || + withUnit(product.battery_mah, 'mAh') || + loc(k.battery), + }, + { label: f.batteryType, value: loc(s.battery?.type) }, + { label: f.chargingTech, value: loc(s.battery?.charging) }, + { + label: f.fastCharging, + value: withUnit(s.battery?.fast_charging_w, 'W'), + }, + { + label: f.reverseCharging, + value: + typeof s.battery?.reverse_charging === 'string' + ? loc(s.battery.reverse_charging) + : yesNo(s.battery?.reverse_charging, ui.supported), + }, + ], + design: [ + { label: f.dimensions, value: loc(s.design?.dimensions) }, + { label: f.weight, value: withUnit(s.design?.weight_g, 'g') }, + { + label: f.materialsAndBuild, + value: loc(joined([s.design?.materials, s.design?.build], ' | ')), + }, + { label: f.resistance, value: loc(s.design?.resistance) }, + shows('design', 'biometrics') && { + label: f.biometrics, + value: loc(s.design?.biometrics), + }, + { label: f.physicalKeys, value: loc(s.design?.keys) }, + ], + cellular: [ + shows('cellular', 'has_5g') && { + label: f.support5g, + value: (s.cellular?.has_5g ?? product.has_5g) ? ui.has5gYes : ui.no, + }, + { label: f.networkTech, value: loc(s.cellular?.technology) }, + { label: f.simType, value: loc(s.cellular?.sim_type) }, + { label: f.dataSpeed, value: loc(s.cellular?.data_speed) }, + { + label: showBands5g ? f.bands5g4g : f.bands4g, + value: loc( + joined( + [showBands5g ? s.cellular?.bands_5g : null, s.cellular?.bands_4g], + ' | ' + ) + ), + }, + { + label: f.volteVowifi, + value: joined( + [ + s.cellular?.volte ? 'VoLTE' : null, + s.cellular?.vowifi ? 'VoWiFi' : null, + ], + ' + ' + ), + }, + ], + connectivity: [ + shows('connectivity', 'wlan') && { + label: f.wlan, + value: loc(s.connectivity?.wlan), + }, + { label: f.bluetooth, value: loc(s.connectivity?.bluetooth) }, + { label: f.usbPort, value: loc(s.connectivity?.usb) }, + shows('connectivity', 'nfc') && { + label: f.nfc, + value: yesNo(s.connectivity?.nfc), + }, + shows('connectivity', 'location') && { + label: f.locationGps, + value: loc(s.connectivity?.location), + }, + { + label: f.sensorsAndOther, + value: loc( + joined([s.connectivity?.sensors, s.connectivity?.other], ' | ') + ), + }, + ], + multimedia: [ + { label: f.loudspeaker, value: loc(s.multimedia?.loudspeaker) }, + shows('multimedia', 'headphone_jack_35mm') && { + label: f.jack35mm, + value: yesNo(s.multimedia?.headphone_jack_35mm), + }, + { label: f.audioFeatures, value: loc(s.multimedia?.audio_features) }, + ], + availability: [ + { label: f.announcedDate, value: loc(s.availability?.announced) }, + { + label: f.commercialStatus, + value: s.availability?.status + ? loc(s.availability.status) + : ui.availableStatus, + }, + { label: f.msrpPrice, value: loc(s.availability?.prices_msrp) }, + ], + regulatory: [ + { + label: f.certifications, + value: loc(s.regulatory?.certifications), + }, + { label: f.fccApproval, value: loc(s.regulatory?.fcc_approval) }, + { + label: f.sarLevel, + value: joined( + [ + s.regulatory?.sar_head + ? `${f.sarHeadPrefix}: ${s.regulatory.sar_head}` + : null, + s.regulatory?.sar_body + ? `${f.sarBodyPrefix}: ${s.regulatory.sar_body}` + : null, + ], + ' | ' + ), + }, + ], + }; + + return template.groups + .map((group) => ({ + group, + rows: groups[group].filter( + (row): row is ProductSpecRow => + row !== false && !isEmptySpecValue(row.value) + ), + })) + .filter(({ rows }) => rows.length > 0); +}