diff --git a/apps/importer/src/shopee/__tests__/entity-matcher.test.ts b/apps/importer/src/shopee/__tests__/entity-matcher.test.ts index 4521e880..f2c79b54 100644 --- a/apps/importer/src/shopee/__tests__/entity-matcher.test.ts +++ b/apps/importer/src/shopee/__tests__/entity-matcher.test.ts @@ -675,6 +675,141 @@ async function runEntityMatcherSuite() { assert.equal(ctrlEntry.platform, 'shopee'); assert.equal(ctrlEntry.event, 'evt name'); console.log(' ✅ [9/9] DOS/Rate#876 EntityMatcher & Logger control-char/boolean bounds passed'); + + // 10. DOS/Rate#1142: masters keep only the spec fields their category has. + const enrichByCategory = async ( + row: Record, + aiResult: Record + ): Promise | undefined> => { + let updated: Record | undefined; + const db = { + from() { + return { + select: () => ({ + eq: () => ({ maybeSingle: async () => ({ data: row, error: null }) }), + }), + update(payload: Record) { + updated = payload; + return { eq: async () => ({ error: null }) }; + }, + }; + }, + } as unknown as RateSupabaseClient; + const enricher = { fetchSpecsFromAi: async () => aiResult } as unknown as AiSpecEnricher; + assert.equal( + await new EntityMatcher(db, enricher).enrichMasterProductById(String(row.id)), + true + ); + return updated; + }; + const phoneShapedAi = { + ram_gb: 1, + storage_gb: 64, + screen_size_inch: 1, + battery_mah: 515, + has_5g: true, + specifications: { + display: { size_inch: 1, refresh_rate_hz: 60, ppi: 300 }, + hardware: { ram_gb: 1, storage_gb: 64, chipset: 'BES2700' }, + camera: { main_camera: '3 High-SNR Microphones' }, + cellular: { has_5g: true, bands_5g: 'n78', bands_4g: 'LTE' }, + battery: { capacity_mah: 515 }, + multimedia: { audio_features: 'ANC, LDAC' }, + connectivity: { bluetooth: '5.4', wlan: 'Auto Switch' }, + }, + key_specs: { screen: '1"', camera: 'Mic', ram: '1GB', storage: '64GB', battery: '515 mAh' }, + }; + const budsRow = await enrichByCategory( + { + id: 'buds-1142', + brand: 'Samsung', + model: 'Galaxy Buds3 Pro', + name: 'Samsung Galaxy Buds3 Pro', + ram_gb: 1, + storage_gb: 64, + screen_size_inch: 1, + has_5g: false, + specifications: { category_slug: 'earbuds', display: { size_inch: 1 } }, + key_specs: { ram: '1GB' }, + }, + phoneShapedAi + ); + assert.equal(budsRow?.ram_gb, null, 'earbuds keep no RAM, even one stored before'); + assert.equal(budsRow?.storage_gb, null); + assert.equal(budsRow?.screen_size_inch, null); + assert.equal(budsRow?.has_5g, false); + assert.equal(budsRow?.battery_mah, 515); + const budsSpecs = budsRow?.specifications as Record>; + assert.equal(budsSpecs.display, undefined); + assert.deepEqual(budsSpecs.hardware, { chipset: 'BES2700' }, 'the audio chip stays'); + assert.equal(budsSpecs.camera, undefined); + assert.equal(budsSpecs.cellular, undefined); + assert.equal(budsSpecs.connectivity?.wlan, undefined); + assert.equal(budsSpecs.connectivity?.bluetooth, '5.4'); + assert.equal(budsSpecs.multimedia?.audio_features, 'ANC, LDAC'); + assert.equal(budsSpecs.category_slug, 'earbuds'); + assert.deepEqual(budsRow?.key_specs, { battery: '515 mAh' }); + + const watchRow = await enrichByCategory( + { + id: 'watch-1142', + brand: 'Apple', + model: 'Watch Ultra 2', + name: 'Apple Watch Ultra 2 64GB', + storage_gb: 64, + has_5g: true, + specifications: { category_slug: 'smartwatches' }, + key_specs: {}, + }, + phoneShapedAi + ); + // A watch keeps its stored 5G facts (a few models have RedCap 5G); only the + // page hides them, and the camera it cannot have is dropped. + assert.equal(watchRow?.has_5g, true); + assert.equal(watchRow?.storage_gb, 64); + const watchSpecs = watchRow?.specifications as Record>; + assert.equal(watchSpecs.camera, undefined); + assert.equal(watchSpecs.cellular?.has_5g, true); + assert.equal(watchSpecs.cellular?.bands_5g, 'n78'); + assert.equal(watchSpecs.cellular?.bands_4g, 'LTE'); + assert.equal(watchSpecs.display?.size_inch, 1); + + // A category guessed from the title does not scope the write: a master + // without a stored category keeps its RAM even when its name says "tai nghe". + const guessedRow = await enrichByCategory( + { + id: 'guess-1142', + brand: 'Samsung', + model: 'Galaxy S25', + name: 'Samsung Galaxy S25 tặng tai nghe Galaxy Buds', + ram_gb: 12, + storage_gb: 256, + has_5g: true, + specifications: {}, + key_specs: {}, + }, + phoneShapedAi + ); + assert.equal(guessedRow?.ram_gb, 12); + assert.equal(guessedRow?.storage_gb, 256); + assert.equal(guessedRow?.has_5g, true); + + const budsInsertDb = createMockSupabase(); + await new EntityMatcher(budsInsertDb.client, offlineEnricher).resolveOrCreateMasterProduct({ + ...s24New, + model_key: 'samsung:galaxy-buds3-pro', + category_slug: 'earbuds', + ram_gb: 1, + storage_gb: 64, + screen_size_inch: 1, + has_5g: true, + }); + const budsInsert = budsInsertDb.capturedUpserts[0]?.payload; + assert.equal(budsInsert?.ram_gb, null); + assert.equal(budsInsert?.storage_gb, null); + assert.equal(budsInsert?.screen_size_inch, null); + assert.equal(budsInsert?.has_5g, false); + console.log(' ✅ [10/10] DOS/Rate#1142 category spec templates on master writes passed'); console.log('🎉 All EntityMatcher & Open-Core Audit C6/C8/T15/Low tests passed!'); } diff --git a/apps/importer/src/shopee/ai-spec-enricher.ts b/apps/importer/src/shopee/ai-spec-enricher.ts index ed053cb9..a9a7efd5 100644 --- a/apps/importer/src/shopee/ai-spec-enricher.ts +++ b/apps/importer/src/shopee/ai-spec-enricher.ts @@ -15,6 +15,7 @@ import { type ProductSpecifications, type KeySpecs, } from '@repo/shared-data'; +import { applyCategorySpecTemplate } from '@repo/shared-data/rules'; import type { NormalizedProductCandidate } from './product-normalizer.js'; import { computeRetryDelayMs, createImporterLogger, sanitizeAiGeneratedText } from './logger.js'; @@ -619,6 +620,7 @@ RULES: 3. Boolean fields (has_5g, nfc, volte, vowifi, headphone_jack_35mm) MUST be JSON booleans (true or false). 4. Separate cameras clearly following PhoneArena structure: main_camera, second_camera (ultrawide), third_camera (telephoto/macro), fourth_camera (periscope if present), front_camera. 5. Keep descriptive text concise, accurate, and readable for Vietnamese & international users. +6. Omit groups and fields the device category does not have. Earbuds have no display, RAM, storage, camera or cellular network; describe their drivers, codecs, ANC and battery instead. Smartwatches have no camera, and a cellular watch is LTE unless the model really supports 5G. REQUIRED JSON SCHEMA: { @@ -977,15 +979,21 @@ REQUIRED JSON SCHEMA: const keySpecsParsed = KeySpecsSchema.safeParse(keySpecsCandidate); const keySpecs: KeySpecs = keySpecsParsed.success ? keySpecsParsed.data : {}; - const result: AiEnrichmentResponse = { - ram_gb: ramGb, - storage_gb: storageGb, - screen_size_inch: screenSizeInch, - battery_mah: batteryMah, - has_5g: has5g, - specifications, - key_specs: keySpecs, - }; + // The schema is phone-shaped; drop what the category does not have + // (earbuds: no screen, RAM or camera; watches: no 5G) (DOS/Rate#1142). + const result: AiEnrichmentResponse = applyCategorySpecTemplate( + catKey, + { + ram_gb: ramGb, + storage_gb: storageGb, + screen_size_inch: screenSizeInch, + battery_mah: batteryMah, + has_5g: has5g, + specifications, + key_specs: keySpecs, + }, + { forStorage: true } + ); this.setCacheEntry(cacheKey, result); logger.info( diff --git a/apps/importer/src/shopee/entity-matcher.ts b/apps/importer/src/shopee/entity-matcher.ts index e5288704..b0b6573a 100644 --- a/apps/importer/src/shopee/entity-matcher.ts +++ b/apps/importer/src/shopee/entity-matcher.ts @@ -4,6 +4,7 @@ import { type CanonicalCategorySlug, type NormalizedProductCandidate, } from './product-normalizer.js'; +import { applyCategorySpecTemplate } from '@repo/shared-data/rules'; import { AiSpecEnricher, mergeProductSpecifications } from './ai-spec-enricher.js'; import { mergeKeySpecs } from './jobs/backfill-specs.js'; import { createImporterLogger } from './logger.js'; @@ -298,54 +299,60 @@ export class EntityMatcher { .replace(/[^a-z0-9-]+/gi, '-') .toLowerCase() .slice(0, 300); - const baseInsertRow = { - category_id: safeCategoryId || null, - brand: - String(candidate.brand ?? '') - .replace(/\p{Cc}+/gu, ' ') - .trim() - .slice(0, 100) || 'Unknown', - model: - String(candidate.model ?? '') - .replace(/\p{Cc}+/gu, ' ') - .trim() - .slice(0, 200) || 'Unknown', - variant_name: candidate.variant_name - ? String(candidate.variant_name) + // Only the spec fields the category has: no screen or RAM on earbuds, no + // 5G on a watch (DOS/Rate#1142). + const baseInsertRow = applyCategorySpecTemplate( + candidate.category_slug, + { + category_id: safeCategoryId || null, + brand: + String(candidate.brand ?? '') + .replace(/\p{Cc}+/gu, ' ') + .trim() + .slice(0, 100) || 'Unknown', + model: + String(candidate.model ?? '') .replace(/\p{Cc}+/gu, ' ') .trim() - .slice(0, 200) || null - : null, - model_key: - String(candidate.model_key ?? '') - .replace(/\p{Cc}+/gu, '') - .trim() - .slice(0, 200) || safeModelKey, - name: - String(candidate.name ?? '') - .replace(/\p{Cc}+/gu, ' ') - .trim() - .slice(0, 300) || safeModelKey, - slug: safeSlug, - description: - typeof candidate.description === 'string' - ? candidate.description + .slice(0, 200) || 'Unknown', + variant_name: candidate.variant_name + ? String(candidate.variant_name) .replace(/\p{Cc}+/gu, ' ') .trim() - .slice(0, 10_000) - : '', - ram_gb: toBoundedPositiveInt(candidate.ram_gb, MAX_RAM_GB), - storage_gb: toBoundedPositiveInt(candidate.storage_gb, MAX_STORAGE_GB), - screen_size_inch: toBoundedPositiveNumber(candidate.screen_size_inch, MAX_SCREEN_SIZE_INCH), - battery_mah: toBoundedPositiveInt(candidate.battery_mah, MAX_BATTERY_MAH), - has_5g: isStrictBoolean(candidate.has_5g), - specifications: candidate.specifications, - key_specs: candidate.key_specs, - images: safeImages, - thumbnail: safeImages[0] || null, - colors: safeColors, - status: seedStatus, - }; + .slice(0, 200) || null + : null, + model_key: + String(candidate.model_key ?? '') + .replace(/\p{Cc}+/gu, '') + .trim() + .slice(0, 200) || safeModelKey, + name: + String(candidate.name ?? '') + .replace(/\p{Cc}+/gu, ' ') + .trim() + .slice(0, 300) || safeModelKey, + slug: safeSlug, + description: + typeof candidate.description === 'string' + ? candidate.description + .replace(/\p{Cc}+/gu, ' ') + .trim() + .slice(0, 10_000) + : '', + ram_gb: toBoundedPositiveInt(candidate.ram_gb, MAX_RAM_GB), + storage_gb: toBoundedPositiveInt(candidate.storage_gb, MAX_STORAGE_GB), + screen_size_inch: toBoundedPositiveNumber(candidate.screen_size_inch, MAX_SCREEN_SIZE_INCH), + battery_mah: toBoundedPositiveInt(candidate.battery_mah, MAX_BATTERY_MAH), + has_5g: isStrictBoolean(candidate.has_5g), + specifications: candidate.specifications, + key_specs: candidate.key_specs, + images: safeImages, + thumbnail: safeImages[0] || null, + colors: safeColors, + status: seedStatus, + }, + { forStorage: true } + ); let { data: createdRows, error: createErr } = await this.supabase .from('master_products') @@ -525,19 +532,30 @@ export class EntityMatcher { const { error: updateErr } = await this.supabase .from('master_products') - .update({ - ram_gb: existingRamGb ?? toBoundedPositiveInt(aiResult.ram_gb, MAX_RAM_GB), - storage_gb: existingStorageGb ?? toBoundedPositiveInt(aiResult.storage_gb, MAX_STORAGE_GB), - screen_size_inch: - existingScreenInch ?? - toBoundedPositiveNumber(aiResult.screen_size_inch, MAX_SCREEN_SIZE_INCH), - battery_mah: - existingBatteryMah ?? toBoundedPositiveInt(aiResult.battery_mah, MAX_BATTERY_MAH), - has_5g: isStrictBoolean(row.has_5g) ? true : isStrictBoolean(aiResult.has_5g ?? row.has_5g), - specifications: mergedSpecifications, - key_specs: mergedKeySpecs, - updated_at: new Date().toISOString(), - }) + .update( + // Only a stored category scopes the write: a guess from the title + // could strip a phone's real RAM. + applyCategorySpecTemplate( + existingCategorySlug, + { + ram_gb: existingRamGb ?? toBoundedPositiveInt(aiResult.ram_gb, MAX_RAM_GB), + storage_gb: + existingStorageGb ?? toBoundedPositiveInt(aiResult.storage_gb, MAX_STORAGE_GB), + screen_size_inch: + existingScreenInch ?? + toBoundedPositiveNumber(aiResult.screen_size_inch, MAX_SCREEN_SIZE_INCH), + battery_mah: + existingBatteryMah ?? toBoundedPositiveInt(aiResult.battery_mah, MAX_BATTERY_MAH), + has_5g: isStrictBoolean(row.has_5g) + ? true + : isStrictBoolean(aiResult.has_5g ?? row.has_5g), + specifications: mergedSpecifications, + key_specs: mergedKeySpecs, + updated_at: new Date().toISOString(), + }, + { forStorage: true } + ) + ) .eq('id', safeMasterId); if (updateErr) { diff --git a/apps/web/e2e/category-spec-template.spec.ts b/apps/web/e2e/category-spec-template.spec.ts new file mode 100644 index 00000000..bf811d4e --- /dev/null +++ b/apps/web/e2e/category-spec-template.spec.ts @@ -0,0 +1,61 @@ +import { expect, test } from '@playwright/test'; + +// Each category shows its own spec template: an earbud stored with +// phone-shaped specs shows no screen, RAM, ROM or camera, and a watch never +// shows 5G (DOS/Rate#1142). The seeded bud carries every one of those values. +const BUD = '/earbuds/e2e-bud-002'; + +test('earbud page shows audio specs and no phone fields', async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 900 }); + await page.goto(BUD); + const specs = page.locator('#specs-section'); + const headings = specs.locator('h3'); + await expect(headings.first()).toHaveText(/^1\. Âm thanh/); + await expect(headings).toHaveCount(7); + await expect(specs).toContainText('E2E 10.5mm driver'); + await expect(specs).toContainText('E2E ANC'); + for (const text of [ + 'Màn hình', + 'Bộ nhớ RAM', + 'Bộ nhớ trong', + 'Camera', + '60 Hz', + '300 ppi', + 'E2E Mic Array', + 'E2E Audio Engine', + '5G', + ]) { + await expect(specs).not.toContainText(text); + } + const main = page.locator('main'); + await expect(main).not.toContainText('1GB'); + await expect(main).not.toContainText('64GB'); + await expect(main).not.toContainText('1 inch'); + await expect(main).not.toContainText('E2E Mic Array'); +}); + +test('earbud card carries no RAM or storage chip', async ({ page }) => { + await page.goto('/earbuds?q=E2E%20Bud%20002'); + const card = page + .locator('a[href="/earbuds/e2e-bud-002"]') + .first() + .locator('xpath=ancestor::article[1]'); + await expect(card).toBeVisible(); + await expect(card).not.toContainText('GB'); + await expect(card).not.toContainText('5G'); +}); + +test('a watch page never shows 5G', async ({ page }) => { + // The seeded phone is 5G; read through the smartwatch template, the flag + // and the 5G group label are gone while the rest of the page still renders. + await page.goto('/smartwatches/iphone-16-pro-max-256gb'); + const meta = page.locator('.rate-pdv-meta'); + 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).not.toContainText('5G'); + await expect(specs).not.toContainText('Camera'); +}); diff --git a/apps/web/e2e/seed.sql b/apps/web/e2e/seed.sql index f0f19601..fb51c50c 100644 --- a/apps/web/e2e/seed.sql +++ b/apps/web/e2e/seed.sql @@ -172,6 +172,22 @@ FROM generate_series(1, 201) AS g; UPDATE rate.master_products SET user_rating_weighted = 4 WHERE slug LIKE 'e2e-bud-%'; +-- One bud stored with phone-shaped specs, as the AI enricher wrote them for +-- real earbuds: a 1 inch screen, 1GB RAM, 64GB ROM, microphones as cameras and +-- a 5G flag. Its page and card must show none of them (DOS/Rate#1142). +UPDATE rate.master_products +SET ram_gb = 1, storage_gb = 64, screen_size_inch = 1, has_5g = true, + specifications = '{"category_slug": "earbuds", + "display": {"size_inch": 1, "refresh_rate_hz": 60, "ppi": 300}, + "hardware": {"ram_gb": 1, "storage_gb": 64, "gpu": "E2E Audio Engine"}, + "camera": {"main_camera": "E2E Mic Array"}, + "cellular": {"has_5g": true}, + "battery": {"capacity_mah": 515}, + "multimedia": {"loudspeaker": "E2E 10.5mm driver", "audio_features": "E2E ANC"}, + "connectivity": {"bluetooth": "5.4"}}', + key_specs = '{"screen": "1 inch", "camera": "E2E Mic Array", "ram": "1GB", "storage": "64GB", "battery": "515 mAh"}' +WHERE slug = 'e2e-bud-002'; + -- A phone whose six 5-star reviews are mostly star-only, one of them a -- sponsored post: the product page shows the star-distribution warning and a -- disclosure badge, which must follow the active locale (DOS/Rate#566). Its diff --git a/apps/web/locales/cs.json b/apps/web/locales/cs.json index bb825048..2da9e16d 100644 --- a/apps/web/locales/cs.json +++ b/apps/web/locales/cs.json @@ -995,7 +995,7 @@ "readAndSubmitReviewsBtn": "Číst a psát recenze ({count})", "tenSpecGroupsBtn": "Parametry", "headToHeadCompareBtn": "Přímé porovnání", - "quickSpecDriver": "Měnič / Displej", + "quickSpecDriver": "Měnič", "quickSpecScreen": "Displej", "quickSpecChipset": "Procesor (Čipset)", "quickSpecAudioAnc": "Zvuk / ANC", diff --git a/apps/web/locales/en.json b/apps/web/locales/en.json index b85f6ecb..6cf63300 100644 --- a/apps/web/locales/en.json +++ b/apps/web/locales/en.json @@ -995,7 +995,7 @@ "readAndSubmitReviewsBtn": "Read and write reviews ({count})", "tenSpecGroupsBtn": "Specifications", "headToHeadCompareBtn": "Compare head to head", - "quickSpecDriver": "Driver / Display", + "quickSpecDriver": "Driver", "quickSpecScreen": "Display", "quickSpecChipset": "Processor (Chipset)", "quickSpecAudioAnc": "Audio / ANC", diff --git a/apps/web/locales/vi.json b/apps/web/locales/vi.json index 3cbc4479..4893124c 100644 --- a/apps/web/locales/vi.json +++ b/apps/web/locales/vi.json @@ -995,7 +995,7 @@ "readAndSubmitReviewsBtn": "Đọc và viết đánh giá ({count})", "tenSpecGroupsBtn": "Thông số kỹ thuật", "headToHeadCompareBtn": "So sánh trực tiếp", - "quickSpecDriver": "Màng loa / Hiển thị", + "quickSpecDriver": "Màng loa", "quickSpecScreen": "Màn hình", "quickSpecChipset": "Vi xử lý (Chipset)", "quickSpecAudioAnc": "Âm thanh / ANC", diff --git a/apps/web/package.json b/apps/web/package.json index f7612fba..b0ff8e7c 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/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", + "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/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", "typecheck": "tsc --noEmit" }, "browserslist": [ diff --git a/apps/web/src/app/[locale]/page.tsx b/apps/web/src/app/[locale]/page.tsx index 3df3be7e..26f429d7 100644 --- a/apps/web/src/app/[locale]/page.tsx +++ b/apps/web/src/app/[locale]/page.tsx @@ -53,7 +53,11 @@ import ContentText from '@/components/elementary/ContentText'; import { contentLang } from '@/lib/content-lang'; import ClientMessages from '@/components/providers/ClientMessages'; import { siteJsonLd } from '@/lib/seo'; -import { productImageUrl } from '@/lib/rate-domain-core'; +import { + getMasterProductCategorySlug, + productImageUrl, +} from '@/lib/rate-domain-core'; +import { applyCategorySpecTemplate } from '@repo/shared-data/rules'; import { merchantRiskTone } from '@/lib/merchant-risk-tone'; // Cached after the first request, refreshed every 5 minutes or when the @@ -466,7 +470,13 @@ export default async function Home({
- {masterProducts.slice(0, 6).map((item) => { + {masterProducts.slice(0, 6).map((storedItem) => { + // An earbud card carries no RAM, storage or screen chip + // (DOS/Rate#1142). + const item = applyCategorySpecTemplate( + getMasterProductCategorySlug(storedItem, catSlugById), + storedItem + ); const thumb = productImageUrl(item); const keySpecs = item.key_specs || {}; const productHref = getMasterProductHref(item, catSlugById); diff --git a/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx b/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx index fa9df891..e12b4ecb 100644 --- a/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx +++ b/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx @@ -1,4 +1,5 @@ import type { Metadata } from 'next'; +import { Fragment, type ReactNode } from 'react'; import { getTranslations, setRequestLocale } from 'next-intl/server'; import { Link } from '@/lib/navigation'; import { notFound } from 'next/navigation'; @@ -54,7 +55,12 @@ import PageShell from '../../components/page-shell'; import type { KeySpecs, ProductSpecifications } from '@repo/shared-data'; import { normalizeRateLocale } from '@/lib/rate-i18n'; -import { getSpecUiDictionary, localizeSpecValue } from '@/lib/spec-localizer'; +import { + getSpecUiDictionary, + localizeSpecValue, + specGroupHeading, + specSectionBadge, +} from '@/lib/spec-localizer'; import { contentLang } from '@/lib/content-lang'; import ContentText from '@/components/elementary/ContentText'; import { @@ -67,6 +73,12 @@ import ProductValueCalculator from '@/components/leaderboard/ProductValueCalcula import ProductOffersExplorer from '@/components/leaderboard/ProductOffersExplorer'; import ProductSpecsExportToolbar from '@/components/leaderboard/ProductSpecsExportToolbar'; import ClientMessages from '@/components/providers/ClientMessages'; +import { + applyCategorySpecTemplate, + getCategorySpecTemplate, + specFieldApplies, + type SpecGroupKey, +} from '@repo/shared-data/rules'; import { isOfferUnavailable, productMarketStats, @@ -161,17 +173,21 @@ export async function ProductDetailView({ }) { const { locale, slug } = await params; setRequestLocale(locale); - const [t, tNav, product] = await Promise.all([ + const [t, tNav, storedProduct] = await Promise.all([ getTranslations({ locale, namespace: 'productDetail' }), getTranslations({ locale, namespace: 'rate.nav' }), getCachedMasterProductBySlug(slug), ]); - if (!product) { + if (!storedProduct) { notFound(); } const categorySlug = categoryBasePath.replace(/^\//, '') || 'smartphones'; + // Each category shows only the spec groups and fields it has: earbuds have + // no screen, RAM or camera, watches no 5G (DOS/Rate#1142). + const specTemplate = getCategorySpecTemplate(categorySlug); + const product = applyCategorySpecTemplate(categorySlug, storedProduct); const localizedCategoryLabel = categorySlug === 'smartphones' || categorySlug === 'laptops' || @@ -180,8 +196,8 @@ export async function ProductDetailView({ categorySlug === 'earbuds' ? tNav(categorySlug) : categoryLabel; - const [offers, reviews, categoryPeers, categoryDealRadar] = await Promise.all( - [ + const [offers, reviews, storedCategoryPeers, categoryDealRadar] = + await Promise.all([ getCachedMasterProductOffers(product.id), getCachedMasterProductReviews(product.id), // No fallbacks: this page is cached, so an empty fallback would be @@ -192,7 +208,9 @@ export async function ProductDetailView({ categorySlug: categorySlug as CanonicalCategorySlug, limit: 50, }), - ] + ]); + const categoryPeers = storedCategoryPeers.map((peer) => + applyCategorySpecTemplate(categorySlug, peer) ); const [reviewSummary, priceHistory] = await Promise.all([ getCachedMasterProductReviewSummary(product.id, reviews), @@ -767,46 +785,51 @@ export async function ProductDetailView({
-
-
-
- {categorySlug === 'earbuds' && - !keySpecs?.camera && - !specs?.camera?.main_camera - ? t('quickSpecAudioAnc') - : t('quickSpecMainCamera')} -
-
- + {(specTemplate.groups.includes('camera') || + categorySlug === 'earbuds') && ( +
+
+
+ {categorySlug === 'earbuds' && + !keySpecs?.camera && + !specs?.camera?.main_camera + ? t('quickSpecAudioAnc') + : t('quickSpecMainCamera')} +
+
+ +
-
-
-
-
- {t('quickSpecMemory')} -
-
- {(() => { - const ram = product.ram_gb ?? specs?.hardware?.ram_gb; - const rom = - product.storage_gb ?? specs?.hardware?.storage_gb; - if (ram && rom) return `${ram}GB / ${rom}GB`; - if (ram) return `${ram}GB RAM`; - if (rom) return `${rom}GB`; - return keySpecs?.ram || keySpecs?.storage || '-'; - })()} + )} + {(specTemplate.ram || specTemplate.storage) && ( +
+
+
+ {t('quickSpecMemory')} +
+
+ {(() => { + const ram = product.ram_gb ?? specs?.hardware?.ram_gb; + const rom = + product.storage_gb ?? specs?.hardware?.storage_gb; + if (ram && rom) return `${ram}GB / ${rom}GB`; + if (ram) return `${ram}GB RAM`; + if (rom) return `${rom}GB`; + return keySpecs?.ram || keySpecs?.storage || '-'; + })()} +
-
+ )}
@@ -937,7 +960,7 @@ export async function ProductDetailView({ />
- {/* SECTION #2: 10 Field Groups Specifications (Full 10/10 Canonical Schema per DESIGN.md v3.0) */} + {/* SECTION #2: Spec groups, per category template (DOS/Rate#1142) */} {(() => { const specUi = getSpecUiDictionary(locale); const loc = (v: string | number | boolean | null | undefined) => ( @@ -946,6 +969,577 @@ export async function ProductDetailView({ locale={locale} /> ); + 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(' | ') || '-'} +
+
+
+
+ ), + }; return (
- {specUi.sectionBadge} + {specSectionBadge(specUi, specTemplate.groups.length)}
@@ -982,518 +1576,9 @@ export async function ProductDetailView({ showLessLabel={t('specsShowLess')} openOnHash="#specs-section" > - {/* 1. Display */} -
-
-

- {specUi.groups.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)} -
-
-
-
- - {/* 2. Hardware */} -
-
-

- {specUi.groups.hardware} -

-
-
- {specUi.fields.chipset} -
-
- {loc(specs?.hardware?.chipset || keySpecs?.chip)} -
-
- {specUi.fields.cpu} -
-
- {loc(specs?.hardware?.cpu)} -
-
- {specUi.fields.gpu} -
-
- {loc(specs?.hardware?.gpu)} -
-
- {specUi.fields.ram} -
-
- {specs?.hardware?.ram_gb - ? `${specs.hardware.ram_gb} GB` - : product.ram_gb - ? `${product.ram_gb} GB` - : '-'} -
-
- {specUi.fields.storage} -
-
- {specs?.hardware?.storage_gb - ? `${specs.hardware.storage_gb} GB` - : product.storage_gb - ? `${product.storage_gb} GB` - : '-'} -
-
- {specUi.fields.storageExpansion} -
-
- {loc(specs?.hardware?.storage_expansion)} -
-
{specUi.fields.os}
-
- {loc(specs?.hardware?.os)} -
-
-
-
- - {/* 3. Camera (PhoneArena Split Architecture) */} -
-
-

- {specUi.groups.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(' | ') - )} -
-
-
-
- - {/* 4. Battery */} -
-
-

- {specUi.groups.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 - : '-'} -
-
-
-
- - {/* 5. Design */} -
-
-

- {specUi.groups.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)} -
-
- {specUi.fields.biometrics} -
-
- {loc(specs?.design?.biometrics)} -
-
- {specUi.fields.physicalKeys} -
-
- {loc(specs?.design?.keys)} -
-
-
-
- - {/* 6. Cellular */} -
-
-

- {specUi.groups.cellular} -

-
-
- {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)} -
-
- {specUi.fields.bands5g4g} -
-
- {loc( - [ - specs?.cellular?.bands_5g, - 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(' + ') - : '-'} -
-
-
-
- - {/* 7. Connectivity */} -
-
-

- {specUi.groups.connectivity} -

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

- {specUi.groups.multimedia} -

-
-
- {specUi.fields.loudspeaker} -
-
- {loc(specs?.multimedia?.loudspeaker)} -
-
- {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)} -
-
-
-
- - {/* 9. Availability */} -
-
-

- {specUi.groups.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)} -
-
-
-
- - {/* 10. Regulatory */} -
-
-

- {specUi.groups.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(' | ') || '-'} -
-
-
-
+ {specTemplate.groups.map((group) => ( + {groupNodes[group]} + ))}
); diff --git a/apps/web/src/app/[locale]/smartphones/page.tsx b/apps/web/src/app/[locale]/smartphones/page.tsx index 8d8e4dfd..80cd949a 100644 --- a/apps/web/src/app/[locale]/smartphones/page.tsx +++ b/apps/web/src/app/[locale]/smartphones/page.tsx @@ -41,6 +41,7 @@ import { BsExclamationTriangle, } from 'react-icons/bs'; import type { KeySpecs, ProductSpecifications } from '@repo/shared-data'; +import { applyCategorySpecTemplate } from '@repo/shared-data/rules'; import ClientMessages from '@/components/providers/ClientMessages'; import BootstrapClient from '@/app/BootstrapClient'; import { productImageUrl } from '@/lib/rate-domain-core'; @@ -770,7 +771,12 @@ export async function CategoryCatalogContent({
) : (
- {products.map((item) => { + {products.map((storedItem) => { + // Only the specs the category has (DOS/Rate#1142). + const item = applyCategorySpecTemplate( + categorySlug, + storedItem + ); const keySpecs = (item.key_specs || {}) as KeySpecs; const specs = (item.specifications || {}) as ProductSpecifications; diff --git a/apps/web/src/lib/__tests__/spec-template.test.ts b/apps/web/src/lib/__tests__/spec-template.test.ts new file mode 100644 index 00000000..3bc2a972 --- /dev/null +++ b/apps/web/src/lib/__tests__/spec-template.test.ts @@ -0,0 +1,120 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { + applyCategorySpecTemplate, + getCategorySpecTemplate, + type SpecGroupKey, +} from '@repo/shared-data/rules'; +import { + getSpecUiDictionary, + specGroupHeading, + specSectionBadge, +} from '../spec-localizer'; + +const readSource = (path: string) => + readFileSync(new URL(path, import.meta.url), 'utf8'); + +// The headings a category's spec section shows, in order. +const headings = (category: string, locale: string) => { + const ui = getSpecUiDictionary(locale); + const template = getCategorySpecTemplate(category); + return template.groups.map((group: SpecGroupKey, i: number) => + specGroupHeading(ui, group, i + 1, template.has5g) + ); +}; + +function main() { + console.warn('🧪 Category spec template suite (DOS/Rate#1142)'); + + // Phones keep the ten numbered groups, worded as before. + const phone = headings('smartphones', 'vi'); + assert.equal(phone.length, 10); + assert.equal(phone[0], '1. Màn hình (Display)'); + assert.equal(phone[5], '6. Mạng Di động & 5G (Cellular)'); + assert.equal(phone[9], '10. Pháp lý, Chứng nhận & SAR (Regulatory)'); + assert.deepEqual(headings('tablets', 'en'), headings('smartphones', 'en')); + assert.equal( + specSectionBadge(getSpecUiDictionary('vi'), 10), + '10 nhóm thông số' + ); + + // Earbuds open on audio and never show a screen, hardware, camera or + // cellular group, in any locale. + for (const locale of ['vi', 'en', 'cs']) { + const ui = getSpecUiDictionary(locale); + const buds = headings('earbuds', locale); + assert.equal(buds.length, 7); + assert.ok(buds[0]!.startsWith('1. ')); + assert.ok(buds[0]!.endsWith(ui.groups.multimedia.replace(/^\d+\.\s*/, ''))); + for (const group of ['display', 'camera', 'cellular'] as const) { + const label = ui.groups[group].replace(/^\d+\.\s*/, ''); + assert.ok( + !buds.some((h: string) => h.endsWith(label)), + `earbuds (${locale}) must not show ${group}` + ); + } + assert.equal(specSectionBadge(ui, buds.length).startsWith('7 '), true); + } + assert.deepEqual(headings('earbuds', 'en'), [ + '1. Audio & multimedia', + '2. Battery & charging', + '3. Connectivity & sensors', + '4. Hardware & performance', + '5. Design & durability', + '6. Availability & MSRP', + '7. Regulatory, certifications & SAR', + ]); + + // Smartwatches: no camera group and no "5G" anywhere in the headings. + for (const locale of ['vi', 'en', 'cs']) { + const watch = headings('smartwatches', locale); + assert.equal(watch.length, 9); + assert.ok( + watch.every((h: string) => !h.includes('5G')), + watch.join(' | ') + ); + } + assert.equal(headings('smartwatches', 'vi')[4], '5. Mạng Di động (Cellular)'); + + // The card chips read only what the template leaves: an earbud has no + // "1GB RAM · 64GB" and a cellular watch no "5G". + const buds = applyCategorySpecTemplate('earbuds', { + ram_gb: 1, + storage_gb: 1, + has_5g: false, + key_specs: { screen: '1"', ram: '1GB', chip: 'H2' }, + }); + assert.equal(buds.ram_gb, null); + assert.equal(buds.storage_gb, null); + assert.deepEqual(buds.key_specs, { chip: 'H2' }); + const ultra2 = applyCategorySpecTemplate('smartwatches', { + storage_gb: 64, + has_5g: true, + }); + assert.equal(ultra2.has_5g, false); + assert.equal(ultra2.storage_gb, 64); + + // The detail page, the category cards and the home cards all apply it. + const detail = readSource('../../app/[locale]/smartphones/[slug]/page.tsx'); + assert.match( + detail, + /const product = applyCategorySpecTemplate\(categorySlug, storedProduct\)/ + ); + assert.match(detail, /applyCategorySpecTemplate\(categorySlug, peer\)/); + assert.match(detail, /specTemplate\.groups\.map\(/); + assert.doesNotMatch(detail, /specUi\.groups\.\w+\}/); + const catalog = readSource('../../app/[locale]/smartphones/page.tsx'); + assert.match( + catalog, + /applyCategorySpecTemplate\(\s*categorySlug,\s*storedItem/ + ); + const home = readSource('../../app/[locale]/page.tsx'); + assert.match( + home, + /applyCategorySpecTemplate\(\s*getMasterProductCategorySlug/ + ); + + console.warn(' ✅ Category spec templates passed'); +} + +main(); diff --git a/apps/web/src/lib/spec-localizer.ts b/apps/web/src/lib/spec-localizer.ts index e64b76d7..ed27b298 100644 --- a/apps/web/src/lib/spec-localizer.ts +++ b/apps/web/src/lib/spec-localizer.ts @@ -1,3 +1,4 @@ +import type { SpecGroupKey } from '@repo/shared-data/rules'; import { type RateLocale, normalizeRateLocale } from './rate-i18n'; export interface SpecUiDictionary { @@ -9,6 +10,8 @@ export interface SpecUiDictionary { supported: string; availableStatus: string; has5gYes: string; + /** The cellular group heading of a category without 5G (smartwatches). */ + cellularNo5gGroup: string; groups: { display: string; hardware: string; @@ -59,6 +62,7 @@ export interface SpecUiDictionary { simType: string; dataSpeed: string; bands5g4g: string; + bands4g: string; volteVowifi: string; wlan: string; bluetooth: string; @@ -90,6 +94,7 @@ const SPEC_UI_DICTS: Record = { supported: 'Có hỗ trợ', availableStatus: 'Đang kinh doanh', has5gYes: 'Có (5G Sub-6 / mmWave)', + cellularNo5gGroup: '6. Mạng Di động (Cellular)', groups: { display: '1. Màn hình (Display)', hardware: '2. Phần cứng & Hiệu năng (Hardware)', @@ -140,6 +145,7 @@ const SPEC_UI_DICTS: Record = { simType: 'Chuẩn SIM', dataSpeed: 'Tốc độ dữ liệu', bands5g4g: 'Băng tần 5G / 4G', + bands4g: 'Băng tần 4G', volteVowifi: 'VoLTE / VoWiFi', wlan: 'Wi-Fi (WLAN)', bluetooth: 'Bluetooth', @@ -169,6 +175,7 @@ const SPEC_UI_DICTS: Record = { supported: 'Supported', availableStatus: 'Available', has5gYes: 'Yes (5G Sub-6 / mmWave)', + cellularNo5gGroup: '6. Cellular network', groups: { display: '1. Display & panel', hardware: '2. Hardware & performance', @@ -219,6 +226,7 @@ const SPEC_UI_DICTS: Record = { simType: 'SIM configuration', dataSpeed: 'Data speed', bands5g4g: '5G / 4G bands', + bands4g: '4G bands', volteVowifi: 'VoLTE / VoWiFi', wlan: 'Wi-Fi (WLAN)', bluetooth: 'Bluetooth', @@ -248,6 +256,7 @@ const SPEC_UI_DICTS: Record = { supported: 'Podporováno', availableStatus: 'V prodeji', has5gYes: 'Ano (5G Sub-6 / mmWave)', + cellularNo5gGroup: '6. Mobilní sítě', groups: { display: '1. Displej a obrazovka', hardware: '2. Hardware a výkon', @@ -298,6 +307,7 @@ const SPEC_UI_DICTS: Record = { simType: 'Typ SIM karty', dataSpeed: 'Rychlost dat', bands5g4g: 'Pásma 5G / 4G', + bands4g: 'Pásma 4G', volteVowifi: 'VoLTE / VoWiFi', wlan: 'Wi-Fi (WLAN)', bluetooth: 'Bluetooth', @@ -324,6 +334,26 @@ export function getSpecUiDictionary(locale?: string): SpecUiDictionary { return SPEC_UI_DICTS[normalizeRateLocale(locale)]; } +/** + * A group heading numbered by its place in the category's template, so an + * earbud page that starts with audio reads "1. Audio" (DOS/Rate#1142). + */ +export function specGroupHeading( + ui: SpecUiDictionary, + group: SpecGroupKey, + position: number, + has5g = true +): string { + const label = + group === 'cellular' && !has5g ? ui.cellularNo5gGroup : ui.groups[group]; + return `${position}. ${label.replace(/^\d+\.\s*/, '')}`; +} + +/** The "10 spec groups" badge with the category's own group count. */ +export function specSectionBadge(ui: SpecUiDictionary, count: number): string { + return ui.sectionBadge.replace(/^\d+/, String(count)); +} + const MONTH_VI_MAP: Record = { january: 'Tháng 01', february: 'Tháng 02', diff --git a/packages/shared-data/src/__tests__/shared-data.test.ts b/packages/shared-data/src/__tests__/shared-data.test.ts index 19a5a29a..869241bf 100644 --- a/packages/shared-data/src/__tests__/shared-data.test.ts +++ b/packages/shared-data/src/__tests__/shared-data.test.ts @@ -823,6 +823,149 @@ function testPriceRules(): void { } } +function testCategorySpecTemplates(): void { + const phoneShaped = { + ram_gb: 1, + storage_gb: 64, + screen_size_inch: 1, + has_5g: true, + specifications: { + category_slug: 'x', + display: { size_inch: 1, refresh_rate_hz: 60, ppi: 300 }, + hardware: { ram_gb: 1, storage_gb: 64, gpu: 'g', chipset: 'c' }, + camera: { main_camera: 'mic' }, + cellular: { has_5g: true, bands_5g: 'n78', bands_4g: 'LTE' }, + design: { biometrics: 'b', weight_g: 5 }, + connectivity: { bluetooth: '5.4', wlan: 'w', nfc: false }, + multimedia: { audio_features: 'ANC', headphone_jack_35mm: false }, + battery: { capacity_mah: 500 }, + }, + key_specs: { + screen: 's', + chip: 'c', + camera: 'm', + battery: 'b', + storage: '64GB', + ram: '1GB', + }, + }; + + // Phones and tablets, and any unknown slug, keep everything untouched. + for (const slug of [ + 'smartphones', + 'tablets', + 'laptops', + undefined, + null, + 'nope', + ]) { + assert.equal( + rules.applyCategorySpecTemplate(slug, phoneShaped), + phoneShaped + ); + assert.equal(rules.getCategorySpecTemplate(slug).groups.length, 10); + } + + // Earbuds: audio groups only, no screen, RAM, ROM, camera or cellular. + const buds = rules.applyCategorySpecTemplate(' Earbuds ', phoneShaped); + const budsSpecs = buds.specifications as unknown as Record< + string, + Record + >; + assert.deepEqual(rules.getCategorySpecTemplate('earbuds').groups, [ + 'multimedia', + 'battery', + 'connectivity', + 'hardware', + 'design', + 'availability', + 'regulatory', + ]); + assert.equal(buds.ram_gb, null); + assert.equal(buds.storage_gb, null); + assert.equal(buds.screen_size_inch, null); + assert.equal(buds.has_5g, false); + for (const group of ['display', 'camera', 'cellular']) { + assert.equal(budsSpecs[group], undefined, `earbuds drop ${group}`); + } + assert.equal(budsSpecs.category_slug, 'x', 'non-group keys are kept'); + assert.deepEqual(budsSpecs.connectivity, { bluetooth: '5.4' }); + assert.deepEqual(budsSpecs.multimedia, { audio_features: 'ANC' }); + assert.deepEqual(budsSpecs.design, { weight_g: 5 }); + // The audio chip is the one hardware fact earbuds keep. + assert.deepEqual(budsSpecs.hardware, { chipset: 'c' }); + const budsStored = rules.applyCategorySpecTemplate('earbuds', phoneShaped, { + forStorage: true, + }); + assert.deepEqual(budsStored, buds, 'earbuds store what they show'); + assert.deepEqual(buds.key_specs, { chip: 'c', battery: 'b' }); + + // Smartwatches: the page shows no camera and no 5G; screen, storage and + // LTE stay. + const watch = rules.applyCategorySpecTemplate('smartwatches', phoneShaped); + const watchSpecs = watch.specifications as unknown as Record< + string, + Record + >; + assert.equal(watch.has_5g, false); + assert.equal(watch.storage_gb, 64); + assert.equal(watch.screen_size_inch, 1); + assert.equal(watchSpecs.camera, undefined); + assert.deepEqual(watchSpecs.cellular, { bands_4g: 'LTE' }); + assert.deepEqual(watchSpecs.hardware, { + ram_gb: 1, + storage_gb: 64, + chipset: 'c', + }); + assert.deepEqual(watch.key_specs, { + screen: 's', + chip: 'c', + battery: 'b', + storage: '64GB', + ram: '1GB', + }); + // Stored, a watch keeps its 5G facts: a few models (RedCap) do have 5G. + const watchStored = rules.applyCategorySpecTemplate( + 'smartwatches', + phoneShaped, + { forStorage: true } + ); + const watchStoredSpecs = watchStored.specifications as unknown as Record< + string, + Record + >; + assert.equal(watchStored.has_5g, true); + assert.deepEqual( + watchStoredSpecs.cellular, + phoneShaped.specifications.cellular + ); + assert.equal(watchStoredSpecs.hardware?.gpu, 'g'); + assert.equal(watchStoredSpecs.camera, undefined); + + // Idempotent, never mutates the input, and leaves absent columns absent. + assert.deepEqual(rules.applyCategorySpecTemplate('earbuds', buds), buds); + assert.equal(phoneShaped.ram_gb, 1); + assert.equal(phoneShaped.specifications.display.size_inch, 1); + assert.equal( + 'ram_gb' in rules.applyCategorySpecTemplate('earbuds', { has_5g: true }), + false + ); + + const watchTemplate = rules.getCategorySpecTemplate('smartwatches'); + assert.equal( + rules.specFieldApplies(watchTemplate, 'cellular', 'has_5g'), + false + ); + assert.equal( + rules.specFieldApplies(watchTemplate, 'cellular', 'technology'), + true + ); + assert.equal( + rules.specFieldApplies(watchTemplate, 'camera', 'main_camera'), + false + ); +} + function assertRoundTrip(modelKey: string): void { const parts = sharedDataRoot.splitModelKey(modelKey); assert.ok(parts, `splitModelKey must parse ${modelKey}`); @@ -1076,6 +1219,8 @@ function main(): void { console.log( ' ✅ [8/8] Item / variant schemas and model_key helpers verified' ); + testCategorySpecTemplates(); + console.log(' ✅ [9/9] Category spec templates (DOS/Rate#1142) verified'); console.log('🎉 All @repo/shared-data tests passed!'); } diff --git a/packages/shared-data/src/rules/index.ts b/packages/shared-data/src/rules/index.ts index 8ea5fd48..5c111d6f 100644 --- a/packages/shared-data/src/rules/index.ts +++ b/packages/shared-data/src/rules/index.ts @@ -8,4 +8,5 @@ export { MAX_VALID_VND_PRICE } from './price-limits.js'; export * from './offer-plausibility.js'; export * from './listing-price-floor.js'; +export * from './spec-template.js'; export * from './model-key.js'; diff --git a/packages/shared-data/src/rules/spec-template.ts b/packages/shared-data/src/rules/spec-template.ts new file mode 100644 index 00000000..9c1b3a76 --- /dev/null +++ b/packages/shared-data/src/rules/spec-template.ts @@ -0,0 +1,245 @@ +/** + * Which spec groups and fields apply to each category (DOS/Rate#1142). + * + * The spec schema is one phone-shaped schema for every device, and the AI + * enricher fills it for earbuds and watches too: earbuds came out with a + * "1 inch" screen, 1GB RAM and microphones as cameras, and a cellular watch + * with 5G. rate.vn renders each category with its own template, and the + * importer drops the fields a category does not have before it writes a + * master. Plain data and functions only, like the rest of `rules`. + */ + +export type SpecGroupKey = + | 'display' + | 'hardware' + | 'camera' + | 'battery' + | 'design' + | 'cellular' + | 'connectivity' + | 'multimedia' + | 'availability' + | 'regulatory'; + +export type KeySpecKey = + 'screen' | 'chip' | 'camera' | 'battery' | 'storage' | 'ram'; + +export interface CategorySpecTemplate { + /** Groups the category has, in display order. */ + groups: readonly SpecGroupKey[]; + /** Fields of a listed group that the category does not have. */ + hiddenFields: Partial>; + /** + * Fields the page does not show although a model may really have them, so + * the importer keeps them: a few watches do carry 5G (RedCap). + */ + displayHiddenFields: Partial>; + /** `key_specs` entries that apply. */ + keySpecs: readonly KeySpecKey[]; + /** Whether the top-level `ram_gb` / `storage_gb` / `screen_size_inch` apply. */ + ram: boolean; + storage: boolean; + screen: boolean; + /** + * Whether the page shows 5G. Watches sell as LTE / GPS + Cellular and the + * enricher flags them 5G wrongly; the stored flag is cleared only for a + * category without a cellular group. + */ + has5g: boolean; +} + +const ALL_GROUPS: readonly SpecGroupKey[] = [ + 'display', + 'hardware', + 'camera', + 'battery', + 'design', + 'cellular', + 'connectivity', + 'multimedia', + 'availability', + 'regulatory', +]; + +const ALL_KEY_SPECS: readonly KeySpecKey[] = [ + 'screen', + 'chip', + 'camera', + 'battery', + 'storage', + 'ram', +]; + +const PHONE_TEMPLATE: CategorySpecTemplate = { + groups: ALL_GROUPS, + hiddenFields: {}, + displayHiddenFields: {}, + keySpecs: ALL_KEY_SPECS, + ram: true, + storage: true, + screen: true, + has5g: true, +}; + +const SPEC_TEMPLATES: Readonly> = { + smartphones: PHONE_TEMPLATE, + tablets: PHONE_TEMPLATE, + // Laptops keep the full template: some ship with 5G modems. + laptops: PHONE_TEMPLATE, + smartwatches: { + groups: [ + 'display', + 'hardware', + 'battery', + 'design', + 'cellular', + 'connectivity', + 'multimedia', + 'availability', + 'regulatory', + ], + hiddenFields: {}, + displayHiddenFields: { + hardware: ['gpu', 'storage_expansion'], + cellular: ['has_5g', 'bands_5g'], + }, + keySpecs: ['screen', 'chip', 'battery', 'storage', 'ram'], + ram: true, + storage: true, + screen: true, + has5g: false, + }, + earbuds: { + groups: [ + 'multimedia', + 'battery', + 'connectivity', + 'hardware', + 'design', + 'availability', + 'regulatory', + ], + hiddenFields: { + // The audio chip (Apple H2, BES2700) is the one hardware fact earbuds have. + hardware: [ + 'cpu', + 'gpu', + 'ram_gb', + 'storage_gb', + 'storage_expansion', + 'os', + ], + design: ['biometrics'], + connectivity: ['wlan', 'location', 'nfc'], + multimedia: ['headphone_jack_35mm'], + }, + displayHiddenFields: {}, + keySpecs: ['chip', 'battery'], + ram: false, + storage: false, + screen: false, + has5g: false, + }, +}; + +const normalizeSlug = (categorySlug: unknown): string => + typeof categorySlug === 'string' ? categorySlug.trim().toLowerCase() : ''; + +/** The template for a category slug; unknown slugs keep the full phone template. */ +export function getCategorySpecTemplate( + categorySlug: string | null | undefined +): CategorySpecTemplate { + return SPEC_TEMPLATES[normalizeSlug(categorySlug)] ?? PHONE_TEMPLATE; +} + +const hiddenIn = ( + template: CategorySpecTemplate, + group: SpecGroupKey, + forStorage: boolean +): readonly string[] => [ + ...(template.hiddenFields[group] ?? []), + ...(forStorage ? [] : (template.displayHiddenFields[group] ?? [])), +]; + +/** Whether the page shows a field of a group for the category. */ +export function specFieldApplies( + template: CategorySpecTemplate, + group: SpecGroupKey, + field: string +): boolean { + return ( + template.groups.includes(group) && + !hiddenIn(template, group, false).includes(field) + ); +} + +export interface CategoryScopedSpecs { + specifications?: unknown; + key_specs?: unknown; + ram_gb?: number | null; + storage_gb?: number | null; + screen_size_inch?: number | null; + has_5g?: boolean | null; +} + +const isRecord = (v: unknown): v is Record => + Boolean(v) && typeof v === 'object' && !Array.isArray(v); + +/** + * 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 + * columns become `null` (`has_5g` becomes `false`) and groups lose keys, so + * callers should type those fields as nullable, as the row types do. + * + * `forStorage` (the importer) removes only what the category cannot have and + * keeps what the page merely hides, such as a watch's real 5G support. + */ +export function applyCategorySpecTemplate( + categorySlug: string | null | undefined, + product: T, + { forStorage = false }: { forStorage?: boolean } = {} +): T { + const template = getCategorySpecTemplate(categorySlug); + if (template === PHONE_TEMPLATE) return product; + const out: T = { ...product }; + + if (isRecord(product.specifications)) { + const specs: Record = {}; + for (const [group, value] of Object.entries(product.specifications)) { + if (!(ALL_GROUPS as readonly string[]).includes(group)) { + specs[group] = value; + continue; + } + if (!template.groups.includes(group as SpecGroupKey)) continue; + const hidden = hiddenIn(template, group as SpecGroupKey, forStorage); + if (hidden.length > 0 && isRecord(value)) { + specs[group] = Object.fromEntries( + Object.entries(value).filter(([field]) => !hidden.includes(field)) + ); + } else { + specs[group] = value; + } + } + out.specifications = specs; + } + + if (isRecord(product.key_specs)) { + out.key_specs = Object.fromEntries( + Object.entries(product.key_specs).filter(([key]) => + (template.keySpecs as readonly string[]).includes(key) + ) + ); + } + + if (!template.ram && 'ram_gb' in product) out.ram_gb = null; + if (!template.storage && 'storage_gb' in product) out.storage_gb = null; + if (!template.screen && 'screen_size_inch' in product) + out.screen_size_inch = null; + const shows5g = forStorage + ? template.groups.includes('cellular') + : template.has5g; + if (!shows5g && 'has_5g' in product) out.has_5g = false; + return out; +}