From 481c8271bc5d8ffd8a1b1ad8e99a464a0c901194 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Wed, 28 Oct 2026 14:15:24 +0700 Subject: [PATCH 1/2] fix(web): apply category spec templates to compare, leaderboard, export and api v1 /compare, the leaderboard rows, the spec export, the key spec line and the REST API v1 product, list and compare responses now read the category's spec view from applyCategorySpecTemplate, like the product page (#1142). An earbud shows no screen, RAM, ROM or camera there and a watch no 5G row. Display mode of applyCategorySpecTemplate also drops the 5G parts of a watch's free-text network fields ("LTE / 5G" becomes "LTE"); storage mode keeps them. Closes #1166 Co-Authored-By: Claude Opus 5.5 --- .../app/[locale]/smartphones/[slug]/page.tsx | 3 +- apps/web/src/app/api/v1/rate/compare/route.ts | 4 +- .../app/api/v1/rate/products/[slug]/route.ts | 13 +- .../web/src/app/api/v1/rate/products/route.ts | 6 +- .../components/compare/CompareWorkspace.tsx | 57 ++++- .../leaderboard/LeaderboardExplorer.tsx | 9 +- .../leaderboard/ProductSpecsExportToolbar.tsx | 82 ++++++- .../src/lib/__tests__/spec-template.test.ts | 221 +++++++++++++++++- apps/web/src/lib/product-competitors.ts | 17 +- .../src/__tests__/shared-data.test.ts | 39 ++++ .../shared-data/src/rules/spec-template.ts | 45 +++- 11 files changed, 467 insertions(+), 29 deletions(-) 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..0097add69 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, @@ -75,6 +82,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 +176,7 @@ function buildCategoryTabs(t: CompareTranslator): Array<{ ]; } -function buildSpecGroups(t: CompareTranslator): SpecGroupDef[] { +export function buildSpecGroups(t: CompareTranslator): SpecGroupDef[] { return [ { id: 'market_trust', @@ -295,6 +304,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 +383,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 +395,7 @@ function buildSpecGroups(t: CompareTranslator): SpecGroupDef[] { }, { id: 'hw_ram', + appliesTo: (tpl) => tpl.ram, label: t('rowHwRam'), renderValue: (p) => { const ram = resolveCompareRamGb(p); @@ -394,6 +406,7 @@ function buildSpecGroups(t: CompareTranslator): SpecGroupDef[] { }, { id: 'hw_storage', + appliesTo: (tpl) => tpl.storage, label: t('rowHwStorage'), renderValue: (p) => { const rom = resolveCompareStorageGb(p); @@ -405,6 +418,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 +615,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 +672,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 +786,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 +819,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 +846,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); 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..857cf737f 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,52 @@ 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 [group, fields] = EXPORT_ROW_FIELDS[index] ?? ['display', []]; + 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 +241,7 @@ export function buildProductSpecExportRows( ]), ], ]; + return rows.filter((_, index) => exportRowApplies(template, index)); } interface ProductSpecsExportToolbarProps { @@ -203,6 +250,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 +261,7 @@ export default function ProductSpecsExportToolbar({ brand, specifications, keySpecs, + categorySlug, radarSignal, }: ProductSpecsExportToolbarProps) { const t = useTranslations('productDetail'); @@ -223,10 +273,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 +302,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,6 +347,10 @@ 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, @@ -296,8 +358,8 @@ export default function ProductSpecsExportToolbar({ schema: 'Rate.vn 10-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..38695d7de 100644 --- a/apps/web/src/lib/__tests__/spec-template.test.ts +++ b/apps/web/src/lib/__tests__/spec-template.test.ts @@ -23,7 +23,220 @@ 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\)\)/ + ); + 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 +327,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..7e87e315b 100644 --- a/packages/shared-data/src/__tests__/shared-data.test.ts +++ b/packages/shared-data/src/__tests__/shared-data.test.ts @@ -942,6 +942,45 @@ 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'); + // 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..c7c14dbc4 100644 --- a/packages/shared-data/src/rules/spec-template.ts +++ b/packages/shared-data/src/rules/spec-template.ts @@ -185,11 +185,46 @@ 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']; +const CELLULAR_SEPARATOR_RE = /\s*[/,|;]\s*/; +const FIVE_G_TOKEN_RE = /\b5G(?:\s*(?:NR|SA|NSA|RedCap))*\b/gi; + +/** The text without its 5G parts, or null when nothing else is left. */ +const withoutFiveG = (text: string): string | null => { + const parts = text.split(CELLULAR_SEPARATOR_RE); + const kept = parts + .map((part) => + part.replace(FIVE_G_TOKEN_RE, '').replace(/\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 +257,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; } From 48ac2524c40125b08ebe664e62ff7fb1e881569c Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Wed, 28 Oct 2026 14:22:32 +0700 Subject: [PATCH 2/2] fix(web): strip 5g qualifiers and use template headings on compare Review follow-up for #1166: the 5G stripping removes bracketed 5G notes and qualifiers such as "SA/NSA/Sub6", also cleans sim_type, and compare group headings come from specGroupHeading so a watch has no "5G" heading. An export row without a field entry stays visible, and the JSON export schema label no longer claims ten groups. Co-Authored-By: Claude Opus 5.5 --- .../components/compare/CompareWorkspace.tsx | 20 +++++++++++---- .../leaderboard/ProductSpecsExportToolbar.tsx | 7 ++++-- .../src/lib/__tests__/spec-template.test.ts | 5 ++++ .../src/__tests__/shared-data.test.ts | 25 +++++++++++++++++++ .../shared-data/src/rules/spec-template.ts | 21 ++++++++++++---- 5 files changed, 66 insertions(+), 12 deletions(-) diff --git a/apps/web/src/components/compare/CompareWorkspace.tsx b/apps/web/src/components/compare/CompareWorkspace.tsx index 0097add69..01d8e9eb8 100644 --- a/apps/web/src/components/compare/CompareWorkspace.tsx +++ b/apps/web/src/components/compare/CompareWorkspace.tsx @@ -62,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'; @@ -2253,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; @@ -2273,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/ProductSpecsExportToolbar.tsx b/apps/web/src/components/leaderboard/ProductSpecsExportToolbar.tsx index 857cf737f..25b6e3b1d 100644 --- a/apps/web/src/components/leaderboard/ProductSpecsExportToolbar.tsx +++ b/apps/web/src/components/leaderboard/ProductSpecsExportToolbar.tsx @@ -93,7 +93,10 @@ const exportRowApplies = ( template: CategorySpecTemplate, index: number ): boolean => { - const [group, fields] = EXPORT_ROW_FIELDS[index] ?? ['display', []]; + 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)); }; @@ -355,7 +358,7 @@ export default function ProductSpecsExportToolbar({ 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: scoped.key_specs, diff --git a/apps/web/src/lib/__tests__/spec-template.test.ts b/apps/web/src/lib/__tests__/spec-template.test.ts index 38695d7de..6ed6529a1 100644 --- a/apps/web/src/lib/__tests__/spec-template.test.ts +++ b/apps/web/src/lib/__tests__/spec-template.test.ts @@ -213,6 +213,11 @@ async function testSurfaces() { 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\)/); diff --git a/packages/shared-data/src/__tests__/shared-data.test.ts b/packages/shared-data/src/__tests__/shared-data.test.ts index 7e87e315b..9d854b6d5 100644 --- a/packages/shared-data/src/__tests__/shared-data.test.ts +++ b/packages/shared-data/src/__tests__/shared-data.test.ts @@ -980,6 +980,31 @@ function testCategorySpecTemplates(): void { 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); diff --git a/packages/shared-data/src/rules/spec-template.ts b/packages/shared-data/src/rules/spec-template.ts index c7c14dbc4..b55b15373 100644 --- a/packages/shared-data/src/rules/spec-template.ts +++ b/packages/shared-data/src/rules/spec-template.ts @@ -187,16 +187,27 @@ const isRecord = (v: unknown): v is Record => // 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']; +const FREE_TEXT_CELLULAR_FIELDS = ['technology', 'data_speed', 'sim_type']; const CELLULAR_SEPARATOR_RE = /\s*[/,|;]\s*/; -const FIVE_G_TOKEN_RE = /\b5G(?:\s*(?:NR|SA|NSA|RedCap))*\b/gi; +// 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 parts = text.split(CELLULAR_SEPARATOR_RE); - const kept = parts + const stripped = text + .replace(FIVE_G_BRACKET_RE, '') + .replace(FIVE_G_PHRASE_RE, ''); + const kept = stripped + .split(CELLULAR_SEPARATOR_RE) .map((part) => - part.replace(FIVE_G_TOKEN_RE, '').replace(/\s+/g, ' ').trim() + part + .replace(/\s+/g, ' ') + .replace(/^[\s-]+|[\s(-]+$/g, '') + .trim() ) .filter(Boolean); if (kept.length === 0) return null;