(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);
+}