feat: complete dashcam catalog ingestion demo
Master Production Deploy / production (push) Failing after 49s

This commit is contained in:
2026-07-31 02:01:58 +03:00
parent ae6c35145f
commit 2ce58fcbc0
62 changed files with 6341 additions and 178 deletions
+4 -9
View File
@@ -1,17 +1,12 @@
import { importCatalogExport } from '../src/entities/dashcam-catalog/api/importCatalogExport.ts';
const manifestUrl = process.env.DASHCAM_CATALOG_MANIFEST_URL ?? process.argv[2];
const token = process.env.DASHCAM_CATALOG_EXPORT_TOKEN;
const exportDir = process.env.DASHCAM_CATALOG_EXPORT_DIR ?? process.argv[2];
if (!manifestUrl) {
throw new Error('Set DASHCAM_CATALOG_MANIFEST_URL or pass manifest URL as the first argument');
if (!exportDir) {
throw new Error('Set DASHCAM_CATALOG_EXPORT_DIR or pass the shared export directory as the first argument');
}
if (!token) {
throw new Error('Set DASHCAM_CATALOG_EXPORT_TOKEN');
}
const result = await importCatalogExport({ manifestUrl, token });
const result = await importCatalogExport({ exportDir });
console.log(
JSON.stringify(
+244
View File
@@ -365,6 +365,216 @@ th {
text-align: center;
}
.catalog-page {
width: min(1180px, calc(100% - 32px));
margin: 0 auto;
padding: 28px 0 56px;
}
.catalog-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 24px;
border-bottom: 1px solid var(--border-subtle);
padding-bottom: 20px;
}
.catalog-header h1 {
margin: 0;
font-size: clamp(2rem, 4vw, 3.25rem);
line-height: 1.05;
}
.catalog-header__eyebrow,
.catalog-product__brand {
margin: 0 0 8px;
color: var(--accent-strong);
font-size: 0.75rem;
font-weight: 750;
letter-spacing: 0;
text-transform: uppercase;
}
.catalog-header__subtitle {
max-width: 46rem;
margin: 12px 0 0;
color: var(--text-muted);
font-size: 1rem;
line-height: 1.5;
}
.catalog-header__meta {
display: grid;
min-width: 126px;
border-left: 1px solid var(--border-subtle);
padding-left: 20px;
}
.catalog-header__meta strong {
font-family: var(--font-tech);
font-size: 2rem;
line-height: 1;
}
.catalog-header__meta span,
.catalog-status {
color: var(--text-muted);
font-size: 0.78rem;
}
.catalog-status {
display: flex;
flex-wrap: wrap;
gap: 8px 16px;
padding: 14px 0;
}
.catalog-status span + span::before {
content: '•';
margin-right: 16px;
color: var(--border-strong);
}
.catalog-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.catalog-product {
display: grid;
grid-template-columns: 150px minmax(0, 1fr);
overflow: hidden;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
background: var(--surface-1);
box-shadow: var(--shadow-soft);
}
.catalog-product__media {
display: grid;
place-items: center;
min-height: 182px;
background: var(--surface-inverse);
padding: 18px;
}
.catalog-product__media img {
width: 100%;
max-height: 132px;
object-fit: contain;
opacity: 0.84;
}
.catalog-product__body {
display: grid;
gap: 14px;
padding: 18px;
}
.catalog-product__heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.catalog-product h2 {
margin: 0;
font-size: 1.25rem;
line-height: 1.2;
}
.catalog-product__source-count {
flex: none;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
color: var(--text-muted);
padding: 4px 6px;
font-family: var(--font-tech);
font-size: 0.68rem;
}
.catalog-product__title {
margin: 0;
color: var(--text-muted);
font-size: 0.85rem;
line-height: 1.45;
}
.catalog-product__specs,
.catalog-product__details dl {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
margin: 0;
}
.catalog-product__specs div,
.catalog-product__details dl div {
min-width: 0;
border-top: 1px solid var(--border-subtle);
padding-top: 7px;
}
.catalog-product__specs dt,
.catalog-product__details dt {
color: var(--text-muted);
font-size: 0.7rem;
}
.catalog-product__specs dd,
.catalog-product__details dd {
margin: 3px 0 0;
font-size: 0.82rem;
font-weight: 650;
}
.catalog-product__details summary {
cursor: pointer;
color: var(--accent-strong);
font-size: 0.8rem;
font-weight: 700;
}
.catalog-product__details dl {
margin-top: 10px;
}
.catalog-product__sources {
display: flex;
flex-wrap: wrap;
gap: 8px;
border-top: 1px solid var(--border-subtle);
padding-top: 12px;
}
.catalog-product__sources a {
border-bottom: 1px solid var(--accent-soft-border);
color: var(--accent-strong);
font-size: 0.78rem;
font-weight: 700;
}
.catalog-empty {
border: 1px dashed var(--border-subtle);
border-radius: var(--radius-lg);
background: var(--surface-1);
padding: 44px 24px;
text-align: center;
}
.catalog-empty h2,
.catalog-empty p {
margin: 0;
}
.catalog-empty p {
margin-top: 8px;
color: var(--text-muted);
}
@media (max-width: 900px) {
.topbar,
.workbench {
@@ -383,6 +593,10 @@ th {
.stats-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.catalog-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 560px) {
@@ -400,6 +614,36 @@ th {
.video-card {
grid-template-columns: 1fr;
}
.catalog-page {
width: min(100% - 20px, 1180px);
padding-top: 18px;
}
.catalog-header {
align-items: flex-start;
flex-direction: column;
}
.catalog-header__meta {
border-left: 0;
border-top: 1px solid var(--border-subtle);
padding: 14px 0 0;
}
.catalog-product {
grid-template-columns: 112px minmax(0, 1fr);
}
.catalog-product__media {
min-height: 0;
padding: 12px;
}
.catalog-product__specs,
.catalog-product__details dl {
grid-template-columns: 1fr;
}
}
@media (prefers-reduced-motion: reduce) {
+30 -4
View File
@@ -1,9 +1,12 @@
import type { Metadata } from 'next';
import Script from 'next/script';
import './globals.css';
const YANDEX_METRIKA_ID = 109268682;
export const metadata: Metadata = {
title: 'Video Registry',
description: 'Operational video registry with primitive storage and aggressive caching.',
title: 'videoreg.ru - автомобильные видеорегистраторы',
description: 'Каталог автомобильных видеорегистраторов из одобренных source cards.',
};
type RootLayoutProps = {
@@ -12,8 +15,31 @@ type RootLayoutProps = {
export default function RootLayout({ children }: RootLayoutProps) {
return (
<html id="top" lang="en">
<body>{children}</body>
<html id="top" lang="ru">
<body>
{children}
<Script id="yandex-metrika" strategy="afterInteractive">
{`
(function(m,e,t,r,i,k,a){
m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
m[i].l=1*new Date();
for (var j = 0; j < document.scripts.length; j++) {if (document.scripts[j].src === r) { return; }}
k=e.createElement(t),a=e.getElementsByTagName(t)[0],k.async=1,k.src=r,a.parentNode.insertBefore(k,a)
})(window, document,'script','https://mc.yandex.ru/metrika/tag.js?id=${YANDEX_METRIKA_ID}', 'ym');
ym(${YANDEX_METRIKA_ID}, 'init', {ssr:true, webvisor:true, clickmap:true, ecommerce:"dataLayer", referrer: document.referrer, url: location.href, accurateTrackBounce:true, trackLinks:true});
`}
</Script>
<noscript>
<div>
<img
src={`https://mc.yandex.ru/watch/${YANDEX_METRIKA_ID}`}
style={{ position: 'absolute', left: '-9999px' }}
alt=""
/>
</div>
</noscript>
</body>
</html>
);
}
+10 -20
View File
@@ -1,27 +1,17 @@
import { getCachedVideoDashboard } from '@/entities/video/api/videoCache';
import { RegisterVideoForm } from '@/features/register-video/ui/RegisterVideoForm';
import { CachePanel } from '@/widgets/cache-panel/ui/CachePanel';
import { VideoDashboard } from '@/widgets/video-dashboard/ui/VideoDashboard';
import { readDashcamCatalog } from '@/entities/dashcam-catalog/api/catalogRepository';
import { DashcamCatalog } from '@/widgets/dashcam-catalog/ui/DashcamCatalog';
export const revalidate = 3600;
export const dynamic = 'force-dynamic';
export default async function Home() {
const dashboard = await getCachedVideoDashboard();
const catalog = await readDashcamCatalog();
return (
<main className="page-shell">
<header className="topbar">
<div>
<p className="eyebrow">videoreg.ru</p>
<h1>Video Registry</h1>
</div>
<CachePanel metadata={dashboard.cache} />
</header>
<div className="workbench">
<RegisterVideoForm />
<VideoDashboard dashboard={dashboard} />
</div>
</main>
<DashcamCatalog
catalog={catalog.catalog}
exportInfo={catalog.export}
hasSharedExportIssue={catalog.error === 'shared_export_unavailable'}
source={catalog.source}
/>
);
}
@@ -0,0 +1,62 @@
import 'server-only';
import { readPrimitiveDb } from '@/shared/db/primitiveJsonDb';
import {
readCatalogExport,
type ReadCatalogExportResult,
} from './importCatalogExport';
import type { DashcamCatalogExport } from '../model/schema';
export type DashcamCatalogReadModel = {
catalog?: DashcamCatalogExport;
error?: 'shared_export_unavailable';
source: 'shared_export' | 'read_model' | 'empty';
export?: Pick<ReadCatalogExportResult, 'createdAt' | 'exportId' | 'itemCount' | 'sha256'>;
};
export async function readDashcamCatalog(): Promise<DashcamCatalogReadModel> {
const exportDir = process.env.DASHCAM_CATALOG_EXPORT_DIR;
if (exportDir) {
try {
const result = await readCatalogExport({ exportDir });
return {
catalog: result.catalog,
source: 'shared_export',
export: {
createdAt: result.createdAt,
exportId: result.exportId,
itemCount: result.itemCount,
sha256: result.sha256,
},
};
} catch {
const readModel = await readPrimitiveDb();
if (readModel.dashcamCatalog) {
return {
catalog: readModel.dashcamCatalog,
source: 'read_model',
error: 'shared_export_unavailable',
};
}
return {
source: 'empty',
error: 'shared_export_unavailable',
};
}
}
const readModel = await readPrimitiveDb();
if (readModel.dashcamCatalog) {
return {
catalog: readModel.dashcamCatalog,
source: 'read_model',
};
}
return { source: 'empty' };
}
@@ -1,23 +1,15 @@
import { createHash } from 'node:crypto';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { importCatalogExport } from './importCatalogExport';
function jsonResponse(payload: unknown) {
return new Response(JSON.stringify(payload), {
status: 200,
headers: {
'content-type': 'application/json',
},
});
}
describe('importCatalogExport', () => {
it('validates manifest, checksum and atomically updates the primitive read model', async () => {
it('validates a shared-volume manifest, checksum and atomically updates the primitive read model', async () => {
const tempDir = await mkdtemp(path.join(os.tmpdir(), 'videoreg-catalog-import-'));
const dbFilePath = path.join(tempDir, 'videoreg.json');
const exportDir = path.join(tempDir, 'exports');
try {
const catalog = {
@@ -29,6 +21,7 @@ describe('importCatalogExport', () => {
id: 'ibox-f5',
brand: 'iBOX',
model: 'F5 WiFi',
canonicalName: 'iBOX F5 Wi-Fi',
title: 'Видеорегистратор для автомобиля iBOX F5 + WiFi с радар-детектором',
specs: {
channels: 1,
@@ -51,32 +44,25 @@ describe('importCatalogExport', () => {
};
const catalogBytes = Buffer.from(JSON.stringify(catalog));
const digest = createHash('sha256').update(catalogBytes).digest('hex');
const fetcher = async (input: string | URL) => {
const url = input.toString();
const artifactPath = path.join(exportDir, 'artifacts', 'export_1', 'catalog.json');
if (url === 'https://exports.example.test/manifest.json') {
return jsonResponse({
schemaVersion: 1,
exportId: 'export_1',
createdAt: '2026-05-16T21:00:00.000Z',
artifactUrl: 'https://exports.example.test/catalog.json',
sha256: digest,
itemCount: 1,
});
}
if (url === 'https://exports.example.test/catalog.json') {
return jsonResponse(catalog);
}
return new Response('not found', { status: 404 });
};
await mkdir(path.dirname(artifactPath), { recursive: true });
await writeFile(artifactPath, catalogBytes);
await writeFile(
path.join(exportDir, 'manifest.json'),
JSON.stringify({
schemaVersion: 1,
exportId: 'export_1',
createdAt: '2026-05-16T21:00:00.000Z',
artifactPath: 'artifacts/export_1/catalog.json',
sha256: digest,
itemCount: 1,
}),
);
const result = await importCatalogExport({
manifestUrl: 'https://exports.example.test/manifest.json',
token: 'secret',
exportDir,
dbFilePath,
fetcher,
now: new Date('2026-05-16T22:00:00.000Z'),
});
const stored = JSON.parse(await readFile(dbFilePath, 'utf8')) as Record<string, unknown>;
@@ -7,19 +7,18 @@ import {
type DashcamCatalogExport,
} from '../model/schema.ts';
type FetchLike = (input: string | URL, init?: RequestInit) => Promise<Response>;
type PrimitiveDbCache = {
videos?: unknown[];
dashcamCatalog?: DashcamCatalogExport;
dashcamCatalogImportedAt?: string;
};
export type ImportCatalogExportOptions = {
manifestUrl: string;
token: string;
export type ReadCatalogExportOptions = {
exportDir?: string;
};
export type ImportCatalogExportOptions = ReadCatalogExportOptions & {
dbFilePath?: string;
fetcher?: FetchLike;
now?: Date;
};
@@ -30,6 +29,15 @@ export type ImportCatalogExportResult = {
dbFilePath: string;
};
export type ReadCatalogExportResult = {
catalog: DashcamCatalogExport;
createdAt: string;
exportDir: string;
exportId: string;
itemCount: number;
sha256: string;
};
function getDefaultDbFilePath() {
if (process.env.VIDEOREG_DB_FILE) {
return process.env.VIDEOREG_DB_FILE;
@@ -38,6 +46,14 @@ function getDefaultDbFilePath() {
return path.join(process.cwd(), 'data', 'videoreg.json');
}
function getDefaultExportDir() {
if (process.env.DASHCAM_CATALOG_EXPORT_DIR) {
return process.env.DASHCAM_CATALOG_EXPORT_DIR;
}
return path.join(process.cwd(), 'data', 'catalog-exports');
}
function sha256(buffer: Buffer) {
return createHash('sha256').update(buffer).digest('hex');
}
@@ -68,20 +84,6 @@ async function writeJsonAtomically(filePath: string, payload: PrimitiveDbCache)
await rename(temporaryPath, filePath);
}
async function fetchJsonBytes(fetcher: FetchLike, url: string, token: string) {
const response = await fetcher(url, {
headers: {
authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
throw new Error(`Catalog export request failed: ${response.status} ${response.statusText}`);
}
return Buffer.from(await response.arrayBuffer());
}
function parseJson(buffer: Buffer, label: string) {
try {
return JSON.parse(buffer.toString('utf8')) as unknown;
@@ -90,14 +92,24 @@ function parseJson(buffer: Buffer, label: string) {
}
}
export async function importCatalogExport(options: ImportCatalogExportOptions): Promise<ImportCatalogExportResult> {
const fetcher = options.fetcher ?? fetch;
const dbFilePath = options.dbFilePath ?? getDefaultDbFilePath();
const importedAt = (options.now ?? new Date()).toISOString();
function resolveArtifactPath(exportDir: string, artifactPath: string) {
if (path.isAbsolute(artifactPath)) {
throw new Error('Catalog manifest artifactPath must be relative to the shared export directory');
}
const manifestBytes = await fetchJsonBytes(fetcher, options.manifestUrl, options.token);
const resolvedExportDir = path.resolve(exportDir);
const resolvedArtifactPath = path.resolve(resolvedExportDir, artifactPath);
const relativePath = path.relative(resolvedExportDir, resolvedArtifactPath);
if (relativePath === '' || relativePath === '..' || relativePath.startsWith(`..${path.sep}`)) {
throw new Error('Catalog manifest artifactPath escapes the shared export directory');
}
return resolvedArtifactPath;
}
function validateExport(manifestBytes: Buffer, artifactBytes: Buffer) {
const manifest = parseCatalogExportManifest(parseJson(manifestBytes, 'manifest'));
const artifactBytes = await fetchJsonBytes(fetcher, manifest.artifactUrl, options.token);
const artifactHash = sha256(artifactBytes);
if (artifactHash.toLowerCase() !== manifest.sha256.toLowerCase()) {
@@ -118,17 +130,49 @@ export async function importCatalogExport(options: ImportCatalogExportOptions):
throw new Error(`Catalog itemCount mismatch: manifest=${manifest.itemCount}, artifact=${catalog.products.length}`);
}
return {
catalog,
manifest,
sha256: artifactHash,
};
}
export async function readCatalogExport(
options: ReadCatalogExportOptions = {},
): Promise<ReadCatalogExportResult> {
const exportDir = path.resolve(options.exportDir ?? getDefaultExportDir());
const manifestBytes = await readFile(path.join(exportDir, 'manifest.json'));
const manifest = parseCatalogExportManifest(parseJson(manifestBytes, 'manifest'));
const artifactPath = manifest.artifactPath ?? path.posix.join('artifacts', manifest.exportId, 'catalog.json');
const artifactBytes = await readFile(resolveArtifactPath(exportDir, artifactPath));
const validated = validateExport(manifestBytes, artifactBytes);
return {
catalog: validated.catalog,
createdAt: validated.manifest.createdAt,
exportDir,
exportId: validated.manifest.exportId,
itemCount: validated.manifest.itemCount,
sha256: validated.sha256,
};
}
export async function importCatalogExport(options: ImportCatalogExportOptions = {}): Promise<ImportCatalogExportResult> {
const dbFilePath = options.dbFilePath ?? getDefaultDbFilePath();
const importedAt = (options.now ?? new Date()).toISOString();
const exportResult = await readCatalogExport(options);
const current = await readExistingDb(dbFilePath);
await writeJsonAtomically(dbFilePath, {
...current,
dashcamCatalog: catalog,
dashcamCatalog: exportResult.catalog,
dashcamCatalogImportedAt: importedAt,
});
return {
exportId: manifest.exportId,
itemCount: manifest.itemCount,
sha256: artifactHash,
exportId: exportResult.exportId,
itemCount: exportResult.itemCount,
sha256: exportResult.sha256,
dbFilePath,
};
}
@@ -1,10 +1,11 @@
import { describe, expect, it } from 'vitest';
import { DashcamCatalogExportSchema } from './schema';
import { CatalogExportManifestSchema, DashcamCatalogExportSchema } from './schema';
const baseProduct = {
id: 'ibox-f5',
brand: 'iBOX',
model: 'F5 WiFi',
canonicalName: 'iBOX F5 Wi-Fi',
title: 'Видеорегистратор для автомобиля iBOX F5 + WiFi с радар-детектором',
specs: {
channels: 1,
@@ -66,4 +67,45 @@ describe('dashcam catalog export schema', () => {
}),
).toThrow();
});
it('accepts a local shared-volume artifact path and MVideo source metadata', () => {
expect(
CatalogExportManifestSchema.parse({
schemaVersion: 1,
exportId: 'export_1',
createdAt: '2026-05-16T21:00:00.000Z',
artifactPath: 'artifacts/export_1/catalog.json',
sha256: 'a'.repeat(64),
itemCount: 1,
}),
).toMatchObject({
artifactPath: 'artifacts/export_1/catalog.json',
});
expect(
DashcamCatalogExportSchema.parse({
schemaVersion: 1,
exportId: 'export_1',
createdAt: '2026-05-16T21:00:00.000Z',
products: [
{
...baseProduct,
sources: [
{
...baseProduct.sources[0],
source: 'mvideo',
url: 'https://www.mvideo.ru/products/test-4254724',
},
],
},
],
}),
).toMatchObject({
products: [
{
sources: [{ source: 'mvideo' }],
},
],
});
});
});
@@ -5,12 +5,13 @@ const isoDateTime = z.string().refine((value) => !Number.isNaN(Date.parse(value)
});
const exportedSpecValueSchema = z.union([z.string(), z.number(), z.boolean()]);
export const MarketSourceSchema = z.enum(['yandex_market', 'mvideo']);
export const ExportedProductImageSchema = z
.object({
url: z.string().url(),
originalUrl: z.string().url(),
source: z.literal('yandex_market'),
source: MarketSourceSchema,
sha256: z.string().min(1).optional(),
width: z.number().int().positive().optional(),
height: z.number().int().positive().optional(),
@@ -19,7 +20,7 @@ export const ExportedProductImageSchema = z
export const ExportedProductSourceSchema = z
.object({
source: z.literal('yandex_market'),
source: MarketSourceSchema,
sourceProductId: z.string().min(1).optional(),
url: z.string().url(),
stableContentHash: z.string().min(1),
@@ -32,6 +33,7 @@ export const ExportedDashcamProductSchema = z
id: z.string().min(1),
brand: z.string().min(1),
model: z.string().min(1),
canonicalName: z.string().min(1).optional(),
title: z.string().min(1),
specs: z.record(z.string().min(1), exportedSpecValueSchema),
images: z.array(ExportedProductImageSchema),
@@ -55,11 +57,15 @@ export const CatalogExportManifestSchema = z
schemaVersion: z.literal(1),
exportId: z.string().min(1),
createdAt: isoDateTime,
artifactUrl: z.string().url(),
artifactPath: z.string().min(1).optional(),
artifactUrl: z.string().url().optional(),
sha256: z.string().regex(/^[a-f0-9]{64}$/i),
itemCount: z.number().int().nonnegative(),
})
.strict();
.strict()
.refine((manifest) => Boolean(manifest.artifactPath || manifest.artifactUrl), {
message: 'Expected artifactPath or artifactUrl',
});
export type CatalogExportManifest = z.infer<typeof CatalogExportManifestSchema>;
export type DashcamCatalogExport = z.infer<typeof DashcamCatalogExportSchema>;
@@ -67,6 +73,7 @@ export type ExportedDashcamProduct = z.infer<typeof ExportedDashcamProductSchema
export type ExportedProductImage = z.infer<typeof ExportedProductImageSchema>;
export type ExportedProductSource = z.infer<typeof ExportedProductSourceSchema>;
export type ExportedSpecValue = z.infer<typeof exportedSpecValueSchema>;
export type MarketSource = z.infer<typeof MarketSourceSchema>;
export function parseCatalogExportManifest(input: unknown) {
return CatalogExportManifestSchema.parse(input);
+1 -1
View File
@@ -49,7 +49,7 @@ function getDbFilePath() {
return process.env.VIDEOREG_DB_FILE;
}
return path.join(/* turbopackIgnore: true */ process.cwd(), 'data', 'videoreg.json');
return path.join(/*turbopackIgnore: true*/ process.cwd(), 'data', 'videoreg.json');
}
async function ensureDbFile(filePath: string) {
@@ -0,0 +1,151 @@
import type { DashcamCatalogExport, ExportedDashcamProduct, ExportedSpecValue } from '@/entities/dashcam-catalog/model/schema';
import type { DashcamCatalogReadModel } from '@/entities/dashcam-catalog/api/catalogRepository';
type DashcamCatalogProps = {
catalog: DashcamCatalogExport | undefined;
source: DashcamCatalogReadModel['source'];
exportInfo?: DashcamCatalogReadModel['export'];
hasSharedExportIssue?: boolean;
};
const preferredSpecKeys = [
'resolution_front',
'resolution_max',
'channels',
'wifi',
'night_mode',
'gps',
'screen',
'battery_power',
'car_power',
] as const;
const sourceLabels = {
mvideo: 'М.Видео',
yandex_market: 'Яндекс Маркет',
};
const specLabels: Record<string, string> = {
battery_power: 'Резервное питание',
car_power: 'Питание от автомобиля',
channels: 'Камер',
gps: 'GPS',
night_mode: 'Ночной режим',
resolution_front: 'Разрешение',
resolution_max: 'Макс. разрешение',
screen: 'Экран',
wifi: 'Wi-Fi',
};
function formatSpecValue(value: ExportedSpecValue) {
if (typeof value === 'boolean') return value ? 'Да' : 'Нет';
if (value === 'battery') return 'Аккумулятор';
if (value === 'capacitor') return 'Конденсатор';
if (value === 'hybrid') return 'Аккумулятор + конденсатор';
return String(value);
}
function formatDate(value: string) {
return new Intl.DateTimeFormat('ru-RU', {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(value));
}
function ProductCard({ product }: { product: ExportedDashcamProduct }) {
const productSpecs = preferredSpecKeys
.filter((key) => product.specs[key] !== undefined)
.map((key) => [key, product.specs[key]] as const);
const remainingSpecs = Object.entries(product.specs).filter(([key]) => !preferredSpecKeys.includes(key as never));
return (
<article className="catalog-product">
<div className="catalog-product__media" aria-hidden="true">
<img alt="" src="/dashcam-product.svg" />
</div>
<div className="catalog-product__body">
<div className="catalog-product__heading">
<div>
<p className="catalog-product__brand">{product.brand}</p>
<h2>{product.canonicalName ?? product.model}</h2>
</div>
<span className="catalog-product__source-count">{product.sources.length} источник</span>
</div>
<p className="catalog-product__title">{product.title}</p>
{productSpecs.length > 0 ? (
<dl className="catalog-product__specs">
{productSpecs.map(([key, value]) => (
<div key={key}>
<dt>{specLabels[key] ?? key}</dt>
<dd>{formatSpecValue(value)}</dd>
</div>
))}
</dl>
) : null}
{remainingSpecs.length > 0 ? (
<details className="catalog-product__details">
<summary>Все характеристики ({Object.keys(product.specs).length})</summary>
<dl>
{remainingSpecs.map(([key, value]) => (
<div key={key}>
<dt>{specLabels[key] ?? key}</dt>
<dd>{formatSpecValue(value)}</dd>
</div>
))}
</dl>
</details>
) : null}
<div className="catalog-product__sources">
{product.sources.map((source) => (
<a href={source.url} key={`${source.source}-${source.url}`} rel="noreferrer" target="_blank">
{sourceLabels[source.source]}
</a>
))}
</div>
</div>
</article>
);
}
export function DashcamCatalog({ catalog, source, exportInfo, hasSharedExportIssue }: DashcamCatalogProps) {
const products = catalog?.products ?? [];
return (
<main className="catalog-page">
<header className="catalog-header">
<div>
<p className="catalog-header__eyebrow">videoreg.ru</p>
<h1>Автомобильные видеорегистраторы</h1>
<p className="catalog-header__subtitle">Проверенный каталог карточек из публичных источников.</p>
</div>
<div className="catalog-header__meta">
<strong>{products.length}</strong>
<span>одобренных моделей</span>
</div>
</header>
<section className="catalog-status" aria-label="Catalog status">
<span>Источник: {source === 'shared_export' ? 'shared export' : source === 'read_model' ? 'read model' : 'ожидается export'}</span>
{exportInfo ? <span>Обновлён: {formatDate(exportInfo.createdAt)}</span> : null}
{hasSharedExportIssue ? <span>Последний shared export недоступен, показана сохранённая версия.</span> : null}
</section>
{products.length > 0 ? (
<section className="catalog-grid" aria-label="Dashcam catalog">
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</section>
) : (
<section className="catalog-empty">
<h2>Каталог ожидает moderation</h2>
<p>Одобренные карточки появятся после первого export из ingestion.</p>
</section>
)}
</main>
);
}