diff --git a/apps/extension/src/test-extension-pipeline.ts b/apps/extension/src/test-extension-pipeline.ts index bab2d95e..107109fc 100644 --- a/apps/extension/src/test-extension-pipeline.ts +++ b/apps/extension/src/test-extension-pipeline.ts @@ -2816,8 +2816,7 @@ async function runTests() { ? radarMod : (radarMod as unknown as { default: typeof radarMod }).default ) as typeof radarMod; - const plausibilityMod = - await import('../../web/src/lib/offer-plausibility.js'); + const plausibilityMod = await import('@repo/shared-data/rules'); const { implausiblyLowOfferIds, implausiblyLowIndexes } = ( typeof plausibilityMod.implausiblyLowOfferIds === 'function' ? plausibilityMod diff --git a/apps/web/e2e/reflow-contrast.spec.ts b/apps/web/e2e/reflow-contrast.spec.ts index 2aa54c8a..887d7ee9 100644 --- a/apps/web/e2e/reflow-contrast.spec.ts +++ b/apps/web/e2e/reflow-contrast.spec.ts @@ -9,6 +9,14 @@ const PHONE_PAGES = [ '/smartphones/iphone-16-pro-max-256gb', '/merchants', '/compare', + // Longer sort labels pushed the catalog sideways in en and cs + // (DOS/Rate#1082). + '/en/smartphones', + '/cs/smartphones', + // Czech copy is the longest; the merchant rating line did not wrap + // (DOS/Rate#1084). + '/cs/leaderboard', + '/cs/merchants/excluded-review-store', ]; for (const path of PHONE_PAGES) { diff --git a/apps/web/e2e/selector-state.spec.ts b/apps/web/e2e/selector-state.spec.ts index 2e08e9c4..6fceab0a 100644 --- a/apps/web/e2e/selector-state.spec.ts +++ b/apps/web/e2e/selector-state.spec.ts @@ -57,3 +57,27 @@ test('the compare category switcher exposes its state', async ({ page }) => { const group = page.getByRole('group', { name: 'Chọn danh mục đối chiếu' }); await expect(group.getByRole('button', { pressed: true })).toHaveCount(1); }); + +// Forced colours drop the colours that mark the pressed button; it must still +// look different from the others (DOS/Rate#1086). +for (const [path, name] of [ + ['/leaderboard', 'Chọn ngành hàng'], + ['/compare', 'Chọn danh mục đối chiếu'], +] as const) { + test(`the pressed category stands out in forced colors on ${path}`, async ({ + page, + }) => { + await page.emulateMedia({ forcedColors: 'active' }); + expect((await page.goto(path))?.status()).toBe(200); + const group = page.getByRole('group', { name }); + const look = (pressed: boolean) => + group + .getByRole('button', { pressed }) + .first() + .evaluate((el) => getComputedStyle(el).backgroundColor); + await expect(group.getByRole('button', { pressed: false })).not.toHaveCount( + 0 + ); + expect(await look(true)).not.toBe(await look(false)); + }); +} diff --git a/apps/web/locales/cs.json b/apps/web/locales/cs.json index 12df9c60..bf2461c4 100644 --- a/apps/web/locales/cs.json +++ b/apps/web/locales/cs.json @@ -396,7 +396,7 @@ "rank3Label": "3. místo", "fullTableTitle": "Kompletní žebříček kategorie", "verifiedReviewsSuffix": "ověřených recenzí", - "blockedSpamCount": "{count, plural, one {Zablokována # falešná/spamová recenze} few {Zablokovány # falešné/spamové recenze} other {Zablokováno # falešných/spamových recenzí}}", + "blockedSpamCount": "{count, plural, one {Zablokována # falešná či spamová recenze} few {Zablokovány # falešné či spamové recenze} other {Zablokováno # falešných či spamových recenzí}}", "storesTrackedSuffix": "obchodů", "viewAuditDetailBtn": "Recenze a 10 skupin parametrů →", "inspectBtn": "Ověřit cenu", diff --git a/apps/web/package.json b/apps/web/package.json index e08cba59..78157a69 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__/sitemap-lastmod.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__/sitemap-lastmod.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/leaderboard-badge-copy.test.ts", "typecheck": "tsc --noEmit" }, "browserslist": [ diff --git a/apps/web/src/app/[locale]/merchants/[id]/page.tsx b/apps/web/src/app/[locale]/merchants/[id]/page.tsx index 70d864fa..ab014465 100644 --- a/apps/web/src/app/[locale]/merchants/[id]/page.tsx +++ b/apps/web/src/app/[locale]/merchants/[id]/page.tsx @@ -282,7 +282,9 @@ export default async function MerchantProfilePage({ className="d-flex align-items-center small flex-wrap gap-3" style={{ color: '#94a3b8' }} > - + {/* Items wrap: "Hodnocení tržiště: Neaktualizováno" is wider + than a 320px header column (DOS/Rate#1084). */} + {t('platformRatingLabel')}{' '} @@ -291,7 +293,7 @@ export default async function MerchantProfilePage({ : t('notUpdatedYet')} - + {t('followersLabel')}{' '} @@ -300,7 +302,7 @@ export default async function MerchantProfilePage({ : '-'} - + {t('trackedModelsLabel')}{' '} diff --git a/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx b/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx index ce83d5ef..b7e7fcea 100644 --- a/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx +++ b/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx @@ -67,7 +67,7 @@ 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 { plausibleMinPrice } from '@/lib/offer-plausibility'; +import { plausibleMinPrice } from '@repo/shared-data/rules'; import { firstImageUrl, usableImageUrls, diff --git a/apps/web/src/app/[locale]/smartphones/page.tsx b/apps/web/src/app/[locale]/smartphones/page.tsx index 9bc6d9c5..21cf4879 100644 --- a/apps/web/src/app/[locale]/smartphones/page.tsx +++ b/apps/web/src/app/[locale]/smartphones/page.tsx @@ -674,7 +674,7 @@ export async function CategoryCatalogContent({
-
+
.input-group-text { margin-right: 0 !important; } } + +// Forced colours replace the background and text colours that mark a pressed +// toggle (btn-danger vs btn-outline-dark), so the selected category looked +// like the others in Windows High Contrast. Draw every pressed control with +// the system highlight instead (DOS/Rate#1086). +// `html` outranks Bootstrap's !important utilities (bg-white, text-dark) on +// children such as count badges. +@media (forced-colors: active) { + html [aria-pressed='true'] { + forced-color-adjust: none; + background-color: Highlight !important; + border-color: Highlight !important; + color: HighlightText !important; + + * { + background-color: transparent !important; + border-color: HighlightText !important; + color: HighlightText !important; + } + } +} diff --git a/apps/web/src/app/style/scss/style.scss b/apps/web/src/app/style/scss/style.scss index 68728f2b..bbdbc400 100644 --- a/apps/web/src/app/style/scss/style.scss +++ b/apps/web/src/app/style/scss/style.scss @@ -6423,6 +6423,11 @@ a.mousewheel.center { border-radius: 50px; transition: all ease 0.4s; color: var(--bs-dark); + // Long labels ("ŘAZENÍ: VÝCHOZÍ (NEDÁVNO AKTUALIZOVANÉ)") wrap instead of + // pushing a 320px page sideways; Bootstrap's toggle is nowrap + // (DOS/Rate#1082). + max-width: 100%; + white-space: normal; &:hover { background-color: var(--bs-primary-bg-subtle); diff --git a/apps/web/src/components/elementary/LocaleSwitcher.tsx b/apps/web/src/components/elementary/LocaleSwitcher.tsx index ebd97a46..b7dc13c2 100644 --- a/apps/web/src/components/elementary/LocaleSwitcher.tsx +++ b/apps/web/src/components/elementary/LocaleSwitcher.tsx @@ -216,8 +216,10 @@ export function LocaleSwitcher({ style={{ height: compact ? '38px' : '48px', // The compact switcher sits in the phone header, which has to fit - // 320px (DOS/Rate#809). - padding: compact ? '0 8px' : '0 14px', + // 320px (DOS/Rate#809) with the widest label: "EN" and "CS" are + // ~5px wider than "VI" and pushed the menu toggle's tap area off + // screen (DOS/Rate#1082). + padding: compact ? '0 5px' : '0 14px', backgroundColor: isDarkHeader ? 'rgba(255, 255, 255, 0.12)' : 'var(--bs-card-bg, #ffffff)', diff --git a/apps/web/src/components/inspect/DeepInspectWorkspace.tsx b/apps/web/src/components/inspect/DeepInspectWorkspace.tsx index 05e81bf8..235f72f3 100644 --- a/apps/web/src/components/inspect/DeepInspectWorkspace.tsx +++ b/apps/web/src/components/inspect/DeepInspectWorkspace.tsx @@ -55,7 +55,7 @@ import { SAMPLE_AUDIT_URLS } from '@/lib/inspect-samples'; import { implausiblyLowOfferIds, plausibleOffers, -} from '@/lib/offer-plausibility'; +} from '@repo/shared-data/rules'; import { productImageUrl } from '@/lib/rate-domain-core'; const INSPECT_CONTROL_CHARS_RE = /\p{Cc}+/gu; diff --git a/apps/web/src/components/leaderboard/ProductOffersExplorer.tsx b/apps/web/src/components/leaderboard/ProductOffersExplorer.tsx index eceb61e6..37ab5a6b 100644 --- a/apps/web/src/components/leaderboard/ProductOffersExplorer.tsx +++ b/apps/web/src/components/leaderboard/ProductOffersExplorer.tsx @@ -24,7 +24,7 @@ import { type RadarSignal, } from '@/lib/deal-radar'; import { escapeCsvCell } from '@/lib/csv-safe'; -import { implausiblyLowOfferIds } from '@/lib/offer-plausibility'; +import { implausiblyLowOfferIds } from '@repo/shared-data/rules'; import { contentLang } from '@/lib/content-lang'; import ContentText from '@/components/elementary/ContentText'; diff --git a/apps/web/src/lib/__tests__/leaderboard-badge-copy.test.ts b/apps/web/src/lib/__tests__/leaderboard-badge-copy.test.ts new file mode 100644 index 00000000..b69c5a67 --- /dev/null +++ b/apps/web/src/lib/__tests__/leaderboard-badge-copy.test.ts @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +// The leaderboard's blocked-spam badge is about 114px wide on a 320px phone +// (~12 characters at its font size). "falešných/spamových" had no line-break +// opportunity (a slash only allows a break before digits), so the badge +// pushed /cs/leaderboard 16px sideways (DOS/Rate#1084). The E2E seed has no +// ignored reviews, so this guards the copy directly. +const MAX_UNBREAKABLE_CHARS = 12; + +for (const locale of ['vi', 'en', 'cs'] as const) { + const file = join(__dirname, '..', '..', '..', 'locales', `${locale}.json`); + const message: string = JSON.parse(readFileSync(file, 'utf8')).leaderboard + .blockedSpamCount; + assert.ok(message, `${locale}: missing leaderboard.blockedSpamCount`); + + // Keep the text of every plural branch; drop ICU syntax and placeholders. + const text = message + .replace(/\{count\}/g, '0') + .replace(/\{count, plural,/, '') + .replace(/\b(?:zero|one|two|few|many|other)\s*\{/g, ' ') + .replace(/[{}#]/g, ' '); + + for (const word of text.split(/\s+/).filter(Boolean)) { + assert.ok( + word.length <= MAX_UNBREAKABLE_CHARS, + `${locale}: "${word}" cannot wrap inside the leaderboard badge` + ); + } +} + +console.log('leaderboard-badge-copy: ok'); diff --git a/apps/web/src/lib/__tests__/offer-plausibility.test.ts b/apps/web/src/lib/__tests__/offer-plausibility.test.ts index 279c8a9f..598037c7 100644 --- a/apps/web/src/lib/__tests__/offer-plausibility.test.ts +++ b/apps/web/src/lib/__tests__/offer-plausibility.test.ts @@ -5,7 +5,7 @@ import { isImplausiblyLowPrice, plausibleMinPrice, plausibleOffers, -} from '../offer-plausibility'; +} from '@repo/shared-data/rules'; function main() { console.warn('🧪 Offer plausibility suite'); diff --git a/apps/web/src/lib/rate-api.ts b/apps/web/src/lib/rate-api.ts index c3059dda..9a0a4d8b 100644 --- a/apps/web/src/lib/rate-api.ts +++ b/apps/web/src/lib/rate-api.ts @@ -2,7 +2,7 @@ import { createClient } from '@supabase/supabase-js'; import type { Database, MasterProduct } from '@repo/shared-data'; import { createLogger } from '@/lib/logger'; import { isPendingReview } from '@/lib/review-display'; -import { plausibleOffers } from '@/lib/offer-plausibility'; +import { plausibleOffers } from '@repo/shared-data/rules'; import { type ReviewFlagCategory, type ReviewItem, diff --git a/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts b/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts index 5cfa3957..a312ac34 100644 --- a/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts +++ b/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts @@ -9699,7 +9699,7 @@ async function runCoreArchitectureSuite() { // 107. Issue #900: Offer-Plausibility, Product-Competitors & Copy/SEO/Sitemap Helpers Bounds { const plausMod = - require('../../../lib/offer-plausibility') as typeof import('../../../lib/offer-plausibility'); + require('@repo/shared-data/rules') as typeof import('@repo/shared-data/rules'); const compMod = require('../../../lib/product-competitors') as typeof import('../../../lib/product-competitors'); const verdictCopyMod = @@ -11462,7 +11462,7 @@ async function runCoreArchitectureSuite() { const { implausiblyLowOfferIds: implausiblyLowOfferIds983, plausibleOffers: plausibleOffers983, - } = await import('../../../lib/offer-plausibility'); + } = await import('@repo/shared-data/rules'); const { buildKeySpecLine: buildKeySpecLine983, rankRelatedCompetitors: rankRelatedCompetitors983, diff --git a/apps/web/src/server/rate/api-handler.ts b/apps/web/src/server/rate/api-handler.ts index 75ec37ed..ffce902d 100644 --- a/apps/web/src/server/rate/api-handler.ts +++ b/apps/web/src/server/rate/api-handler.ts @@ -1,6 +1,6 @@ import 'server-only'; import { NextResponse } from 'next/server'; -import { plausibleMinPrice, plausibleOffers } from '@/lib/offer-plausibility'; +import { plausibleMinPrice, plausibleOffers } from '@repo/shared-data/rules'; import { MAX_VALID_VND_PRICE, normalizeOfferOriginalPrice, diff --git a/apps/web/src/server/rate/deals.ts b/apps/web/src/server/rate/deals.ts index d0f2ba0c..7ea83728 100644 --- a/apps/web/src/server/rate/deals.ts +++ b/apps/web/src/server/rate/deals.ts @@ -18,7 +18,7 @@ import { getRadarSignal, type RadarSignal, } from '../../lib/deal-radar'; -import { plausibleOffers } from '../../lib/offer-plausibility'; +import { plausibleOffers } from '@repo/shared-data/rules'; import { RATE_CACHE_TAGS, categoryCacheTag, diff --git a/apps/web/src/server/rate/extension.ts b/apps/web/src/server/rate/extension.ts index c11d6f5a..5186a6ec 100644 --- a/apps/web/src/server/rate/extension.ts +++ b/apps/web/src/server/rate/extension.ts @@ -1,8 +1,9 @@ import 'server-only'; import { isImplausiblyLowPrice, + minimumListingPriceVnd, plausibleOffers, -} from '../../lib/offer-plausibility'; +} from '@repo/shared-data/rules'; import type { Database } from '@repo/shared-data'; import { MAX_VALID_VND_PRICE, @@ -269,11 +270,6 @@ const ACCESSORY_KEYWORDS_VI_EN = [ 'camera lens protector', ]; -const MINIMUM_DEVICE_PRICE_FLOOR_VND = 1_200_000; -const MINIMUM_EARBUDS_PRICE_FLOOR_VND = 400_000; -const EARBUDS_TITLE_REGEX = - /\b(?:airpods|earpods|buds|freebuds|soundpeats|wf-|wh-|tws|tai[\s_-]*nghe|earbuds|in[\s_-]*ear)\b/i; - function isDomainOrSubdomain(host: string, domain: string): boolean { return host === domain || host.endsWith(`.${domain}`); } @@ -458,11 +454,7 @@ export function detectAccessoryOrJunk(params: { } } - const isEarbuds = - EARBUDS_TITLE_REGEX.test(titleLower) || EARBUDS_TITLE_REGEX.test(urlLower); - const minFloor = isEarbuds - ? MINIMUM_EARBUDS_PRICE_FLOOR_VND - : MINIMUM_DEVICE_PRICE_FLOOR_VND; + const minFloor = minimumListingPriceVnd([titleLower, urlLower]); if ( typeof safeParams.price === 'number' && diff --git a/packages/shared-data/package.json b/packages/shared-data/package.json index fa2df17b..bc2dfe0b 100644 --- a/packages/shared-data/package.json +++ b/packages/shared-data/package.json @@ -8,6 +8,11 @@ "import": "./dist/index.js", "require": "./dist/index.js" }, + "./rules": { + "types": "./dist/rules/index.d.ts", + "import": "./dist/rules/index.js", + "require": "./dist/rules/index.js" + }, "./server": { "types": "./dist/server.d.ts", "import": "./dist/server.js", diff --git a/packages/shared-data/src/__tests__/shared-data.test.ts b/packages/shared-data/src/__tests__/shared-data.test.ts index 04b09b68..00885221 100644 --- a/packages/shared-data/src/__tests__/shared-data.test.ts +++ b/packages/shared-data/src/__tests__/shared-data.test.ts @@ -18,6 +18,8 @@ import { z, } from '../index.js'; import { getAdminClient, getRateClient, getDosafeClient } from '../server.js'; +import * as rules from '../rules/index.js'; +import { readdirSync, readFileSync } from 'node:fs'; function testVersionAndZeroStrapiExports(): void { assert.equal(SHARED_DATA_VERSION, '2.0.0'); @@ -659,26 +661,102 @@ function testSeedingRiskPenalty(): void { ); } +function testPriceRules(): void { + // One 1B VND ceiling for the schemas and the bait-price rule. + assert.equal(rules.MAX_VALID_VND_PRICE, sharedDataRoot.MAX_VALID_VND_PRICE); + assert.equal( + rules.MAX_PLAUSIBLE_VND_PRICE, + sharedDataRoot.MAX_VALID_VND_PRICE + ); + // The rules ship on their own subpath, not the root export. + assert.equal('plausibleOffers' in sharedDataRoot, false); + + assert.equal( + rules.minimumListingPriceVnd(['iphone 16 pro max 256gb']), + 1_200_000 + ); + assert.equal( + rules.minimumListingPriceVnd(['galaxy buds pro', 'https://shop.vn/x']), + 400_000 + ); + // Current behaviour, kept by the move: "buds3" has no word boundary after + // "buds", so it gets the device floor (follow-up in DOS/Rate#1089). + assert.equal(rules.minimumListingPriceVnd(['galaxy buds3 pro']), 1_200_000); + assert.equal( + rules.minimumListingPriceVnd(['sony', 'https://shop.vn/tai-nghe-sony']), + 400_000, + 'the URL alone can name earbuds' + ); + assert.equal( + rules.minimumListingPriceVnd(['budsy phone stand']), + 1_200_000, + 'whole words only' + ); + + // 500k is below 40% of the median of the other three (3.1M): flagged. + const offers = [ + { id: 'a', price_current: 500_000 }, + { id: 'b', price_current: 3_000_000 }, + { id: 'c', price_current: 3_100_000 }, + { id: 'd', price_current: 9_000_000 }, + ]; + assert.deepEqual([...rules.implausiblyLowOfferIds(offers)], ['a']); + assert.equal(rules.plausibleMinPrice(0, offers), 3_000_000); + + // One definition of the ceiling: the schemas re-export it, never redefine it. + assert.doesNotMatch( + readFileSync( + new URL('../schemas/specs.schema.js', import.meta.url), + 'utf8' + ), + /MAX_VALID_VND_PRICE\s*=/ + ); + + // Browser-safe: the compiled rules import only each other, so neither zod + // nor Supabase can reach a client bundle through them. + const dir = new URL('../rules/', import.meta.url); + for (const file of readdirSync(dir).filter((f) => f.endsWith('.js'))) { + const source = readFileSync(new URL(file, dir), 'utf8'); + // Static import/export statements (tsc emits them at column 0) and + // dynamic import() calls; comments are not matched. + const specifiers = [ + ...source.matchAll(/^(?:import|export)\b[^;]*?['"]([^'"]+)['"]/gm), + ...source.matchAll(/\bimport\(\s*['"]([^'"]+)['"]/g), + ].map((m) => m[1]); + for (const specifier of specifiers) { + assert.match( + specifier ?? '', + /^\.\/[\w-]+\.js$/, + `rules/${file} imports ${specifier}` + ); + } + } +} + function main(): void { console.log('🧪 Running @repo/shared-data unit tests...'); testVersionAndZeroStrapiExports(); console.log( - ' ✅ [1/5] Version & Zero-Strapi root export isolation verified' + ' ✅ [1/7] Version & Zero-Strapi root export isolation verified' ); testRouteConstants(); - console.log(' ✅ [2/5] Route & REST API v1 constants verified'); + console.log(' ✅ [2/7] Route & REST API v1 constants verified'); testTenFieldGroupsSpecificationsSchema(); console.log( - ' ✅ [3/5] 10-Field-Groups ProductSpecificationsSchema verified' + ' ✅ [3/7] 10-Field-Groups ProductSpecificationsSchema verified' ); testMasterProductAndScrapedProductSchemas(); console.log( - ' ✅ [4/5] MasterProduct, ScrapedProduct & common schemas verified' + ' ✅ [4/7] MasterProduct, ScrapedProduct & common schemas verified' ); testSupabaseClientGuards(); - console.log(' ✅ [5/6] Supabase client fail-closed env guards verified'); + console.log(' ✅ [5/7] Supabase client fail-closed env guards verified'); testSeedingRiskPenalty(); - console.log(' ✅ [6/6] Shared seeding risk penalty verified'); + console.log(' ✅ [6/7] Shared seeding risk penalty verified'); + testPriceRules(); + console.log( + ' ✅ [7/7] Shared price rules (@repo/shared-data/rules) 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 new file mode 100644 index 00000000..3523dc37 --- /dev/null +++ b/packages/shared-data/src/rules/index.ts @@ -0,0 +1,9 @@ +/** + * Business rules about prices, shared by rate.vn, the importer and the + * crawlers (DOS/Rate#1089). Plain functions and constants only: no zod and + * no Supabase client, so client components can import + * `@repo/shared-data/rules` without pulling either into the bundle. + */ +export { MAX_VALID_VND_PRICE } from './price-limits.js'; +export * from './offer-plausibility.js'; +export * from './listing-price-floor.js'; diff --git a/packages/shared-data/src/rules/listing-price-floor.ts b/packages/shared-data/src/rules/listing-price-floor.ts new file mode 100644 index 00000000..93778a56 --- /dev/null +++ b/packages/shared-data/src/rules/listing-price-floor.ts @@ -0,0 +1,23 @@ +/** + * Price floor for a main device listing. Below it, a listing that claims to be + * a phone, tablet, watch or laptop is almost always an accessory or a spare + * part. Earbuds sell for less, so they have their own floor. + */ + +/** Below this, a main device listing is treated as an accessory or part. */ +export const MINIMUM_DEVICE_PRICE_FLOOR_VND = 1_200_000; +/** Below this, an earbuds listing is treated as an accessory or part. */ +export const MINIMUM_EARBUDS_PRICE_FLOOR_VND = 400_000; +/** Titles or URLs of earbuds listings. */ +export const EARBUDS_TITLE_REGEX = + /\b(?:airpods|earpods|buds|freebuds|soundpeats|wf-|wh-|tws|tai[\s_-]*nghe|earbuds|in[\s_-]*ear)\b/i; + +/** + * The floor for a listing: the earbuds floor when any of the texts (title, + * URL) names earbuds, else the device floor. + */ +export function minimumListingPriceVnd(texts: readonly string[]): number { + return texts.some((text) => EARBUDS_TITLE_REGEX.test(text)) + ? MINIMUM_EARBUDS_PRICE_FLOOR_VND + : MINIMUM_DEVICE_PRICE_FLOOR_VND; +} diff --git a/apps/web/src/lib/offer-plausibility.ts b/packages/shared-data/src/rules/offer-plausibility.ts similarity index 92% rename from apps/web/src/lib/offer-plausibility.ts rename to packages/shared-data/src/rules/offer-plausibility.ts index 287447ed..3e953ba6 100644 --- a/apps/web/src/lib/offer-plausibility.ts +++ b/packages/shared-data/src/rules/offer-plausibility.ts @@ -3,14 +3,19 @@ * store asks for the same model is almost never a real deal: it is a * counterfeit, an accessory or a bait listing. Such offers must not set a * product's "from" price or the deal radar's market floor. + * + * The database applies the same rule to rate.master_products.min_price in + * rate.refresh_master_market_stats (DOS-Me migration + * 20261024060000_rate_master_min_price_plausible.sql): change both together. */ +import { MAX_VALID_VND_PRICE } from './price-limits.js'; /** An offer below this share of the other offers' median is implausible. */ export const IMPLAUSIBLY_LOW_RATIO = 0.4; /** Fewer offers than this are too few to call one an outlier. */ export const MIN_OFFERS_FOR_OUTLIER = 3; -/** Maximum plausible VND listing price (1B VND). */ -export const MAX_PLAUSIBLE_VND_PRICE = 1_000_000_000; +/** Maximum plausible VND listing price: the shared 1B VND ceiling. */ +export const MAX_PLAUSIBLE_VND_PRICE = MAX_VALID_VND_PRICE; function toSafePlausiblePrice(raw: unknown): number { if (typeof raw !== 'number' && typeof raw !== 'string') return NaN; diff --git a/packages/shared-data/src/rules/price-limits.ts b/packages/shared-data/src/rules/price-limits.ts new file mode 100644 index 00000000..caddbbc2 --- /dev/null +++ b/packages/shared-data/src/rules/price-limits.ts @@ -0,0 +1,2 @@ +/** Maximum realistic VND listing price (1 billion VND) to block unscaled fixed-point / concatenated price bugs. */ +export const MAX_VALID_VND_PRICE = 1_000_000_000; diff --git a/packages/shared-data/src/schemas/specs.schema.ts b/packages/shared-data/src/schemas/specs.schema.ts index f3833675..310b11f6 100644 --- a/packages/shared-data/src/schemas/specs.schema.ts +++ b/packages/shared-data/src/schemas/specs.schema.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { MAX_VALID_VND_PRICE } from '../rules/price-limits.js'; import { HttpUrlSchema, StatusSchema } from './common.schema.js'; /** @@ -122,8 +123,8 @@ export const KeySpecsSchema = z.object({ ram: z.string().max(200).optional(), }); -/** Maximum realistic VND listing price (1 billion VND) to block unscaled fixed-point / concatenated price bugs. */ -export const MAX_VALID_VND_PRICE = 1_000_000_000; +// Defined with the price rules so `@repo/shared-data/rules` stays free of zod. +export { MAX_VALID_VND_PRICE }; export const MasterProductSchema = z.object({ id: z.string().uuid(),