diff --git a/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx b/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx index e12b4ecb4..55d27fcc2 100644 --- a/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx +++ b/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx @@ -258,7 +258,7 @@ export async function ProductDetailView({ ? bayesianSummary.bayesianRating : rawUserRating; - const keySpecLine = buildKeySpecLine(product); + const keySpecLine = buildKeySpecLine(product, categorySlug); const mainImage = firstImageUrl(product.images); const canonicalUrl = `https://rate.vn${categoryBasePath}/${product.slug}`; @@ -1562,6 +1562,7 @@ export async function ProductDetailView({ brand={product.brand} specifications={specs || {}} keySpecs={keySpecs || {}} + categorySlug={categorySlug} radarSignal={radarSignal} /> diff --git a/apps/web/src/app/api/v1/rate/compare/route.ts b/apps/web/src/app/api/v1/rate/compare/route.ts index 7c8bfa1f2..7472de454 100644 --- a/apps/web/src/app/api/v1/rate/compare/route.ts +++ b/apps/web/src/app/api/v1/rate/compare/route.ts @@ -7,6 +7,7 @@ import { } from '@/server/rate'; import { computeProductRadarSignal } from '@/server/rate/deals'; import { radarExportFields } from '@/lib/deal-radar'; +import { applyCategorySpecTemplate } from '@repo/shared-data/rules'; import { PUBLIC_API_CORS_HEADERS, jsonApiError, @@ -61,7 +62,8 @@ export async function GET(request: NextRequest) { data: products.map((p) => { const offers = offersByMasterId.get(p.id) ?? []; return { - ...p, + // The category's spec view, as /compare shows it (DOS/Rate#1166). + ...applyCategorySpecTemplate(lockedCategory, p), canonical_href: getMasterProductHref(p), deal_radar: radarExportFields(computeProductRadarSignal(p, offers)), }; diff --git a/apps/web/src/app/api/v1/rate/products/[slug]/route.ts b/apps/web/src/app/api/v1/rate/products/[slug]/route.ts index 37dea9fe0..c51fbed5a 100644 --- a/apps/web/src/app/api/v1/rate/products/[slug]/route.ts +++ b/apps/web/src/app/api/v1/rate/products/[slug]/route.ts @@ -8,6 +8,8 @@ import { } from '@/server/rate'; import { computeProductRadarSignal } from '@/server/rate/deals'; import { isOfferUnavailable } from '@/lib/product-market-stats'; +import { getMasterProductCategorySlug } from '@/lib/rate-domain'; +import { applyCategorySpecTemplate } from '@repo/shared-data/rules'; import { PUBLIC_API_CORS_HEADERS, jsonApiError, @@ -29,6 +31,9 @@ export async function OPTIONS() { * Returns canonical Master Product (10 Field Groups), cross-platform merchant offers, * time-series Price History + Fake Discount Detection, and JReviews Dual-Rating & AI Review Summary. * Designed for Rate Web UI, Rate Browser Extension (MV3), and ecosystem consumers. + * The product carries the category's spec view, as rate.vn shows it: no + * screen, RAM, storage or camera for earbuds and no 5G for a watch + * (DOS/Rate#1166). */ export async function GET(_request: NextRequest, context: RouteContext) { try { @@ -47,15 +52,19 @@ export async function GET(_request: NextRequest, context: RouteContext) { ); } - const product = await getCachedMasterProductBySlug(cleanSlug); + const storedProduct = await getCachedMasterProductBySlug(cleanSlug); - if (!product) { + if (!storedProduct) { return NextResponse.json( { ok: false, error: 'Product not found', code: 'NOT_FOUND' }, { status: 404, headers: PUBLIC_API_CORS_HEADERS } ); } + const product = applyCategorySpecTemplate( + getMasterProductCategorySlug(storedProduct), + storedProduct + ); const [offers, reviews] = await Promise.all([ getCachedMasterProductOffers(product.id), getCachedMasterProductReviews(product.id), diff --git a/apps/web/src/app/api/v1/rate/products/route.ts b/apps/web/src/app/api/v1/rate/products/route.ts index 4bb9be5fb..d88feabe2 100644 --- a/apps/web/src/app/api/v1/rate/products/route.ts +++ b/apps/web/src/app/api/v1/rate/products/route.ts @@ -7,6 +7,8 @@ import { } from '@/server/rate'; import { computeProductRadarSignal } from '@/server/rate/deals'; import { radarExportFields } from '@/lib/deal-radar'; +import { getMasterProductCategorySlug } from '@/lib/rate-domain'; +import { applyCategorySpecTemplate } from '@repo/shared-data/rules'; import { PUBLIC_API_CORS_HEADERS, jsonApiError, @@ -48,6 +50,8 @@ const VALID_PRODUCT_SORTS = new Set< * GET /api/v1/rate/products * Structured JSON Backend API for Web Client, Browser Extension MV3, and Partners. * Supports 10-Field-Groups multi-criteria filtering, sorting, and optional directory facets. + * Each product carries its category's spec view, as rate.vn shows it + * (DOS/Rate#1166); filters still match the stored columns. */ export async function GET(request: NextRequest) { try { @@ -145,7 +149,7 @@ export async function GET(request: NextRequest) { data: products.map((p) => { const offers = offersByMasterId.get(p.id) ?? []; return { - ...p, + ...applyCategorySpecTemplate(getMasterProductCategorySlug(p), p), deal_radar: radarExportFields(computeProductRadarSignal(p, offers)), }; }), diff --git a/apps/web/src/components/compare/CompareWorkspace.tsx b/apps/web/src/components/compare/CompareWorkspace.tsx index a43768000..01d8e9eb8 100644 --- a/apps/web/src/components/compare/CompareWorkspace.tsx +++ b/apps/web/src/components/compare/CompareWorkspace.tsx @@ -23,6 +23,13 @@ import type { MasterProduct, ProductSpecifications, } from '@repo/shared-data'; +import { + applyCategorySpecTemplate, + getCategorySpecTemplate, + specFieldApplies, + type CategorySpecTemplate, + type SpecGroupKey, +} from '@repo/shared-data/rules'; import { type CanonicalCategorySlug, MAX_VALID_VND_PRICE, @@ -55,7 +62,11 @@ import { setCompareTray, } from './compare-tray'; import { upsertWatchlistItem } from '@/components/watchlist/watchlist-store'; -import { getSpecUiDictionary, localizeSpecValue } from '@/lib/spec-localizer'; +import { + getSpecUiDictionary, + localizeSpecValue, + specGroupHeading, +} from '@/lib/spec-localizer'; import ContentText from '@/components/elementary/ContentText'; import { productImageUrl } from '@/lib/rate-domain-core'; @@ -75,6 +86,8 @@ interface SpecRowDef { renderValue: (p: MasterProduct) => string; numericValue?: (p: MasterProduct) => number | null; higherIsBetter?: boolean; + /** Whether the category's spec template has this row (DOS/Rate#1166). */ + appliesTo?: (template: CategorySpecTemplate) => boolean; } interface SpecGroupDef { @@ -167,7 +180,7 @@ function buildCategoryTabs(t: CompareTranslator): Array<{ ]; } -function buildSpecGroups(t: CompareTranslator): SpecGroupDef[] { +export function buildSpecGroups(t: CompareTranslator): SpecGroupDef[] { return [ { id: 'market_trust', @@ -295,6 +308,7 @@ function buildSpecGroups(t: CompareTranslator): SpecGroupDef[] { { id: 'display_size', label: t('rowDisplaySize'), + appliesTo: (tpl) => tpl.screen, renderValue: (p) => { const size = resolveCompareDisplaySizeInch(p); return size != null ? `${size}"` : '-'; @@ -373,6 +387,7 @@ function buildSpecGroups(t: CompareTranslator): SpecGroupDef[] { }, { id: 'hw_cpu_gpu', + appliesTo: (tpl) => specFieldApplies(tpl, 'hardware', 'cpu'), label: t('rowHwCpuGpu'), renderValue: (p) => { const specs = (p.specifications || {}) as ProductSpecifications; @@ -384,6 +399,7 @@ function buildSpecGroups(t: CompareTranslator): SpecGroupDef[] { }, { id: 'hw_ram', + appliesTo: (tpl) => tpl.ram, label: t('rowHwRam'), renderValue: (p) => { const ram = resolveCompareRamGb(p); @@ -394,6 +410,7 @@ function buildSpecGroups(t: CompareTranslator): SpecGroupDef[] { }, { id: 'hw_storage', + appliesTo: (tpl) => tpl.storage, label: t('rowHwStorage'), renderValue: (p) => { const rom = resolveCompareStorageGb(p); @@ -405,6 +422,9 @@ function buildSpecGroups(t: CompareTranslator): SpecGroupDef[] { }, { id: 'hw_os_exp', + appliesTo: (tpl) => + specFieldApplies(tpl, 'hardware', 'os') || + specFieldApplies(tpl, 'hardware', 'storage_expansion'), label: t('rowHwOsExp'), renderValue: (p) => { const specs = (p.specifications || {}) as ProductSpecifications; @@ -599,6 +619,7 @@ function buildSpecGroups(t: CompareTranslator): SpecGroupDef[] { rows: [ { id: 'cell_5g', + appliesTo: (tpl) => tpl.has5g, label: t('rowCell5g'), renderValue: (p) => { const specs = (p.specifications || {}) as ProductSpecifications; @@ -655,6 +676,7 @@ function buildSpecGroups(t: CompareTranslator): SpecGroupDef[] { }, { id: 'conn_nfc', + appliesTo: (tpl) => specFieldApplies(tpl, 'connectivity', 'nfc'), label: t('rowConnNfc'), renderValue: (p) => { const specs = (p.specifications || {}) as ProductSpecifications; @@ -768,6 +790,28 @@ function buildSpecGroups(t: CompareTranslator): SpecGroupDef[] { ]; } +/** + * The spec groups and rows a category's template has, in its order, so an + * earbud shows no screen, RAM or camera row and a watch no 5G row + * (DOS/Rate#1142, DOS/Rate#1166). The market group always comes first. + */ +export function filterSpecGroupsForCategory( + groups: readonly SpecGroupDef[], + categorySlug: string +): SpecGroupDef[] { + const template = getCategorySpecTemplate(categorySlug); + const order = (id: string) => + id === 'market_trust' ? -1 : template.groups.indexOf(id as SpecGroupKey); + return groups + .filter((group) => order(group.id) !== -1 || group.id === 'market_trust') + .sort((a, b) => order(a.id) - order(b.id)) + .map((group) => ({ + ...group, + rows: group.rows.filter((row) => row.appliesTo?.(template) ?? true), + })) + .filter((group) => group.rows.length > 0); +} + export default function CompareWorkspace({ initialProducts, catalogCandidates, @@ -779,14 +823,14 @@ export default function CompareWorkspace({ const router = useRouter(); const specDict = useMemo(() => getSpecUiDictionary(locale), [locale]); const categoryTabs = useMemo(() => buildCategoryTabs(t), [t]); - const specGroups = useMemo(() => buildSpecGroups(t), [t]); + const allSpecGroups = useMemo(() => buildSpecGroups(t), [t]); const [activeCategory, setActiveCategory] = useState( () => initialProducts.length > 0 ? getMasterProductCategorySlug(initialProducts[0]!) : initialCategory ); - const [products, setProducts] = useState(() => { + const [storedProducts, setProducts] = useState(() => { const targetCat = initialProducts.length > 0 ? getMasterProductCategorySlug(initialProducts[0]!) @@ -806,6 +850,17 @@ export default function CompareWorkspace({ const [highlightWinners, setHighlightWinners] = useState(true); const [copiedLink, setCopiedLink] = useState(false); const radarIndex = useDealRadarIndex(); + // Every row, verdict and export reads the category's view of a product, + // like the product page (DOS/Rate#1166). + const products = useMemo( + () => + storedProducts.map((p) => applyCategorySpecTemplate(activeCategory, p)), + [storedProducts, activeCategory] + ); + const specGroups = useMemo( + () => filterSpecGroupsForCategory(allSpecGroups, activeCategory), + [allSpecGroups, activeCategory] + ); const searchInputRef = useRef(null); const searchBoxRef = useRef(null); @@ -2202,7 +2257,7 @@ export default function CompareWorkspace({ - )} - {specGroups.map((group) => { + {specGroups.map((group, groupIndex) => { // Filter rows when `showDiffOnly` is enabled const visibleRows = group.rows.filter((row) => { if (!showDiffOnly || products.length < 2) return true; @@ -2222,11 +2277,17 @@ export default function CompareWorkspace({ 1 + (products.length < MAX_COMPARE_ITEMS ? 1 : 0); + // Numbered in the category's order, and a watch's + // cellular heading without "5G" (DOS/Rate#1166). The + // market group comes first, so spec groups start at 1. const groupTitle = group.id in specDict.groups - ? specDict.groups[ - group.id as keyof typeof specDict.groups - ] + ? specGroupHeading( + specDict, + group.id as SpecGroupKey, + groupIndex, + getCategorySpecTemplate(activeCategory).has5g + ) : group.title; return ( diff --git a/apps/web/src/components/leaderboard/LeaderboardExplorer.tsx b/apps/web/src/components/leaderboard/LeaderboardExplorer.tsx index 5b0cf660c..57be9ac29 100644 --- a/apps/web/src/components/leaderboard/LeaderboardExplorer.tsx +++ b/apps/web/src/components/leaderboard/LeaderboardExplorer.tsx @@ -36,6 +36,7 @@ import { useDealRadarIndex } from '@/components/radar/use-deal-radar'; import CompareQuickToggle from '@/components/compare/CompareQuickToggle'; import { escapeCsvCell } from '@/lib/csv-safe'; import type { KeySpecs } from '@repo/shared-data'; +import { applyCategorySpecTemplate } from '@repo/shared-data/rules'; import { BsArrowLeftRight, BsAwardFill, @@ -409,10 +410,12 @@ export default function LeaderboardExplorer({ return map; }, [products]); + // Rows read the category's view of a product, like the product page: no + // RAM or storage pill for earbuds (DOS/Rate#1166). const categoryProducts = useMemo(() => { - return products.filter( - (p) => getMasterProductCategorySlug(p) === activeCategory - ); + return products + .filter((p) => getMasterProductCategorySlug(p) === activeCategory) + .map((p) => applyCategorySpecTemplate(activeCategory, p)); }, [products, activeCategory]); const availableBrands = useMemo(() => { diff --git a/apps/web/src/components/leaderboard/ProductSpecsExportToolbar.tsx b/apps/web/src/components/leaderboard/ProductSpecsExportToolbar.tsx index 2dc9724dd..25b6e3b1d 100644 --- a/apps/web/src/components/leaderboard/ProductSpecsExportToolbar.tsx +++ b/apps/web/src/components/leaderboard/ProductSpecsExportToolbar.tsx @@ -4,6 +4,13 @@ import { useState } from 'react'; import { useTranslations } from 'next-intl'; import ExportMenu from '@/components/elementary/ExportMenu'; import type { KeySpecs, ProductSpecifications } from '@repo/shared-data'; +import { + applyCategorySpecTemplate, + getCategorySpecTemplate, + specFieldApplies, + type CategorySpecTemplate, + type SpecGroupKey, +} from '@repo/shared-data/rules'; import { RADAR_CSV_HEADERS, radarCsvCells, @@ -62,13 +69,55 @@ function joinSpecParts(parts: Array): string { return valid.join(' | ') || '-'; } +type SpecExportRow = [string, string, string]; + +// The template group each row reads, and the fields that make it a row the +// category has (any one of them is enough). +const EXPORT_ROW_FIELDS: ReadonlyArray<[SpecGroupKey, readonly string[]]> = [ + ['display', ['size_inch', 'type', 'refresh_rate_hz']], + ['display', ['resolution', 'protection']], + ['hardware', ['chipset', 'cpu', 'gpu']], + ['hardware', ['ram_gb', 'storage_gb', 'os']], + ['camera', ['main_camera']], + ['camera', ['video_recording', 'front_camera']], + ['battery', ['capacity_mah']], + ['design', ['dimensions', 'weight_g', 'resistance']], + ['cellular', ['technology', 'sim_type', 'data_speed']], + ['connectivity', ['wlan', 'bluetooth', 'usb']], + ['multimedia', ['loudspeaker', 'audio_features']], + ['availability', ['announced']], + ['regulatory', ['certifications']], +]; + +const exportRowApplies = ( + template: CategorySpecTemplate, + index: number +): boolean => { + const entry = EXPORT_ROW_FIELDS[index]; + // A row added without an entry stays visible rather than vanishing. + if (!entry) return true; + const [group, fields] = entry; + return fields.some((field) => specFieldApplies(template, group, field)); +}; + +/** + * The spec rows a product exports. With a category, only the rows and values + * its spec template has, as on the product page: no screen, RAM, ROM or + * camera for earbuds and no 5G for a watch (DOS/Rate#1166). + */ export function buildProductSpecExportRows( specifications?: ProductSpecifications | null, - keySpecs?: KeySpecs | null -): Array<[string, string, string]> { - const specs = specifications ?? {}; - const keys = keySpecs ?? {}; - return [ + keySpecs?: KeySpecs | null, + categorySlug?: string | null +): SpecExportRow[] { + const scoped = applyCategorySpecTemplate(categorySlug, { + specifications: specifications ?? {}, + key_specs: keySpecs ?? {}, + }); + const template = getCategorySpecTemplate(categorySlug); + const specs = (scoped.specifications ?? {}) as ProductSpecifications; + const keys = (scoped.key_specs ?? {}) as KeySpecs; + const rows: SpecExportRow[] = [ [ '1. Display', 'Size & Panel', @@ -195,6 +244,7 @@ export function buildProductSpecExportRows( ]), ], ]; + return rows.filter((_, index) => exportRowApplies(template, index)); } interface ProductSpecsExportToolbarProps { @@ -203,6 +253,8 @@ interface ProductSpecsExportToolbarProps { readonly brand: string; readonly specifications: ProductSpecifications; readonly keySpecs: KeySpecs; + /** Scopes the rows and the JSON to the category's spec template. */ + readonly categorySlug?: string | null; readonly radarSignal?: RadarSignal | null; } @@ -212,6 +264,7 @@ export default function ProductSpecsExportToolbar({ brand, specifications, keySpecs, + categorySlug, radarSignal, }: ProductSpecsExportToolbarProps) { const t = useTranslations('productDetail'); @@ -223,10 +276,14 @@ export default function ProductSpecsExportToolbar({ const handleCopySpecsMarkdown = async () => { if (typeof navigator === 'undefined') return; - const rows = buildProductSpecExportRows(specifications, keySpecs); + const rows = buildProductSpecExportRows( + specifications, + keySpecs, + categorySlug + ); const md = [ - `### 📋 Rate.vn 10-Field-Groups Technical Specifications: ${safeProductName} (${safeBrand})`, + `### 📋 Rate.vn Technical Specifications: ${safeProductName} (${safeBrand})`, `- **Price signal:** ${radarMarkdownCell(radarSignal)}`, '', '| Field Group | Attribute | Specification |', @@ -248,7 +305,11 @@ export default function ProductSpecsExportToolbar({ const handleDownloadSpecsCsv = () => { if (typeof document === 'undefined') return; - const rows = buildProductSpecExportRows(specifications, keySpecs); + const rows = buildProductSpecExportRows( + specifications, + keySpecs, + categorySlug + ); const headers = [ 'Slug', 'Product', @@ -289,15 +350,19 @@ export default function ProductSpecsExportToolbar({ const handleDownloadSpecsJson = () => { if (typeof document === 'undefined') return; + const scoped = applyCategorySpecTemplate(categorySlug, { + specifications, + key_specs: keySpecs, + }); const payload = { slug: safeSlug, name: safeProductName, brand: safeBrand, - schema: 'Rate.vn 10-Field-Groups (GSMArena + PhoneArena)', + schema: 'Rate.vn spec field groups (GSMArena + PhoneArena)', exported_at: new Date().toISOString(), deal_radar: radarExportFields(radarSignal), - key_specs: keySpecs, - specifications, + key_specs: scoped.key_specs, + specifications: scoped.specifications, }; const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json;charset=utf-8;', diff --git a/apps/web/src/lib/__tests__/spec-template.test.ts b/apps/web/src/lib/__tests__/spec-template.test.ts index 3bc2a9727..6ed6529a1 100644 --- a/apps/web/src/lib/__tests__/spec-template.test.ts +++ b/apps/web/src/lib/__tests__/spec-template.test.ts @@ -23,7 +23,225 @@ const headings = (category: string, locale: string) => { ); }; -function main() { +// A stored earbud and a cellular watch the way the enricher filled them: +// phone-shaped, with a screen, RAM, ROM, cameras and 5G. +const phoneShapedSpecs = { + display: { size_inch: 1, type: 'OLED', refresh_rate_hz: 60, ppi: 326 }, + hardware: { chipset: 'H2', cpu: 'c', ram_gb: 1, storage_gb: 64, os: 'o' }, + camera: { main_camera: '2 mic', front_camera: 'f' }, + battery: { capacity_mah: 500 }, + cellular: { + has_5g: true, + technology: 'LTE / 5G', + data_speed: 'LTE, 5G', + bands_5g: 'n78', + sim_type: 'eSIM', + }, + connectivity: { bluetooth: '5.3', nfc: true }, + multimedia: { audio_features: 'ANC' }, +}; +const phoneShapedProduct = { + id: 'm1', + slug: 'phone-shaped', + name: 'Phone shaped', + brand: 'Apple', + ram_gb: 1, + storage_gb: 64, + screen_size_inch: 1, + has_5g: true, + specifications: phoneShapedSpecs, + key_specs: { + screen: '1 inch', + chip: 'H2', + camera: 'mic', + ram: '1GB', + storage: '64GB', + battery: '500 mAh', + }, +}; + +const SCREEN_RAM_CAMERA_RE = + /\b(?:\d+(?:\.\d+)?\s*(?:"|inch|Hz|ppi)|\d+\s*GB|RAM|ROM|mic|camera)\b/i; +const FIVE_G_RE = /\b5G\b/; + +async function testSurfaces() { + // /compare (DOS/Rate#1166): an earbud has no screen, hardware memory, + // camera or cellular row; a watch no 5G row and no 5G in its network text. + const nodeModule = await import('node:module'); + const extMap = ( + nodeModule.default as unknown as { + _extensions?: Record void>; + } + )._extensions; + if (extMap && !extMap['.css']) { + extMap['.css'] = (mod) => { + mod.exports = {}; + }; + } + const { buildSpecGroups, filterSpecGroupsForCategory } = + await import('../../components/compare/CompareWorkspace'); + const t = ((key: string) => key) as unknown as Parameters< + typeof buildSpecGroups + >[0]; + const all = buildSpecGroups(t); + const rowIds = (category: string) => + filterSpecGroupsForCategory(all, category).flatMap((g) => + g.rows.map((r) => r.id) + ); + const groupIds = (category: string) => + filterSpecGroupsForCategory(all, category).map((g) => g.id); + assert.deepEqual( + groupIds('smartphones'), + all.map((g) => g.id) + ); + assert.deepEqual(rowIds('smartphones'), rowIds('tablets')); + assert.deepEqual(groupIds('earbuds'), [ + 'market_trust', + 'multimedia', + 'battery', + 'connectivity', + 'hardware', + 'design', + 'availability', + 'regulatory', + ]); + const budRows = rowIds('earbuds'); + for (const id of [ + 'display_size', + 'display_refresh', + 'hw_cpu_gpu', + 'hw_ram', + 'hw_storage', + 'hw_os_exp', + 'cam_main', + 'cell_5g', + 'conn_nfc', + ]) { + assert.ok(!budRows.includes(id), `earbuds compare has no ${id}`); + } + assert.ok(budRows.includes('hw_chipset')); + const watchRows = rowIds('smartwatches'); + assert.ok(!watchRows.includes('cell_5g')); + assert.ok(!groupIds('smartwatches').includes('camera')); + assert.ok(watchRows.includes('hw_ram') && watchRows.includes('cell_sim')); + + const render = (category: string) => { + const product = applyCategorySpecTemplate( + category, + phoneShapedProduct as never + ); + return filterSpecGroupsForCategory(all, category) + .filter((g) => g.id !== 'market_trust') + .flatMap((g) => g.rows.map((r) => `${r.id}=${r.renderValue(product)}`)); + }; + const budCells = render('earbuds'); + assert.ok( + budCells.every( + (c) => c.startsWith('hw_chipset=') || !SCREEN_RAM_CAMERA_RE.test(c) + ), + budCells.join(' | ') + ); + const watchCells = render('smartwatches'); + assert.ok( + watchCells.every((c) => !FIVE_G_RE.test(c)), + watchCells.join(' | ') + ); + assert.ok(watchCells.includes('cell_sim=eSIM | LTE'), watchCells.join(' | ')); + + // Spec export (Markdown, CSV and JSON share the rows). + const { buildProductSpecExportRows } = + await import('../../components/leaderboard/ProductSpecsExportToolbar'); + const exportRows = (category?: string) => + buildProductSpecExportRows( + phoneShapedSpecs as never, + phoneShapedProduct.key_specs, + category + ); + assert.equal(exportRows().length, 13); + assert.equal(exportRows('smartphones').length, 13); + const budExport = exportRows('earbuds'); + assert.deepEqual( + budExport.map(([, attr]) => attr), + [ + 'Chipset & CPU/GPU', + 'Capacity & Fast Charging', + 'Dimensions, Weight & IP Rating', + 'WLAN, Bluetooth & USB', + 'Audio & Speakers', + 'Release & MSRP', + 'Certifications & SAR', + ] + ); + assert.equal(budExport[0]?.[2], 'H2'); + assert.ok( + budExport.every(([, , v]) => !SCREEN_RAM_CAMERA_RE.test(v)), + JSON.stringify(budExport) + ); + const watchExport = exportRows('smartwatches'); + assert.ok(!watchExport.some(([grp]) => grp.startsWith('3. Camera'))); + assert.ok( + watchExport.every(([, , v]) => !FIVE_G_RE.test(v)), + JSON.stringify(watchExport) + ); + assert.ok( + watchExport.some(([, , v]) => v === 'LTE | eSIM | LTE'), + JSON.stringify(watchExport) + ); + + // The key spec line outside the product page. + const { buildKeySpecLine } = await import('../product-competitors'); + assert.equal( + buildKeySpecLine(phoneShapedProduct as never, 'earbuds'), + 'H2 • 500 mAh' + ); + assert.equal( + buildKeySpecLine(phoneShapedProduct as never), + 'H2 • 1GB • 64GB • 500 mAh' + ); + + // The leaderboard rows, the export toolbar on the product page and REST + // API v1 read the scoped product. + const leaderboard = readSource( + '../../components/leaderboard/LeaderboardExplorer.tsx' + ); + assert.match( + leaderboard, + /\.map\(\(p\) => applyCategorySpecTemplate\(activeCategory, p\)\)/ + ); + const compare = readSource('../../components/compare/CompareWorkspace.tsx'); + assert.match( + compare, + /storedProducts\.map\(\(p\) => applyCategorySpecTemplate\(activeCategory, p\)\)/ + ); + // 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*\)/ + ); + const detail = readSource('../../app/[locale]/smartphones/[slug]/page.tsx'); + assert.match(detail, /categorySlug=\{categorySlug\}/); + assert.match(detail, /buildKeySpecLine\(product, categorySlug\)/); + const apiList = readSource('../../app/api/v1/rate/products/route.ts'); + assert.match( + apiList, + /\.\.\.applyCategorySpecTemplate\(getMasterProductCategorySlug\(p\), p\)/ + ); + const apiDetail = readSource( + '../../app/api/v1/rate/products/[slug]/route.ts' + ); + assert.match( + apiDetail, + /const product = applyCategorySpecTemplate\(\s*getMasterProductCategorySlug\(storedProduct\),\s*storedProduct\s*\)/ + ); + const apiCompare = readSource('../../app/api/v1/rate/compare/route.ts'); + assert.match( + apiCompare, + /\.\.\.applyCategorySpecTemplate\(lockedCategory, p\)/ + ); + console.warn(' ✅ Compare, leaderboard, export and API v1 scoped'); +} + +async function main() { console.warn('🧪 Category spec template suite (DOS/Rate#1142)'); // Phones keep the ten numbered groups, worded as before. @@ -114,7 +332,11 @@ function main() { /applyCategorySpecTemplate\(\s*getMasterProductCategorySlug/ ); + await testSurfaces(); console.warn(' ✅ Category spec templates passed'); } -main(); +main().catch((error: unknown) => { + console.error(error); + process.exit(1); +}); diff --git a/apps/web/src/lib/product-competitors.ts b/apps/web/src/lib/product-competitors.ts index 41c07c69f..9f5df65f5 100644 --- a/apps/web/src/lib/product-competitors.ts +++ b/apps/web/src/lib/product-competitors.ts @@ -3,6 +3,7 @@ import type { MasterProduct, ProductSpecifications, } from '@repo/shared-data'; +import { applyCategorySpecTemplate } from '@repo/shared-data/rules'; import { resolveCompareBatteryMah, resolveCompareRamGb, @@ -103,16 +104,24 @@ export function peerPriceDelta( /** * "Chip • RAM • storage • battery" for the share card, from key_specs with a - * fallback to the 10-group specifications and top-level columns. + * fallback to the 10-group specifications and top-level columns. With a + * category it reads only what the category's spec template has, so an + * earbud never shows RAM or storage (DOS/Rate#1166). */ export function buildKeySpecLine( - product: Pick< + storedProduct: Pick< MasterProduct, 'key_specs' | 'specifications' | 'ram_gb' | 'storage_gb' | 'battery_mah' - > + >, + categorySlug?: string | null ): string { - if (!product || typeof product !== 'object' || Array.isArray(product)) + if ( + !storedProduct || + typeof storedProduct !== 'object' || + Array.isArray(storedProduct) + ) return ''; + const product = applyCategorySpecTemplate(categorySlug, storedProduct); const keySpecs = ( product.key_specs && typeof product.key_specs === 'object' && diff --git a/packages/shared-data/src/__tests__/shared-data.test.ts b/packages/shared-data/src/__tests__/shared-data.test.ts index 869241bf4..9d854b6d5 100644 --- a/packages/shared-data/src/__tests__/shared-data.test.ts +++ b/packages/shared-data/src/__tests__/shared-data.test.ts @@ -942,6 +942,70 @@ function testCategorySpecTemplates(): void { assert.equal(watchStoredSpecs.hardware?.gpu, 'g'); assert.equal(watchStoredSpecs.camera, undefined); + // The free-text network fields lose their 5G parts on the page only + // (DOS/Rate#1166); a 5GHz Wi-Fi mention is not 5G. + const lteWatch = { + specifications: { + cellular: { + technology: 'LTE / 5G', + data_speed: 'LTE Cat 4, 5G NR', + sim_type: 'eSIM', + }, + connectivity: { wlan: 'Wi-Fi 802.11 b/g/n, 5GHz' }, + }, + }; + const lteWatchShown = rules.applyCategorySpecTemplate( + 'smartwatches', + lteWatch + ).specifications as unknown as Record>; + assert.deepEqual(lteWatchShown.cellular, { + technology: 'LTE', + data_speed: 'LTE Cat 4', + sim_type: 'eSIM', + }); + assert.deepEqual( + lteWatchShown.connectivity, + lteWatch.specifications.connectivity + ); + assert.deepEqual( + rules.applyCategorySpecTemplate('smartwatches', { + specifications: { cellular: { technology: '5G' } }, + }).specifications, + { cellular: {} } + ); + assert.deepEqual( + rules.applyCategorySpecTemplate('smartwatches', lteWatch, { + forStorage: true, + }).specifications, + lteWatch.specifications + ); + assert.equal(lteWatch.specifications.cellular.technology, 'LTE / 5G'); + // 5G qualifiers and bracketed notes go with it; other text stays. + const shownTech = (technology: string) => + ( + rules.applyCategorySpecTemplate('smartwatches', { + specifications: { cellular: { technology } }, + }).specifications as { cellular: { technology?: string } } + ).cellular.technology; + assert.equal(shownTech('HSPA, LTE-A, 5G SA/NSA/Sub6'), 'HSPA, LTE-A'); + assert.equal(shownTech('LTE / 5G NR Sub-6'), 'LTE'); + assert.equal(shownTech('LTE (5G RedCap)'), 'LTE'); + assert.equal(shownTech('5G-ready LTE'), 'LTE'); + assert.equal(shownTech('LTE (Cat 4) / 5G'), 'LTE (Cat 4)'); + assert.equal(shownTech('GSM / LTE'), 'GSM / LTE'); + assert.equal( + shownTech(shownTech('HSPA, LTE-A, 5G SA/NSA/Sub6') ?? ''), + 'HSPA, LTE-A' + ); + assert.equal( + ( + rules.applyCategorySpecTemplate('smartwatches', { + specifications: { cellular: { sim_type: 'eSIM (5G)' } }, + }).specifications as { cellular: { sim_type?: string } } + ).cellular.sim_type, + 'eSIM' + ); + // Idempotent, never mutates the input, and leaves absent columns absent. assert.deepEqual(rules.applyCategorySpecTemplate('earbuds', buds), buds); assert.equal(phoneShaped.ram_gb, 1); diff --git a/packages/shared-data/src/rules/spec-template.ts b/packages/shared-data/src/rules/spec-template.ts index 9c1b3a76b..b55b15373 100644 --- a/packages/shared-data/src/rules/spec-template.ts +++ b/packages/shared-data/src/rules/spec-template.ts @@ -185,11 +185,57 @@ export interface CategoryScopedSpecs { const isRecord = (v: unknown): v is Record => Boolean(v) && typeof v === 'object' && !Array.isArray(v); +// Free-text cellular fields the enricher writes for a watch, such as +// "LTE / 5G" (DOS/Rate#1166). +const FREE_TEXT_CELLULAR_FIELDS = ['technology', 'data_speed', 'sim_type']; +const CELLULAR_SEPARATOR_RE = /\s*[/,|;]\s*/; +// A bracketed 5G note, as in "LTE (5G RedCap)". +const FIVE_G_BRACKET_RE = /\s*\([^()]*\b5G\b[^()]*\)/gi; +// "5G" with its qualifiers, also slash-joined ones: "5G SA/NSA/Sub6", +// "5G NR Sub-6", "5G-ready". +const FIVE_G_PHRASE_RE = + /\b5G\b(?:(?:\s*\/\s*|[\s-]*)(?:NR|SA|NSA|Sub-?6(?:\s*GHz)?|mmWave|RedCap|ready)\b)*/gi; + +/** The text without its 5G parts, or null when nothing else is left. */ +const withoutFiveG = (text: string): string | null => { + const stripped = text + .replace(FIVE_G_BRACKET_RE, '') + .replace(FIVE_G_PHRASE_RE, ''); + const kept = stripped + .split(CELLULAR_SEPARATOR_RE) + .map((part) => + part + .replace(/\s+/g, ' ') + .replace(/^[\s-]+|[\s(-]+$/g, '') + .trim() + ) + .filter(Boolean); + if (kept.length === 0) return null; + const separator = CELLULAR_SEPARATOR_RE.exec(text)?.[0] ?? ' / '; + return kept.join(separator); +}; + +const withoutFiveGText = ( + cellular: Record +): Record => { + const out: Record = { ...cellular }; + for (const field of FREE_TEXT_CELLULAR_FIELDS) { + const value = out[field]; + if (typeof value !== 'string' || !/\b5G\b/i.test(value)) continue; + const cleaned = withoutFiveG(value); + if (cleaned === null) delete out[field]; + else out[field] = cleaned; + } + return out; +}; + /** * A copy of a product with the spec groups, fields and flat columns its * category does not have removed. Fields outside the ten groups (such as - * `category_slug`) are kept. Values the category has are never changed, so - * applying it twice gives the same result. It only narrows: removed flat + * `category_slug`) are kept. Values the category has are never changed, + * except that display mode drops the 5G parts of a free-text network field + * ("LTE / 5G" becomes "LTE") for a category that hides 5G, so applying it + * twice gives the same result. It only narrows: removed flat * columns become `null` (`has_5g` becomes `false`) and groups lose keys, so * callers should type those fields as nullable, as the row types do. * @@ -222,6 +268,12 @@ export function applyCategorySpecTemplate( specs[group] = value; } } + // The page hides 5G for the category, so it also leaves it out of the + // free-text network fields. + const shownCellular = specs.cellular; + if (!forStorage && !template.has5g && isRecord(shownCellular)) { + specs.cellular = withoutFiveGText(shownCellular); + } out.specifications = specs; }