move next to web-front

This commit is contained in:
2026-05-17 01:08:19 +03:00
parent cc219cdb26
commit 9e53a7b4fc
52 changed files with 1442 additions and 175 deletions
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+10
View File
@@ -0,0 +1,10 @@
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
output: 'standalone',
turbopack: {
root: process.cwd(),
},
};
export default nextConfig;
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
'@tailwindcss/postcss': {},
},
};
export default config;
+1
View File
@@ -0,0 +1 @@
+18
View File
@@ -0,0 +1,18 @@
<svg width="1200" height="720" viewBox="0 0 1200 720" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="1200" height="720" fill="#151A1C"/>
<path d="M0 515C206 448 412 418 618 424C824 430 1018 473 1200 552V720H0V515Z" fill="#202629"/>
<path d="M170 574C356 516 552 492 758 504C918 513 1065 548 1200 608V720H0V646C55 618 112 594 170 574Z" fill="#2B3235"/>
<path d="M592 515L548 720H653L615 515H592Z" fill="#D7D1C4" fill-opacity=".72"/>
<path d="M423 292C423 246.713 459.713 210 505 210H740C785.287 210 822 246.713 822 292V396C822 441.287 785.287 478 740 478H505C459.713 478 423 441.287 423 396V292Z" fill="#0F1314"/>
<path d="M423 292C423 246.713 459.713 210 505 210H740C785.287 210 822 246.713 822 292V396C822 441.287 785.287 478 740 478H505C459.713 478 423 441.287 423 396V292Z" stroke="#6F7779" stroke-width="3"/>
<path d="M486 210L518 164H716L748 210H486Z" fill="#1E2527" stroke="#6F7779" stroke-width="3"/>
<circle cx="623" cy="344" r="93" fill="#1B2224" stroke="#A7B8BD" stroke-width="5"/>
<circle cx="623" cy="344" r="62" fill="#0B0D0E" stroke="#56798A" stroke-width="4"/>
<circle cx="623" cy="344" r="31" fill="#223037"/>
<circle cx="642" cy="324" r="13" fill="#D9E7EA" fill-opacity=".55"/>
<rect x="483" y="278" width="72" height="19" rx="4" fill="#56798A" fill-opacity=".55"/>
<rect x="692" y="278" width="72" height="19" rx="4" fill="#8F7660" fill-opacity=".46"/>
<rect x="488" y="410" width="270" height="14" rx="7" fill="#D7D1C4" fill-opacity=".25"/>
<path d="M289 185C410 109 567 75 760 92C895 104 1005 146 1090 218" stroke="#D9E7EA" stroke-opacity=".16" stroke-width="2"/>
<path d="M183 446C304 405 440 385 591 385C797 385 968 423 1104 499" stroke="#D9E7EA" stroke-opacity=".14" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+28
View File
@@ -0,0 +1,28 @@
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;
if (!manifestUrl) {
throw new Error('Set DASHCAM_CATALOG_MANIFEST_URL or pass manifest URL as the first argument');
}
if (!token) {
throw new Error('Set DASHCAM_CATALOG_EXPORT_TOKEN');
}
const result = await importCatalogExport({ manifestUrl, token });
console.log(
JSON.stringify(
{
status: 'imported',
exportId: result.exportId,
itemCount: result.itemCount,
sha256: result.sha256,
dbFilePath: result.dbFilePath,
},
null,
2,
),
);
@@ -0,0 +1,19 @@
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Design System Showcase | videoreg.ru',
description:
'Hidden design-system showcase for the Best Dashcams 2026 comparison experience.',
robots: {
follow: false,
index: false,
},
};
type ShowcaseLayoutProps = {
children: React.ReactNode;
};
export default function ShowcaseLayout({ children }: ShowcaseLayoutProps) {
return children;
}
@@ -0,0 +1,26 @@
import { notFound } from 'next/navigation';
import { dashcamProducts } from '@/entities/dashcam/model/data';
import { BestDashcamsPage } from '@/shared/ui/templates/BestDashcamsPage';
export const dynamicParams = false;
export const revalidate = 3600;
export function generateStaticParams() {
return [{ devRoot: '_dev' }];
}
type ShowcasePageProps = {
params: Promise<{
devRoot: string;
}>;
};
export default async function ShowcasePage({ params }: ShowcasePageProps) {
const { devRoot } = await params;
if (devRoot !== '_dev') {
notFound();
}
return <BestDashcamsPage products={dashcamProducts} />;
}
+29
View File
@@ -0,0 +1,29 @@
import { NextResponse } from 'next/server';
import { revalidatePath, revalidateTag } from 'next/cache';
import { getCachedVideoDashboard, VIDEO_COLLECTION_TAG } from '@/entities/video/api/videoCache';
import { addVideo } from '@/entities/video/api/videoRepository';
import { normalizeVideoDraft } from '@/entities/video/model/video';
export const revalidate = 3600;
export async function GET() {
const dashboard = await getCachedVideoDashboard();
return NextResponse.json(dashboard, {
headers: {
'cache-control': 'public, max-age=60, s-maxage=3600, stale-while-revalidate=86400',
},
});
}
export async function POST(request: Request) {
const body = (await request.json().catch(() => undefined)) as
| { title?: string; owner?: string; sourceUrl?: string; status?: string }
| undefined;
const video = await addVideo(normalizeVideoDraft(body ?? {}));
revalidateTag(VIDEO_COLLECTION_TAG, { expire: 0 });
revalidatePath('/');
return NextResponse.json({ video }, { status: 201 });
}
+412
View File
@@ -0,0 +1,412 @@
@import "tailwindcss";
:root {
color-scheme: light;
--font-ui: Geist, Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
--font-tech: "IBM Plex Mono", Iosevka, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
"Liberation Mono", monospace;
--surface-0: #f2eee6;
--surface-1: #fbfaf6;
--surface-2: #e8e1d5;
--surface-3: #d8cfc0;
--surface-inverse: #151a1c;
--text-primary: #202528;
--text-muted: #667076;
--text-subtle: #858076;
--text-inverse: #f7f3ea;
--text-on-accent: #f8fbfb;
--border-subtle: #d7cec0;
--border-strong: #bdb3a4;
--accent: #56798a;
--accent-strong: #385f70;
--accent-soft: #dbe7e9;
--accent-soft-border: #b7cbd1;
--warning: #8f7044;
--warning-soft: #efe5d6;
--warning-soft-border: #d7bea0;
--danger: #884b46;
--danger-soft: #eadbd8;
--danger-soft-border: #c9aaa5;
--success: #58705c;
--success-soft: #dde8dd;
--success-soft-border: #b8ccb9;
--focus: #56798a;
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
--shadow-soft: 0 14px 34px rgb(32 37 40 / 8%);
--shadow-raised: 0 22px 48px rgb(32 37 40 / 12%);
}
@theme inline {
--font-sans: var(--font-ui);
--font-mono: var(--font-tech);
}
@media (prefers-color-scheme: dark) {
:root {
color-scheme: dark;
--surface-0: #101415;
--surface-1: #171d1f;
--surface-2: #202729;
--surface-3: #2d3538;
--surface-inverse: #f4efe6;
--text-primary: #f3efe8;
--text-muted: #b7b2aa;
--text-subtle: #948f87;
--text-inverse: #161a1c;
--text-on-accent: #f8fbfb;
--border-subtle: #30383b;
--border-strong: #4b5559;
--accent: #7f9ead;
--accent-strong: #b5ced7;
--accent-soft: #1d3139;
--accent-soft-border: #31505b;
--warning: #d0b184;
--warning-soft: #352a1e;
--warning-soft-border: #5a462b;
--danger: #d7aaa5;
--danger-soft: #351f1f;
--danger-soft-border: #583433;
--success: #a8c2aa;
--success-soft: #1f3022;
--success-soft-border: #38523b;
--shadow-soft: 0 14px 34px rgb(0 0 0 / 24%);
--shadow-raised: 0 22px 48px rgb(0 0 0 / 32%);
}
}
* {
box-sizing: border-box;
}
html {
min-height: 100%;
scroll-behavior: smooth;
}
body {
min-height: 100%;
margin: 0;
background: var(--surface-0);
color: var(--text-primary);
font-family: var(--font-ui);
text-rendering: optimizeLegibility;
}
button,
input,
select {
font: inherit;
}
a {
color: inherit;
text-decoration: none;
}
img {
max-width: 100%;
}
h1,
h2,
h3,
p,
td,
th {
overflow-wrap: anywhere;
}
::selection {
background: var(--accent-soft);
color: var(--text-primary);
}
.page-shell {
width: min(1180px, calc(100% - 32px));
margin: 0 auto;
padding: 24px 0 40px;
}
.topbar {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24px;
border-bottom: 1px solid var(--border-subtle);
padding-bottom: 18px;
}
.eyebrow {
margin: 0 0 6px;
color: var(--accent-strong);
font-size: 0.78rem;
font-weight: 700;
text-transform: uppercase;
}
.page-shell h1 {
margin: 0;
font-size: 2rem;
line-height: 1.1;
}
.page-shell h2 {
margin: 0;
font-size: 1.1rem;
line-height: 1.25;
}
.page-shell h3 {
margin: 0;
font-size: 0.95rem;
line-height: 1.25;
}
.workbench {
display: grid;
grid-template-columns: minmax(280px, 360px) minmax(0, 1fr);
gap: 20px;
align-items: start;
padding-top: 22px;
}
.panel {
background: var(--surface-1);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-soft);
}
.panel-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
border-bottom: 1px solid var(--border-subtle);
padding: 16px;
}
.panel-body {
padding: 16px;
}
.form-grid {
display: grid;
gap: 14px;
}
.field {
display: grid;
gap: 6px;
}
.field span {
color: var(--text-muted);
font-size: 0.82rem;
font-weight: 650;
}
.field input,
.field select {
min-height: 42px;
width: 100%;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
background: var(--surface-1);
color: var(--text-primary);
padding: 9px 10px;
}
.field input:focus,
.field select:focus {
border-color: var(--accent);
outline: 3px solid color-mix(in srgb, var(--accent) 22%, transparent);
}
.submit-button {
min-height: 42px;
border: 0;
border-radius: var(--radius-md);
background: var(--accent);
color: var(--text-on-accent);
cursor: pointer;
font-weight: 750;
}
.submit-button:hover {
background: var(--accent-strong);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(4, minmax(110px, 1fr));
gap: 10px;
}
.metric {
min-height: 76px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
background: var(--surface-2);
padding: 12px;
}
.metric span {
display: block;
color: var(--text-muted);
font-size: 0.76rem;
font-weight: 700;
text-transform: uppercase;
}
.metric strong {
display: block;
margin-top: 7px;
font-size: 1.45rem;
}
.video-list {
display: grid;
gap: 10px;
margin-top: 16px;
}
.video-card {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 14px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
background: var(--surface-1);
padding: 14px;
}
.video-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
}
.pill {
min-height: 24px;
border-radius: 999px;
background: var(--surface-2);
color: var(--text-muted);
padding: 4px 9px;
font-size: 0.76rem;
font-weight: 700;
}
.pill.status-draft {
color: var(--warning);
}
.pill.status-published {
color: var(--success);
}
.pill.status-archived {
color: var(--danger);
}
.cache-panel {
display: grid;
grid-template-columns: repeat(3, auto);
gap: 8px;
justify-content: end;
}
.cache-item {
min-height: 42px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
background: var(--surface-1);
padding: 7px 10px;
}
.cache-item span {
display: block;
color: var(--text-muted);
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
}
.cache-item strong {
display: block;
margin-top: 2px;
font-size: 0.84rem;
}
.empty-state {
border: 1px dashed var(--border-subtle);
border-radius: var(--radius-lg);
color: var(--text-muted);
padding: 22px;
text-align: center;
}
@media (max-width: 900px) {
.topbar,
.workbench {
grid-template-columns: 1fr;
}
.topbar {
display: grid;
}
.cache-panel {
justify-content: stretch;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.stats-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 560px) {
.page-shell {
width: min(100% - 20px, 1180px);
padding-top: 16px;
}
.page-shell h1 {
font-size: 1.55rem;
}
.cache-panel,
.stats-grid,
.video-card {
grid-template-columns: 1fr;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
}
}
+14
View File
@@ -0,0 +1,14 @@
import { NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
export function GET() {
return NextResponse.json({
status: 'ok',
service: 'videoreg-web',
metadata: {
commitSha: process.env.COMMIT_SHA ?? 'local',
releaseId: process.env.RELEASE_ID ?? 'local',
},
});
}
+17
View File
@@ -0,0 +1,17 @@
import { NextResponse } from 'next/server';
import { listVideos } from '@/entities/video/api/videoRepository';
export const dynamic = 'force-dynamic';
export async function GET() {
await listVideos();
return NextResponse.json({
status: 'ok',
service: 'videoreg-web',
metadata: {
commitSha: process.env.COMMIT_SHA ?? 'local',
releaseId: process.env.RELEASE_ID ?? 'local',
},
});
}
+19
View File
@@ -0,0 +1,19 @@
import type { Metadata } from 'next';
import './globals.css';
export const metadata: Metadata = {
title: 'Video Registry',
description: 'Operational video registry with primitive storage and aggressive caching.',
};
type RootLayoutProps = {
children: React.ReactNode;
};
export default function RootLayout({ children }: RootLayoutProps) {
return (
<html id="top" lang="en">
<body>{children}</body>
</html>
);
}
+27
View File
@@ -0,0 +1,27 @@
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';
export const revalidate = 3600;
export default async function Home() {
const dashboard = await getCachedVideoDashboard();
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>
);
}
@@ -0,0 +1,96 @@
import { createHash } from 'node:crypto';
import { mkdtemp, readFile, rm } 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 () => {
const tempDir = await mkdtemp(path.join(os.tmpdir(), 'videoreg-catalog-import-'));
const dbFilePath = path.join(tempDir, 'videoreg.json');
try {
const catalog = {
schemaVersion: 1,
exportId: 'export_1',
createdAt: '2026-05-16T21:00:00.000Z',
products: [
{
id: 'ibox-f5',
brand: 'iBOX',
model: 'F5 WiFi',
title: 'Видеорегистратор для автомобиля iBOX F5 + WiFi с радар-детектором',
specs: {
channels: 1,
wifi: true,
},
images: [],
sources: [
{
source: 'yandex_market',
sourceProductId: '5197387201',
url: 'https://market.yandex.ru/card/ibox-f5-wifi-videoregistrator-s-signaturnym-radar-detektorom/5197387201',
stableContentHash: 'stable-hash',
fetchedAt: '2026-05-16T20:00:00.000Z',
},
],
discoveredAt: '2026-05-16T20:00:00.000Z',
updatedAt: '2026-05-16T20:00:00.000Z',
},
],
};
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();
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 });
};
const result = await importCatalogExport({
manifestUrl: 'https://exports.example.test/manifest.json',
token: 'secret',
dbFilePath,
fetcher,
now: new Date('2026-05-16T22:00:00.000Z'),
});
const stored = JSON.parse(await readFile(dbFilePath, 'utf8')) as Record<string, unknown>;
expect(result).toMatchObject({
exportId: 'export_1',
itemCount: 1,
sha256: digest,
});
expect(stored.dashcamCatalog).toEqual(catalog);
expect(stored.dashcamCatalogImportedAt).toBe('2026-05-16T22:00:00.000Z');
expect(stored.videos).toEqual([]);
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});
});
@@ -0,0 +1,134 @@
import { createHash } from 'node:crypto';
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
import path from 'node:path';
import {
parseCatalogExportManifest,
parseDashcamCatalogExport,
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;
dbFilePath?: string;
fetcher?: FetchLike;
now?: Date;
};
export type ImportCatalogExportResult = {
exportId: string;
itemCount: number;
sha256: string;
dbFilePath: string;
};
function getDefaultDbFilePath() {
if (process.env.VIDEOREG_DB_FILE) {
return process.env.VIDEOREG_DB_FILE;
}
return path.join(process.cwd(), 'data', 'videoreg.json');
}
function sha256(buffer: Buffer) {
return createHash('sha256').update(buffer).digest('hex');
}
async function readExistingDb(filePath: string): Promise<PrimitiveDbCache> {
try {
const raw = await readFile(filePath, 'utf8');
const parsed = JSON.parse(raw) as PrimitiveDbCache;
return {
...parsed,
videos: Array.isArray(parsed.videos) ? parsed.videos : [],
};
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return { videos: [] };
}
throw error;
}
}
async function writeJsonAtomically(filePath: string, payload: PrimitiveDbCache) {
const temporaryPath = `${filePath}.${process.pid}.tmp`;
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(temporaryPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
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;
} catch (error) {
throw new Error(`Invalid ${label} JSON: ${(error as Error).message}`);
}
}
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();
const manifestBytes = await fetchJsonBytes(fetcher, options.manifestUrl, options.token);
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()) {
throw new Error(`Catalog artifact checksum mismatch: expected ${manifest.sha256}, got ${artifactHash}`);
}
const catalog = parseDashcamCatalogExport(parseJson(artifactBytes, 'catalog artifact'));
if (catalog.exportId !== manifest.exportId) {
throw new Error(`Catalog exportId mismatch: manifest=${manifest.exportId}, artifact=${catalog.exportId}`);
}
if (catalog.createdAt !== manifest.createdAt) {
throw new Error(`Catalog createdAt mismatch: manifest=${manifest.createdAt}, artifact=${catalog.createdAt}`);
}
if (catalog.products.length !== manifest.itemCount) {
throw new Error(`Catalog itemCount mismatch: manifest=${manifest.itemCount}, artifact=${catalog.products.length}`);
}
const current = await readExistingDb(dbFilePath);
await writeJsonAtomically(dbFilePath, {
...current,
dashcamCatalog: catalog,
dashcamCatalogImportedAt: importedAt,
});
return {
exportId: manifest.exportId,
itemCount: manifest.itemCount,
sha256: artifactHash,
dbFilePath,
};
}
@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest';
import { DashcamCatalogExportSchema } from './schema';
const baseProduct = {
id: 'ibox-f5',
brand: 'iBOX',
model: 'F5 WiFi',
title: 'Видеорегистратор для автомобиля iBOX F5 + WiFi с радар-детектором',
specs: {
channels: 1,
wifi: true,
resolution_front: '2304x1296',
},
images: [
{
url: 'https://cdn.example.test/images/ibox-f5/orig.jpg',
originalUrl: 'https://avatars.mds.yandex.net/get-mpic/18787582/2a/orig',
source: 'yandex_market',
},
],
sources: [
{
source: 'yandex_market',
sourceProductId: '5197387201',
url: 'https://market.yandex.ru/card/ibox-f5-wifi-videoregistrator-s-signaturnym-radar-detektorom/5197387201',
stableContentHash: 'abc123',
fetchedAt: '2026-05-16T20:00:00.000Z',
},
],
discoveredAt: '2026-05-16T20:00:00.000Z',
updatedAt: '2026-05-16T20:00:00.000Z',
};
describe('dashcam catalog export schema', () => {
it('accepts approved canonical catalog payload', () => {
expect(
DashcamCatalogExportSchema.parse({
schemaVersion: 1,
exportId: 'export_1',
createdAt: '2026-05-16T21:00:00.000Z',
products: [baseProduct],
}),
).toEqual({
schemaVersion: 1,
exportId: 'export_1',
createdAt: '2026-05-16T21:00:00.000Z',
products: [baseProduct],
});
});
it('rejects internal-only aggregate rating in public export', () => {
expect(() =>
DashcamCatalogExportSchema.parse({
schemaVersion: 1,
exportId: 'export_1',
createdAt: '2026-05-16T21:00:00.000Z',
products: [
{
...baseProduct,
aggregateRating: {
value: 4.8,
ratingCount: 17,
},
},
],
}),
).toThrow();
});
});
@@ -0,0 +1,77 @@
import { z } from 'zod';
const isoDateTime = z.string().refine((value) => !Number.isNaN(Date.parse(value)), {
message: 'Expected an ISO-compatible date-time string',
});
const exportedSpecValueSchema = z.union([z.string(), z.number(), z.boolean()]);
export const ExportedProductImageSchema = z
.object({
url: z.string().url(),
originalUrl: z.string().url(),
source: z.literal('yandex_market'),
sha256: z.string().min(1).optional(),
width: z.number().int().positive().optional(),
height: z.number().int().positive().optional(),
})
.strict();
export const ExportedProductSourceSchema = z
.object({
source: z.literal('yandex_market'),
sourceProductId: z.string().min(1).optional(),
url: z.string().url(),
stableContentHash: z.string().min(1),
fetchedAt: isoDateTime,
})
.strict();
export const ExportedDashcamProductSchema = z
.object({
id: z.string().min(1),
brand: z.string().min(1),
model: z.string().min(1),
title: z.string().min(1),
specs: z.record(z.string().min(1), exportedSpecValueSchema),
images: z.array(ExportedProductImageSchema),
sources: z.array(ExportedProductSourceSchema).min(1),
discoveredAt: isoDateTime,
updatedAt: isoDateTime,
})
.strict();
export const DashcamCatalogExportSchema = z
.object({
schemaVersion: z.literal(1),
exportId: z.string().min(1),
createdAt: isoDateTime,
products: z.array(ExportedDashcamProductSchema),
})
.strict();
export const CatalogExportManifestSchema = z
.object({
schemaVersion: z.literal(1),
exportId: z.string().min(1),
createdAt: isoDateTime,
artifactUrl: z.string().url(),
sha256: z.string().regex(/^[a-f0-9]{64}$/i),
itemCount: z.number().int().nonnegative(),
})
.strict();
export type CatalogExportManifest = z.infer<typeof CatalogExportManifestSchema>;
export type DashcamCatalogExport = z.infer<typeof DashcamCatalogExportSchema>;
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 function parseCatalogExportManifest(input: unknown) {
return CatalogExportManifestSchema.parse(input);
}
export function parseDashcamCatalogExport(input: unknown) {
return DashcamCatalogExportSchema.parse(input);
}
@@ -0,0 +1,401 @@
import type { DashcamProduct, ScoreDimension, SpecKey } from './types';
export const scoreDimensions: Array<{ key: ScoreDimension; label: string }> = [
{ key: 'video', label: 'Video' },
{ key: 'night', label: 'Night' },
{ key: 'parking', label: 'Parking' },
{ key: 'reliability', label: 'Reliability' },
{ key: 'usability', label: 'Usability' },
{ key: 'value', label: 'Value' },
];
export const comparisonSpecRows: Array<{ key: SpecKey; label: string }> = [
{ key: 'sensor', label: 'Sensor' },
{ key: 'resolution', label: 'Resolution / FPS' },
{ key: 'bitrate', label: 'Bitrate' },
{ key: 'hdr', label: 'HDR' },
{ key: 'lens', label: 'Lens / FOV' },
{ key: 'storage', label: 'Storage' },
{ key: 'power', label: 'Capacitor / battery' },
{ key: 'channels', label: 'Channels' },
];
export const dashcamProducts: DashcamProduct[] = [
{
id: 'kaito-vx9-pro',
brand: 'Kaito',
model: 'VX9 Pro',
award: 'Overall pick',
tagline: 'The most balanced front and rear system for daily drivers.',
score: 9.2,
price: '$329',
priceNote: 'typical kit price',
seller: 'Verified retailers',
ctaLabel: 'Check sellers',
mediaTone: 'petrol',
evidence: '46 logged drives, 12 night routes, 3 heat cycles',
editorialNote: 'Wins because the night image holds plate detail without making the app or install feel fragile.',
strengths: ['stable HDR', 'clear night plates', 'clean app pairing'],
knownIssues: ['rear cable is stiff in cold weather'],
scores: {
video: 9.4,
night: 9.1,
parking: 8.8,
reliability: 9.3,
usability: 9.0,
value: 8.8,
},
specs: {
sensor: {
label: 'Sensor',
value: '1/1.8 in stacked CMOS',
trust: 'verified',
method: 'teardown',
source: 'bench teardown',
},
resolution: {
label: 'Resolution / FPS',
value: '4K 60 front + 2K rear',
trust: 'verified',
method: 'measured',
source: 'sample footage',
},
bitrate: {
label: 'Bitrate',
value: '82 Mbps front',
trust: 'verified',
method: 'measured',
source: 'file analysis',
},
hdr: {
label: 'HDR',
value: 'dual-exposure HDR',
trust: 'verified',
method: 'firmware-docs',
source: 'firmware notes',
},
lens: {
label: 'Lens / FOV',
value: 'f/1.7, 146 deg',
trust: 'claimed',
method: 'manufacturer',
},
storage: {
label: 'Storage',
value: '512 GB microSD',
trust: 'verified',
method: 'measured',
},
power: {
label: 'Capacitor / battery',
value: 'supercapacitor',
trust: 'verified',
method: 'teardown',
},
channels: {
label: 'Channels',
value: '2-channel',
trust: 'verified',
method: 'multi-source',
},
},
},
{
id: 'renga-night-s2',
brand: 'Renga',
model: 'Night S2',
award: 'Best night detail',
tagline: 'A low-light specialist for unlit roads and wet city glass.',
score: 9.1,
price: '$289',
priceNote: 'front unit',
seller: 'Camera specialists',
ctaLabel: 'View pricing',
mediaTone: 'graphite',
evidence: '31 logged night routes, rain test, tunnel exposure test',
editorialNote: 'Plate recovery is excellent, but the parking mode setup is less polished than the overall pick.',
strengths: ['low noise image', 'fast exposure recovery', 'strong windshield glare control'],
knownIssues: ['parking menus need clearer wording'],
scores: {
video: 9.0,
night: 9.7,
parking: 7.9,
reliability: 8.7,
usability: 8.2,
value: 8.9,
},
specs: {
sensor: {
label: 'Sensor',
value: 'large-format night CMOS',
trust: 'claimed',
method: 'manufacturer',
},
resolution: {
label: 'Resolution / FPS',
value: '4K 30 front',
trust: 'verified',
method: 'measured',
},
bitrate: {
label: 'Bitrate',
value: '76 Mbps front',
trust: 'verified',
method: 'measured',
},
hdr: {
label: 'HDR',
value: 'night HDR',
trust: 'claimed',
method: 'manufacturer',
},
lens: {
label: 'Lens / FOV',
value: 'f/1.6, 142 deg',
trust: 'claimed',
method: 'manufacturer',
},
storage: {
label: 'Storage',
value: '256 GB microSD',
trust: 'verified',
method: 'measured',
},
power: {
label: 'Capacitor / battery',
value: 'supercapacitor',
trust: 'verified',
method: 'teardown',
},
channels: {
label: 'Channels',
value: '1-channel',
trust: 'verified',
method: 'multi-source',
},
},
},
{
id: 'aster-park-duo',
brand: 'Aster',
model: 'Park Duo',
award: 'Best parking mode',
tagline: 'The safest choice for parked cars and apartment garages.',
score: 8.9,
price: '$379',
priceNote: 'hardwire kit included',
seller: 'Install partners',
ctaLabel: 'Find kit',
mediaTone: 'brass',
evidence: '18 overnight sessions, 8 voltage cut-off cycles, heat soak test',
editorialNote: 'The image is not the sharpest, but the buffered parking behavior is dependable and easy to audit.',
strengths: ['buffered impact clips', 'stable voltage cut-off', 'excellent event labeling'],
knownIssues: ['front 4K mode increases cabin heat'],
scores: {
video: 8.4,
night: 8.5,
parking: 9.6,
reliability: 9.2,
usability: 8.7,
value: 8.1,
},
specs: {
sensor: {
label: 'Sensor',
value: 'dual 5 MP CMOS',
trust: 'unknown',
},
resolution: {
label: 'Resolution / FPS',
value: '2K 60 front + 2K rear',
trust: 'verified',
method: 'measured',
},
bitrate: {
label: 'Bitrate',
value: '64 Mbps combined',
trust: 'verified',
method: 'measured',
},
hdr: {
label: 'HDR',
value: 'parking HDR',
trust: 'claimed',
method: 'manufacturer',
},
lens: {
label: 'Lens / FOV',
value: 'f/1.8, 140 deg',
trust: 'claimed',
method: 'manufacturer',
},
storage: {
label: 'Storage',
value: '512 GB microSD',
trust: 'verified',
method: 'measured',
},
power: {
label: 'Capacitor / battery',
value: 'supercapacitor',
trust: 'verified',
method: 'teardown',
},
channels: {
label: 'Channels',
value: '2-channel',
trust: 'verified',
method: 'multi-source',
},
},
},
{
id: 'mori-value-4k',
brand: 'Mori',
model: 'Value 4K',
award: 'Best value',
tagline: 'Strong front-camera quality without paying for cloud extras.',
score: 8.6,
price: '$179',
priceNote: 'front unit',
seller: 'Marketplace verified',
ctaLabel: 'Compare price',
mediaTone: 'titanium',
evidence: '22 daytime routes, 9 night routes, 2 firmware versions',
editorialNote: 'A credible budget pick because it avoids the usual cheap-camera failure: unreadable evening plates.',
strengths: ['sharp daytime video', 'simple menus', 'good included mount'],
knownIssues: ['voice alerts sound compressed'],
scores: {
video: 8.8,
night: 8.1,
parking: 7.5,
reliability: 8.4,
usability: 8.8,
value: 9.5,
},
specs: {
sensor: {
label: 'Sensor',
value: '8 MP CMOS',
trust: 'unknown',
},
resolution: {
label: 'Resolution / FPS',
value: '4K 30 front',
trust: 'verified',
method: 'measured',
},
bitrate: {
label: 'Bitrate',
value: '58 Mbps front',
trust: 'verified',
method: 'measured',
},
hdr: {
label: 'HDR',
value: 'basic HDR',
trust: 'claimed',
method: 'manufacturer',
},
lens: {
label: 'Lens / FOV',
value: 'f/1.8, 150 deg',
trust: 'claimed',
method: 'manufacturer',
},
storage: {
label: 'Storage',
value: '256 GB microSD',
trust: 'verified',
method: 'measured',
},
power: {
label: 'Capacitor / battery',
value: 'supercapacitor',
trust: 'verified',
method: 'teardown',
},
channels: {
label: 'Channels',
value: '1-channel',
trust: 'verified',
method: 'multi-source',
},
},
},
{
id: 'haven-fleet-lte',
brand: 'Haven',
model: 'Fleet LTE',
award: 'Best connected',
tagline: 'For rideshare drivers who need location, upload, and incident logs.',
score: 8.7,
price: '$449',
priceNote: 'LTE kit',
seller: 'Fleet resellers',
ctaLabel: 'See plans',
mediaTone: 'oxblood',
evidence: '14 LTE upload tests, 20 app sessions, 6 parking alerts',
editorialNote: 'The subscription is not cheap, but remote event review is genuinely useful for shared vehicles.',
strengths: ['reliable LTE upload', 'good incident timeline', 'strong cabin audio controls'],
knownIssues: ['cloud plan pricing needs care'],
scores: {
video: 8.5,
night: 8.2,
parking: 8.9,
reliability: 8.8,
usability: 9.0,
value: 7.4,
},
specs: {
sensor: {
label: 'Sensor',
value: 'dual 5 MP CMOS',
trust: 'claimed',
method: 'manufacturer',
},
resolution: {
label: 'Resolution / FPS',
value: '2K 30 front + 1080p cabin',
trust: 'verified',
method: 'measured',
},
bitrate: {
label: 'Bitrate',
value: '48 Mbps combined',
trust: 'verified',
method: 'measured',
},
hdr: {
label: 'HDR',
value: 'front HDR',
trust: 'verified',
method: 'firmware-docs',
},
lens: {
label: 'Lens / FOV',
value: '145 deg front, 165 deg cabin',
trust: 'claimed',
method: 'manufacturer',
},
storage: {
label: 'Storage',
value: '256 GB microSD + cloud',
trust: 'verified',
method: 'multi-source',
},
power: {
label: 'Capacitor / battery',
value: 'supercapacitor',
trust: 'verified',
method: 'teardown',
},
channels: {
label: 'Channels',
value: '2-channel cabin',
trust: 'verified',
method: 'multi-source',
},
},
},
];
@@ -0,0 +1,48 @@
export type TrustState = 'verified' | 'claimed' | 'unknown';
export type VerificationMethod =
| 'measured'
| 'firmware-docs'
| 'teardown'
| 'multi-source'
| 'manufacturer';
export type ScoreDimension = 'video' | 'night' | 'parking' | 'reliability' | 'usability' | 'value';
export type SpecKey =
| 'sensor'
| 'resolution'
| 'bitrate'
| 'hdr'
| 'lens'
| 'storage'
| 'power'
| 'channels';
export type SpecFact = {
label: string;
value: string;
trust: TrustState;
method?: VerificationMethod;
source?: string;
};
export type DashcamProduct = {
id: string;
brand: string;
model: string;
award: string;
tagline: string;
score: number;
price: string;
priceNote: string;
seller: string;
ctaLabel: string;
mediaTone: 'petrol' | 'graphite' | 'brass' | 'oxblood' | 'titanium';
evidence: string;
editorialNote: string;
strengths: string[];
knownIssues: string[];
scores: Record<ScoreDimension, number>;
specs: Record<SpecKey, SpecFact>;
};
@@ -0,0 +1,29 @@
import 'server-only';
import { unstable_cache } from 'next/cache';
import { DB_MEMORY_TTL_MS, NEXT_CACHE_REVALIDATE_SECONDS } from '@/shared/config/env';
import { calculateVideoStats } from '@/entities/video/model/video';
import { listVideos } from './videoRepository';
export const VIDEO_COLLECTION_TAG = 'video-collection';
export const getCachedVideoDashboard = unstable_cache(
async () => {
const videos = await listVideos();
return {
videos,
stats: calculateVideoStats(videos),
cache: {
generatedAt: new Date().toISOString(),
nextRevalidateSeconds: NEXT_CACHE_REVALIDATE_SECONDS,
memoryTtlMs: DB_MEMORY_TTL_MS,
},
};
},
['video-dashboard-v1'],
{
revalidate: NEXT_CACHE_REVALIDATE_SECONDS,
tags: [VIDEO_COLLECTION_TAG],
},
);
@@ -0,0 +1,21 @@
import 'server-only';
import { createVideo } from '@/entities/video/model/video';
import type { VideoDraft } from '@/entities/video/model/types';
import { readPrimitiveDb, writePrimitiveDb } from '@/shared/db/primitiveJsonDb';
export async function listVideos() {
const db = await readPrimitiveDb();
return [...db.videos].sort((left, right) => right.createdAt.localeCompare(left.createdAt));
}
export async function addVideo(draft: VideoDraft) {
const video = createVideo(draft);
await writePrimitiveDb((current) => ({
...current,
videos: [video, ...current.videos],
}));
return video;
}
@@ -0,0 +1,37 @@
export const videoStatuses = ['draft', 'published', 'archived'] as const;
export type VideoStatus = (typeof videoStatuses)[number];
export type Video = {
id: string;
title: string;
owner: string;
sourceUrl: string;
status: VideoStatus;
createdAt: string;
updatedAt: string;
};
export type VideoDraft = {
title: string;
owner: string;
sourceUrl: string;
status: VideoStatus;
};
export type VideoDashboardStats = {
total: number;
draft: number;
published: number;
archived: number;
};
export type VideoDashboardData = {
videos: Video[];
stats: VideoDashboardStats;
cache: {
generatedAt: string;
nextRevalidateSeconds: number;
memoryTtlMs: number;
};
};
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest';
import { calculateVideoStats, normalizeVideoDraft } from './video';
import type { Video } from './types';
describe('video model', () => {
it('normalizes sparse form input', () => {
expect(
normalizeVideoDraft({
title: ' Demo reel ',
owner: '',
sourceUrl: 'not a url',
status: 'unexpected',
}),
).toEqual({
title: 'Demo reel',
owner: 'Unassigned',
sourceUrl: 'https://example.com/video',
status: 'draft',
});
});
it('counts videos by status', () => {
const base = {
id: 'id',
title: 'Title',
owner: 'Ops',
sourceUrl: 'https://example.com/video',
createdAt: '2026-05-16T00:00:00.000Z',
updatedAt: '2026-05-16T00:00:00.000Z',
};
const videos: Video[] = [
{ ...base, id: 'draft', status: 'draft' },
{ ...base, id: 'published', status: 'published' },
{ ...base, id: 'archived', status: 'archived' },
{ ...base, id: 'published-2', status: 'published' },
];
expect(calculateVideoStats(videos)).toEqual({
total: 4,
draft: 1,
published: 2,
archived: 1,
});
});
});
@@ -0,0 +1,68 @@
import { videoStatuses, type Video, type VideoDashboardStats, type VideoDraft, type VideoStatus } from './types';
const fallbackUrl = 'https://example.com/video';
function normalizeText(value: string, fallback: string) {
const normalized = value.trim().replace(/\s+/g, ' ');
return normalized.length > 0 ? normalized : fallback;
}
function normalizeUrl(value: string) {
const normalized = value.trim();
if (normalized.length === 0) return fallbackUrl;
try {
return new URL(normalized).toString();
} catch {
return fallbackUrl;
}
}
export function isVideoStatus(value: string): value is VideoStatus {
return videoStatuses.includes(value as VideoStatus);
}
export function normalizeVideoDraft(input: {
title?: string;
owner?: string;
sourceUrl?: string;
status?: string;
}): VideoDraft {
return {
title: normalizeText(input.title ?? '', 'Untitled video'),
owner: normalizeText(input.owner ?? '', 'Unassigned'),
sourceUrl: normalizeUrl(input.sourceUrl ?? ''),
status: input.status && isVideoStatus(input.status) ? input.status : 'draft',
};
}
export function createVideoId(now = new Date()) {
const suffix = Math.random().toString(36).slice(2, 8);
return `video_${now.getTime().toString(36)}_${suffix}`;
}
export function createVideo(draft: VideoDraft, now = new Date()): Video {
const timestamp = now.toISOString();
return {
...draft,
id: createVideoId(now),
createdAt: timestamp,
updatedAt: timestamp,
};
}
export function calculateVideoStats(videos: Video[]): VideoDashboardStats {
return videos.reduce<VideoDashboardStats>(
(stats, video) => ({
...stats,
total: stats.total + 1,
[video.status]: stats[video.status] + 1,
}),
{
total: 0,
draft: 0,
published: 0,
archived: 0,
},
);
}
@@ -0,0 +1,20 @@
'use server';
import { revalidatePath, updateTag } from 'next/cache';
import { VIDEO_COLLECTION_TAG } from '@/entities/video/api/videoCache';
import { addVideo } from '@/entities/video/api/videoRepository';
import { normalizeVideoDraft } from '@/entities/video/model/video';
export async function registerVideoAction(formData: FormData) {
const draft = normalizeVideoDraft({
title: String(formData.get('title') ?? ''),
owner: String(formData.get('owner') ?? ''),
sourceUrl: String(formData.get('sourceUrl') ?? ''),
status: String(formData.get('status') ?? ''),
});
await addVideo(draft);
updateTag(VIDEO_COLLECTION_TAG);
revalidatePath('/');
}
@@ -0,0 +1,36 @@
import { registerVideoAction } from '../model/actions';
export function RegisterVideoForm() {
return (
<section className="panel" aria-labelledby="register-video-title">
<div className="panel-header">
<h2 id="register-video-title">Register video</h2>
</div>
<form className="panel-body form-grid" action={registerVideoAction}>
<label className="field">
<span>Title</span>
<input name="title" placeholder="Q2 product demo" required />
</label>
<label className="field">
<span>Owner</span>
<input name="owner" placeholder="Media ops" required />
</label>
<label className="field">
<span>Source URL</span>
<input name="sourceUrl" type="url" placeholder="https://example.com/video" required />
</label>
<label className="field">
<span>Status</span>
<select name="status" defaultValue="draft">
<option value="draft">Draft</option>
<option value="published">Published</option>
<option value="archived">Archived</option>
</select>
</label>
<button className="submit-button" type="submit">
Add video
</button>
</form>
</section>
);
}
+5
View File
@@ -0,0 +1,5 @@
export const APP_NAME = 'videoreg';
export const DB_MEMORY_TTL_MS = Number(process.env.VIDEOREG_DB_MEMORY_TTL_MS ?? 60 * 60 * 1000);
export const NEXT_CACHE_REVALIDATE_SECONDS = Number(
process.env.VIDEOREG_NEXT_CACHE_REVALIDATE_SECONDS ?? 60 * 60,
);
+145
View File
@@ -0,0 +1,145 @@
import 'server-only';
import { mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { DB_MEMORY_TTL_MS } from '@/shared/config/env';
import { DashcamCatalogExportSchema, type DashcamCatalogExport } from '@/entities/dashcam-catalog/model/schema';
import type { Video } from '@/entities/video/model/types';
type PrimitiveDbSchema = {
videos: Video[];
dashcamCatalog?: DashcamCatalogExport;
dashcamCatalogImportedAt?: string;
};
type MemorySnapshot = {
loadedAt: number;
fileMtimeMs: number;
data: PrimitiveDbSchema;
};
const defaultSeed: PrimitiveDbSchema = {
videos: [
{
id: 'video_seed_launch',
title: 'Launch recording',
owner: 'Ops',
sourceUrl: 'https://example.com/launch-recording',
status: 'published',
createdAt: '2026-05-16T09:00:00.000Z',
updatedAt: '2026-05-16T09:00:00.000Z',
},
{
id: 'video_seed_cut',
title: 'Editing backlog',
owner: 'Media',
sourceUrl: 'https://example.com/editing-backlog',
status: 'draft',
createdAt: '2026-05-16T10:00:00.000Z',
updatedAt: '2026-05-16T10:00:00.000Z',
},
],
};
let memorySnapshot: MemorySnapshot | undefined;
let writeQueue = Promise.resolve();
function getDbFilePath() {
if (process.env.VIDEOREG_DB_FILE) {
return process.env.VIDEOREG_DB_FILE;
}
return path.join(/* turbopackIgnore: true */ process.cwd(), 'data', 'videoreg.json');
}
async function ensureDbFile(filePath: string) {
await mkdir(path.dirname(filePath), { recursive: true });
try {
await stat(filePath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error;
}
await writeFile(filePath, `${JSON.stringify(defaultSeed, null, 2)}\n`, 'utf8');
}
}
function parseSchema(raw: string): PrimitiveDbSchema {
const parsed = JSON.parse(raw) as Partial<PrimitiveDbSchema>;
const catalog = parsed.dashcamCatalog
? DashcamCatalogExportSchema.safeParse(parsed.dashcamCatalog)
: undefined;
return {
videos: Array.isArray(parsed.videos) ? parsed.videos : [],
...(catalog?.success ? { dashcamCatalog: catalog.data } : {}),
...(typeof parsed.dashcamCatalogImportedAt === 'string'
? { dashcamCatalogImportedAt: parsed.dashcamCatalogImportedAt }
: {}),
};
}
async function loadFromDisk(filePath: string): Promise<MemorySnapshot> {
await ensureDbFile(filePath);
const [fileStat, raw] = await Promise.all([stat(filePath), readFile(filePath, 'utf8')]);
const snapshot = {
loadedAt: Date.now(),
fileMtimeMs: fileStat.mtimeMs,
data: parseSchema(raw),
};
memorySnapshot = snapshot;
return snapshot;
}
export async function readPrimitiveDb() {
const filePath = getDbFilePath();
const now = Date.now();
if (memorySnapshot && now - memorySnapshot.loadedAt < DB_MEMORY_TTL_MS) {
return memorySnapshot.data;
}
const fileStat = await stat(filePath).catch((error: NodeJS.ErrnoException) => {
if (error.code === 'ENOENT') return undefined;
throw error;
});
if (memorySnapshot && fileStat && fileStat.mtimeMs === memorySnapshot.fileMtimeMs) {
memorySnapshot.loadedAt = now;
return memorySnapshot.data;
}
return (await loadFromDisk(filePath)).data;
}
export async function writePrimitiveDb(
updater: (current: PrimitiveDbSchema) => PrimitiveDbSchema | Promise<PrimitiveDbSchema>,
) {
const operation = writeQueue.then(async () => {
const filePath = getDbFilePath();
const current = await readPrimitiveDb();
const next = await updater(structuredClone(current));
const temporaryPath = `${filePath}.${process.pid}.tmp`;
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(temporaryPath, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
await rename(temporaryPath, filePath);
const fileStat = await stat(filePath);
memorySnapshot = {
loadedAt: Date.now(),
fileMtimeMs: fileStat.mtimeMs,
data: next,
};
return next;
});
writeQueue = operation.then(
() => undefined,
() => undefined,
);
return operation;
}
+5
View File
@@ -0,0 +1,5 @@
type ClassValue = string | false | null | undefined;
export function cn(...classes: ClassValue[]) {
return classes.filter(Boolean).join(' ');
}
+6
View File
@@ -0,0 +1,6 @@
export function formatDateTime(value: string) {
return new Intl.DateTimeFormat('en', {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(value));
}
+32
View File
@@ -0,0 +1,32 @@
import type { ReactNode } from 'react';
import { cn } from '@/shared/lib/cn';
type BadgeTone = 'accent' | 'neutral' | 'warning' | 'danger' | 'success';
type BadgeProps = {
children: ReactNode;
className?: string;
tone?: BadgeTone;
};
const toneClasses: Record<BadgeTone, string> = {
accent: 'border-[var(--accent-soft-border)] bg-[var(--accent-soft)] text-[var(--accent-strong)]',
neutral: 'border-[var(--border-subtle)] bg-[var(--surface-2)] text-[var(--text-muted)]',
warning: 'border-[var(--warning-soft-border)] bg-[var(--warning-soft)] text-[var(--warning)]',
danger: 'border-[var(--danger-soft-border)] bg-[var(--danger-soft)] text-[var(--danger)]',
success: 'border-[var(--success-soft-border)] bg-[var(--success-soft)] text-[var(--success)]',
};
export function Badge({ children, className, tone = 'neutral' }: BadgeProps) {
return (
<span
className={cn(
'inline-flex min-h-6 items-center rounded-[var(--radius-sm)] border px-2 py-1 text-[0.72rem] font-semibold',
toneClasses[tone],
className,
)}
>
{children}
</span>
);
}
+67
View File
@@ -0,0 +1,67 @@
import type { AnchorHTMLAttributes, ButtonHTMLAttributes, ReactNode } from 'react';
import { cn } from '@/shared/lib/cn';
type ButtonVariant = 'primary' | 'secondary' | 'ghost';
type ButtonSize = 'sm' | 'md';
type BaseProps = {
children: ReactNode;
className?: string;
size?: ButtonSize;
variant?: ButtonVariant;
};
type LinkProps = BaseProps &
AnchorHTMLAttributes<HTMLAnchorElement> & {
href: string;
};
type NativeButtonProps = BaseProps &
ButtonHTMLAttributes<HTMLButtonElement> & {
href?: undefined;
};
type ButtonProps = LinkProps | NativeButtonProps;
const variantClasses: Record<ButtonVariant, string> = {
primary:
'border-[var(--accent)] bg-[var(--accent)] text-[var(--text-on-accent)] hover:bg-[var(--accent-strong)] hover:border-[var(--accent-strong)]',
secondary:
'border-[var(--border-strong)] bg-[var(--surface-1)] text-[var(--text-primary)] hover:bg-[var(--surface-2)]',
ghost:
'border-transparent bg-transparent text-[var(--text-muted)] hover:bg-[var(--surface-2)] hover:text-[var(--text-primary)]',
};
const sizeClasses: Record<ButtonSize, string> = {
sm: 'min-h-9 px-3 text-xs',
md: 'min-h-11 px-4 text-sm',
};
export function Button(props: ButtonProps) {
const { children, className, size = 'md', variant = 'primary' } = props;
const classes = cn(
'inline-flex items-center justify-center gap-2 rounded-[var(--radius-md)] border font-semibold transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--focus)]',
sizeClasses[size],
variantClasses[variant],
className,
);
if (props.href) {
const { href, ...linkProps } = props;
return (
<a {...linkProps} className={classes} href={href}>
{children}
</a>
);
}
const buttonProps = props as NativeButtonProps;
const { type = 'button', ...restButtonProps } = buttonProps;
return (
<button {...restButtonProps} className={classes} type={type}>
{children}
</button>
);
}
@@ -0,0 +1,56 @@
import type { TrustState, VerificationMethod } from '@/entities/dashcam/model/types';
import { cn } from '@/shared/lib/cn';
import { Icon } from './Icon';
type EvidenceChipProps = {
className?: string;
compact?: boolean;
method?: VerificationMethod;
source?: string;
state: TrustState;
};
const labels: Record<TrustState, string> = {
verified: 'Verified',
claimed: 'Claimed',
unknown: 'Unknown',
};
const stateClasses: Record<TrustState, string> = {
verified: 'border-[var(--success-soft-border)] bg-[var(--success-soft)] text-[var(--success)]',
claimed: 'border-[var(--warning-soft-border)] bg-[var(--warning-soft)] text-[var(--warning)]',
unknown: 'border-[var(--border-subtle)] bg-[var(--surface-2)] text-[var(--text-muted)]',
};
const iconByState: Record<TrustState, 'check' | 'info' | 'circle'> = {
verified: 'check',
claimed: 'info',
unknown: 'circle',
};
const methodLabels: Record<VerificationMethod, string> = {
measured: 'measured in review',
'firmware-docs': 'confirmed from firmware docs',
teardown: 'confirmed by teardown',
'multi-source': 'confirmed by multiple sources',
manufacturer: 'manufacturer claimed',
};
export function EvidenceChip({ className, compact, method, source, state }: EvidenceChipProps) {
const detail = [method ? methodLabels[method] : undefined, source].filter(Boolean).join(', ');
const title = detail ? `${labels[state]}: ${detail}` : labels[state];
return (
<span
className={cn(
'inline-flex min-h-6 items-center gap-1.5 rounded-[var(--radius-sm)] border px-2 py-1 text-[0.68rem] font-semibold',
stateClasses[state],
className,
)}
title={title}
>
<Icon className="h-3.5 w-3.5" name={iconByState[state]} />
{labels[state]}
</span>
);
}
+72
View File
@@ -0,0 +1,72 @@
import { cn } from '@/shared/lib/cn';
type IconName =
| 'camera'
| 'check'
| 'circle'
| 'info'
| 'shield'
| 'signal'
| 'spark'
| 'warning';
type IconProps = {
name: IconName;
className?: string;
};
export function Icon({ name, className }: IconProps) {
return (
<svg
aria-hidden="true"
className={cn('h-4 w-4 shrink-0', className)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.8"
>
{paths[name]}
</svg>
);
}
const paths: Record<IconName, React.ReactNode> = {
camera: (
<>
<path d="M4 8.8A2.8 2.8 0 0 1 6.8 6h1.7l1.2-1.8h4.6L15.5 6h1.7A2.8 2.8 0 0 1 20 8.8v7.4a2.8 2.8 0 0 1-2.8 2.8H6.8A2.8 2.8 0 0 1 4 16.2V8.8Z" />
<path d="M9 12.5a3 3 0 1 0 6 0a3 3 0 0 0-6 0Z" />
</>
),
check: <path d="m5 12.5 4.2 4.2L19 6.8" />,
circle: <path d="M12 19.2a7.2 7.2 0 1 0 0-14.4 7.2 7.2 0 0 0 0 14.4Z" />,
info: (
<>
<path d="M12 17v-6" />
<path d="M12 7.3h.01" />
<path d="M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Z" />
</>
),
shield: <path d="M12 21s7-3.4 7-10.2V5.4L12 3 5 5.4v5.4C5 17.6 12 21 12 21Z" />,
signal: (
<>
<path d="M4.8 13.6a10.2 10.2 0 0 1 14.4 0" />
<path d="M8 16.8a5.7 5.7 0 0 1 8 0" />
<path d="M12 20h.01" />
</>
),
spark: (
<>
<path d="M12 3.5 13.8 9l5.7 1.8-5.7 1.9L12 18.5l-1.8-5.8-5.7-1.9L10.2 9 12 3.5Z" />
<path d="m18 4 1 2.6 2.5.9-2.5.8L18 11l-.9-2.7-2.6-.8 2.6-.9L18 4Z" />
</>
),
warning: (
<>
<path d="M12 8v5" />
<path d="M12 17h.01" />
<path d="m10.2 4.4-7 12.2A2.2 2.2 0 0 0 5.1 20h13.8a2.2 2.2 0 0 0 1.9-3.4l-7-12.2a2.1 2.1 0 0 0-3.6 0Z" />
</>
),
};
@@ -0,0 +1,33 @@
import { cn } from '@/shared/lib/cn';
type ScoreMeterProps = {
className?: string;
label: string;
value: number;
};
export function ScoreMeter({ className, label, value }: ScoreMeterProps) {
const normalizedValue = Math.max(0, Math.min(value, 10));
return (
<div className={cn('grid gap-2', className)}>
<div className="flex items-baseline justify-between gap-3 text-xs">
<span className="font-semibold text-[var(--text-muted)]">{label}</span>
<span className="font-mono text-[var(--text-primary)]">{normalizedValue.toFixed(1)}</span>
</div>
<div
aria-label={`${label} score ${normalizedValue.toFixed(1)} out of 10`}
aria-valuemax={10}
aria-valuemin={0}
aria-valuenow={normalizedValue}
className="h-1.5 overflow-hidden rounded-full bg-[var(--surface-3)]"
role="meter"
>
<div
className="h-full rounded-full bg-[var(--accent)]"
style={{ width: `${normalizedValue * 10}%` }}
/>
</div>
</div>
);
}
@@ -0,0 +1,169 @@
import type { ReactNode } from 'react';
import type { DashcamProduct } from '@/entities/dashcam/model/types';
import { scoreDimensions } from '@/entities/dashcam/model/data';
import { cn } from '@/shared/lib/cn';
import { Badge } from '@/shared/ui/atoms/Badge';
import { Button } from '@/shared/ui/atoms/Button';
import { EvidenceChip } from '@/shared/ui/atoms/EvidenceChip';
import { Icon } from '@/shared/ui/atoms/Icon';
import { ScoreMeter } from '@/shared/ui/atoms/ScoreMeter';
type ProductCardRootProps = {
children: ReactNode;
className?: string;
featured?: boolean;
};
function Root({ children, className, featured }: ProductCardRootProps) {
return (
<article
className={cn(
'overflow-hidden rounded-[var(--radius-lg)] border bg-[var(--surface-1)]',
featured
? 'border-[var(--accent-soft-border)] shadow-[var(--shadow-raised)]'
: 'border-[var(--border-subtle)] shadow-[var(--shadow-soft)]',
className,
)}
>
{children}
</article>
);
}
function Media({ product }: { product: DashcamProduct }) {
return (
<div
className={cn(
'relative isolate min-h-56 overflow-hidden bg-[var(--surface-inverse)] p-4 text-[var(--text-inverse)]',
mediaToneClasses[product.mediaTone],
)}
>
<div className="absolute inset-0 opacity-70 [background-image:linear-gradient(130deg,rgba(255,255,255,.12),transparent_45%),radial-gradient(circle_at_24%_20%,rgba(255,255,255,.12),transparent_24%)]" />
<img
alt=""
className="absolute inset-x-0 bottom-0 z-0 h-full w-full object-cover opacity-55 mix-blend-screen"
src="/dashcam-product.svg"
/>
<div className="relative z-10 flex h-full min-h-48 flex-col justify-between gap-8">
<div className="flex items-center justify-between gap-3">
<Badge className="border-white/15 bg-white/10 text-white" tone="neutral">
{product.award}
</Badge>
<span className="font-mono text-xs text-white/70">score {product.score.toFixed(1)}</span>
</div>
<div className="w-fit rounded-[var(--radius-md)] border border-white/15 bg-black/30 px-3 py-2 backdrop-blur-sm">
<p className="m-0 font-mono text-[0.72rem] text-white/72">evidence log</p>
<p className="m-0 mt-1 max-w-64 text-sm font-semibold text-white">{product.evidence}</p>
</div>
</div>
</div>
);
}
function Header({ product }: { product: DashcamProduct }) {
return (
<div className="grid gap-3 p-5">
<div>
<p className="m-0 text-xs font-semibold uppercase text-[var(--accent-strong)]">
{product.brand}
</p>
<h2 className="m-0 mt-1 text-2xl font-semibold leading-tight text-[var(--text-primary)]">
{product.model}
</h2>
</div>
<p className="m-0 text-sm leading-6 text-[var(--text-muted)]">{product.tagline}</p>
</div>
);
}
function Snapshot({ product }: { product: DashcamProduct }) {
const specKeys = ['sensor', 'resolution', 'bitrate'] as const;
return (
<dl className="grid gap-2 border-y border-[var(--border-subtle)] bg-[var(--surface-0)] px-5 py-4">
{specKeys.map((key) => {
const spec = product.specs[key];
return (
<div className="grid grid-cols-[6rem_minmax(0,1fr)] items-start gap-3" key={key}>
<dt className="text-xs font-semibold text-[var(--text-muted)]">{spec.label}</dt>
<dd className="m-0 flex min-w-0 flex-wrap items-center gap-2 text-sm font-medium text-[var(--text-primary)]">
<span>{spec.value}</span>
<EvidenceChip compact method={spec.method} source={spec.source} state={spec.trust} />
</dd>
</div>
);
})}
</dl>
);
}
function ScoreStrip({ product }: { product: DashcamProduct }) {
return (
<div className="grid gap-3 p-5">
{scoreDimensions.map((dimension) => (
<ScoreMeter
key={dimension.key}
label={dimension.label}
value={product.scores[dimension.key]}
/>
))}
</div>
);
}
function Verdict({ product }: { product: DashcamProduct }) {
return (
<div className="grid gap-3 px-5 pb-5">
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-2)] p-3">
<p className="m-0 flex items-center gap-2 text-xs font-semibold uppercase text-[var(--text-muted)]">
<Icon className="text-[var(--accent)]" name="shield" />
Editorial override note
</p>
<p className="m-0 mt-2 text-sm leading-6 text-[var(--text-primary)]">{product.editorialNote}</p>
</div>
<div className="flex flex-wrap gap-2">
{product.strengths.map((strength) => (
<Badge key={strength} tone="accent">
{strength}
</Badge>
))}
</div>
</div>
);
}
function Actions({ product }: { product: DashcamProduct }) {
return (
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-[var(--border-subtle)] p-5">
<div>
<p className="m-0 font-mono text-xl font-semibold text-[var(--text-primary)]">{product.price}</p>
<p className="m-0 mt-1 text-xs text-[var(--text-muted)]">
{product.priceNote} / {product.seller}
</p>
</div>
<Button href="#sellers" variant="secondary">
<Icon name="signal" />
{product.ctaLabel}
</Button>
</div>
);
}
export const ProductCard = {
Actions,
Header,
Media,
Root,
ScoreStrip,
Snapshot,
Verdict,
};
const mediaToneClasses: Record<DashcamProduct['mediaTone'], string> = {
brass: '[--media-accent:#8F7660]',
graphite: '[--media-accent:#687175]',
oxblood: '[--media-accent:#7B4B49]',
petrol: '[--media-accent:#56798A]',
titanium: '[--media-accent:#A8A095]',
};
@@ -0,0 +1,33 @@
import type { DashcamProduct } from '@/entities/dashcam/model/types';
import { Badge } from '@/shared/ui/atoms/Badge';
type WinnerRailProps = {
products: DashcamProduct[];
};
export function WinnerRail({ products }: WinnerRailProps) {
return (
<section aria-label="Winner shortcuts" className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
{products.map((product) => (
<a
className="group grid min-h-36 rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-1)] p-4 shadow-[var(--shadow-soft)] transition-colors hover:border-[var(--accent-soft-border)] hover:bg-[var(--surface-2)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--focus)]"
href={`#${product.id}`}
key={product.id}
>
<div>
<Badge tone={product.award === 'Overall pick' ? 'accent' : 'neutral'}>{product.award}</Badge>
<h3 className="m-0 mt-3 text-base font-semibold text-[var(--text-primary)]">
{product.brand} {product.model}
</h3>
</div>
<div className="mt-4 flex items-end justify-between gap-3">
<p className="m-0 text-xs leading-5 text-[var(--text-muted)]">{product.price}</p>
<span className="font-mono text-lg font-semibold text-[var(--accent-strong)]">
{product.score.toFixed(1)}
</span>
</div>
</a>
))}
</section>
);
}
@@ -0,0 +1,59 @@
import { Badge } from '@/shared/ui/atoms/Badge';
import { Button } from '@/shared/ui/atoms/Button';
import { Icon } from '@/shared/ui/atoms/Icon';
export function PageHero() {
return (
<section className="grid gap-8 py-8 md:grid-cols-[minmax(0,1fr)_minmax(19rem,29rem)] md:items-end md:py-12">
<div className="grid gap-6">
<div className="flex flex-wrap gap-2">
<Badge tone="accent">2026 guide</Badge>
<Badge tone="neutral">verified / claimed / unknown</Badge>
</div>
<div className="grid gap-4">
<h1 className="m-0 max-w-4xl text-5xl font-semibold leading-[1.02] text-[var(--text-primary)] sm:text-6xl lg:text-7xl">
Best Dashcams 2026
</h1>
<p className="m-0 max-w-2xl text-base leading-7 text-[var(--text-muted)] sm:text-lg">
Evidence-led rankings for drivers who care about plate detail, parking mode, heat
stability, and readable specs before checkout.
</p>
</div>
<div className="flex flex-wrap gap-3">
<Button href="#winners">
<Icon name="camera" />
View winners
</Button>
<Button href="#methodology" variant="secondary">
<Icon name="shield" />
Methodology
</Button>
</div>
</div>
<aside
aria-label="Method summary"
className="rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-1)] p-5 shadow-[var(--shadow-soft)]"
>
<div className="flex items-center justify-between gap-4 border-b border-[var(--border-subtle)] pb-4">
<div>
<p className="m-0 text-xs font-semibold uppercase text-[var(--text-muted)]">ranking model</p>
<p className="m-0 mt-1 text-lg font-semibold text-[var(--text-primary)]">Six scored dimensions</p>
</div>
<Icon className="h-5 w-5 text-[var(--accent)]" name="spark" />
</div>
<dl className="mt-4 grid grid-cols-2 gap-3 text-sm">
{['video', 'night', 'parking', 'reliability', 'usability', 'value'].map((item) => (
<div
className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-0)] p-3"
key={item}
>
<dt className="font-mono text-xs text-[var(--text-muted)]">{item}</dt>
<dd className="m-0 mt-1 font-semibold text-[var(--text-primary)]">weighted by use case</dd>
</div>
))}
</dl>
</aside>
</section>
);
}
@@ -0,0 +1,134 @@
import type { DashcamProduct } from '@/entities/dashcam/model/types';
import { comparisonSpecRows, scoreDimensions } from '@/entities/dashcam/model/data';
import { EvidenceChip } from '@/shared/ui/atoms/EvidenceChip';
type ProductComparisonTableProps = {
products: DashcamProduct[];
};
export function ProductComparisonTable({ products }: ProductComparisonTableProps) {
return (
<section
aria-labelledby="comparison-title"
className="rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-1)] shadow-[var(--shadow-soft)]"
id="comparison"
>
<div className="grid gap-2 border-b border-[var(--border-subtle)] p-5">
<p className="m-0 text-xs font-semibold uppercase text-[var(--accent-strong)]">hardware first</p>
<h2 className="m-0 text-2xl font-semibold text-[var(--text-primary)]" id="comparison-title">
Compact comparison matrix
</h2>
<p className="m-0 max-w-2xl text-sm leading-6 text-[var(--text-muted)]">
The first rows show decisive hardware specs. Outcome rows and editorial issues follow.
</p>
</div>
<div className="overflow-x-auto">
<table className="min-w-[64rem] border-collapse text-left text-sm">
<caption className="sr-only">Dashcam hardware and outcome comparison</caption>
<thead>
<tr className="border-b border-[var(--border-subtle)]">
<th
className="sticky left-0 z-20 w-44 bg-[var(--surface-1)] px-5 py-4 text-xs font-semibold uppercase text-[var(--text-muted)]"
scope="col"
>
Spec
</th>
{products.map((product) => (
<th
className="min-w-48 bg-[var(--surface-1)] px-4 py-4 align-bottom"
key={product.id}
scope="col"
>
<span className="block text-xs font-semibold uppercase text-[var(--accent-strong)]">
{product.award}
</span>
<span className="mt-1 block text-base font-semibold text-[var(--text-primary)]">
{product.brand} {product.model}
</span>
</th>
))}
</tr>
</thead>
<tbody>
{comparisonSpecRows.map((row) => (
<tr className="border-b border-[var(--border-subtle)]" key={row.key}>
<th
className="sticky left-0 z-10 bg-[var(--surface-1)] px-5 py-4 text-xs font-semibold text-[var(--text-muted)]"
scope="row"
>
{row.label}
</th>
{products.map((product) => {
const spec = product.specs[row.key];
return (
<td className="bg-[var(--surface-0)] px-4 py-4 align-top" key={product.id}>
<div className="grid gap-2">
<span className="font-medium text-[var(--text-primary)]">{spec.value}</span>
<EvidenceChip
compact
method={spec.method}
source={spec.source}
state={spec.trust}
/>
</div>
</td>
);
})}
</tr>
))}
<tr className="border-b border-[var(--border-subtle)]">
<th
className="sticky left-0 z-10 bg-[var(--surface-1)] px-5 py-4 text-xs font-semibold text-[var(--text-muted)]"
scope="row"
>
Score
</th>
{products.map((product) => (
<td className="px-4 py-4 align-top" key={product.id}>
<span className="font-mono text-2xl font-semibold text-[var(--accent-strong)]">
{product.score.toFixed(1)}
</span>
</td>
))}
</tr>
{scoreDimensions.map((dimension) => (
<tr className="border-b border-[var(--border-subtle)]" key={dimension.key}>
<th
className="sticky left-0 z-10 bg-[var(--surface-1)] px-5 py-4 text-xs font-semibold text-[var(--text-muted)]"
scope="row"
>
{dimension.label}
</th>
{products.map((product) => (
<td className="px-4 py-4 align-top" key={product.id}>
<span className="font-mono font-semibold text-[var(--text-primary)]">
{product.scores[dimension.key].toFixed(1)}
</span>
</td>
))}
</tr>
))}
<tr>
<th
className="sticky left-0 z-10 bg-[var(--surface-1)] px-5 py-4 text-xs font-semibold text-[var(--text-muted)]"
scope="row"
>
Known issue
</th>
{products.map((product) => (
<td className="px-4 py-4 align-top text-[var(--text-primary)]" key={product.id}>
{product.knownIssues[0]}
</td>
))}
</tr>
</tbody>
</table>
</div>
</section>
);
}
@@ -0,0 +1,182 @@
import type { DashcamProduct } from '@/entities/dashcam/model/types';
import { Badge } from '@/shared/ui/atoms/Badge';
import { Button } from '@/shared/ui/atoms/Button';
import { Icon } from '@/shared/ui/atoms/Icon';
import { ProductCard } from '@/shared/ui/molecules/ProductCard';
import { WinnerRail } from '@/shared/ui/molecules/WinnerRail';
import { PageHero } from '@/shared/ui/organisms/PageHero';
import { ProductComparisonTable } from '@/shared/ui/organisms/ProductComparisonTable';
type BestDashcamsPageProps = {
products: DashcamProduct[];
};
export function BestDashcamsPage({ products }: BestDashcamsPageProps) {
const [overallPick, ...useCasePicks] = products;
return (
<main className="min-h-screen bg-[var(--surface-0)] text-[var(--text-primary)]">
<SiteHeader />
<div className="mx-auto grid w-[calc(100%_-_1.25rem)] max-w-[1180px] gap-10 pb-14 sm:w-[calc(100%_-_2rem)]">
<PageHero />
<WinnerRail products={products} />
<section className="grid gap-5" id="winners">
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<p className="m-0 text-xs font-semibold uppercase text-[var(--accent-strong)]">
tested winners
</p>
<h2 className="m-0 mt-2 text-3xl font-semibold text-[var(--text-primary)]">
Fast decisions, visible evidence
</h2>
</div>
<Button href="#comparison" variant="secondary">
<Icon name="signal" />
Compare specs
</Button>
</div>
<div className="grid gap-5 lg:grid-cols-[1.1fr_.9fr]">
<div id={overallPick.id}>
<ProductCard.Root featured>
<ProductCard.Media product={overallPick} />
<ProductCard.Header product={overallPick} />
<ProductCard.Snapshot product={overallPick} />
<ProductCard.ScoreStrip product={overallPick} />
<ProductCard.Verdict product={overallPick} />
<ProductCard.Actions product={overallPick} />
</ProductCard.Root>
</div>
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-1">
{useCasePicks.slice(0, 2).map((product) => (
<div id={product.id} key={product.id}>
<ProductCard.Root>
<ProductCard.Media product={product} />
<ProductCard.Header product={product} />
<ProductCard.Snapshot product={product} />
<ProductCard.Actions product={product} />
</ProductCard.Root>
</div>
))}
</div>
</div>
<div className="grid gap-5 md:grid-cols-2">
{useCasePicks.slice(2).map((product) => (
<div id={product.id} key={product.id}>
<ProductCard.Root>
<ProductCard.Media product={product} />
<ProductCard.Header product={product} />
<ProductCard.Snapshot product={product} />
<ProductCard.Actions product={product} />
</ProductCard.Root>
</div>
))}
</div>
</section>
<ProductComparisonTable products={products} />
<section
aria-labelledby="sellers-title"
className="grid gap-5 rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-1)] p-5 shadow-[var(--shadow-soft)]"
id="sellers"
>
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<p className="m-0 text-xs font-semibold uppercase text-[var(--accent-strong)]">
seller comparison
</p>
<h2 className="m-0 mt-2 text-2xl font-semibold text-[var(--text-primary)]" id="sellers-title">
Prices stay secondary to evidence
</h2>
</div>
<Badge tone="neutral">affiliate-safe layout</Badge>
</div>
<div className="grid gap-3 lg:grid-cols-5">
{products.map((product) => (
<article
className="grid gap-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-0)] p-4"
key={product.id}
>
<div>
<p className="m-0 text-xs font-semibold uppercase text-[var(--text-muted)]">
{product.seller}
</p>
<h3 className="m-0 mt-2 text-base font-semibold text-[var(--text-primary)]">
{product.brand} {product.model}
</h3>
</div>
<div>
<p className="m-0 font-mono text-xl font-semibold text-[var(--text-primary)]">
{product.price}
</p>
<p className="m-0 mt-1 text-xs text-[var(--text-muted)]">{product.priceNote}</p>
</div>
<Button href="#methodology" size="sm" variant="secondary">
Review terms
</Button>
</article>
))}
</div>
</section>
<section
className="grid gap-5 rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-inverse)] p-5 text-[var(--text-inverse)] shadow-[var(--shadow-soft)] md:grid-cols-[1fr_1.1fr]"
id="methodology"
>
<div>
<Badge className="border-white/15 bg-white/10 text-white" tone="neutral">
Methodology
</Badge>
<h2 className="m-0 mt-4 text-3xl font-semibold">How winners are selected</h2>
</div>
<div className="grid gap-4 text-sm leading-6 text-white/72">
<p className="m-0">
Scores are grouped into video, night, parking, reliability, usability, and value.
Category winners use different weights over the same dimensions.
</p>
<p className="m-0">
Public evidence states stay simple: verified, claimed, and unknown. Internal notes
preserve whether a fact came from measurement, firmware docs, teardown, multiple
sources, or manufacturer claims.
</p>
</div>
</section>
</div>
</main>
);
}
function SiteHeader() {
return (
<header className="border-b border-[var(--border-subtle)] bg-[color-mix(in_srgb,var(--surface-0)_88%,transparent)] backdrop-blur">
<div className="mx-auto flex min-h-16 w-[calc(100%_-_1.25rem)] max-w-[1180px] items-center justify-between gap-4 sm:w-[calc(100%_-_2rem)]">
<a
className="flex items-center gap-3 font-semibold text-[var(--text-primary)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--focus)]"
href="#top"
>
<span className="grid h-9 w-9 place-items-center rounded-[var(--radius-md)] border border-[var(--border-strong)] bg-[var(--surface-1)]">
<Icon className="text-[var(--accent)]" name="camera" />
</span>
videoreg.ru
</a>
<nav aria-label="Primary navigation" className="hidden items-center gap-1 text-sm md:flex">
{['Winners', 'Comparison', 'Methodology', 'Sellers'].map((item) => (
<a
className="rounded-[var(--radius-sm)] px-3 py-2 text-[var(--text-muted)] transition-colors hover:bg-[var(--surface-2)] hover:text-[var(--text-primary)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--focus)]"
href={`#${item.toLowerCase()}`}
key={item}
>
{item}
</a>
))}
</nav>
</div>
</header>
);
}
@@ -0,0 +1,25 @@
import type { VideoDashboardData } from '@/entities/video/model/types';
import { formatDateTime } from '@/shared/lib/date';
type CachePanelProps = {
metadata: VideoDashboardData['cache'];
};
export function CachePanel({ metadata }: CachePanelProps) {
return (
<aside className="cache-panel" aria-label="Cache status">
<div className="cache-item">
<span>Next cache</span>
<strong>{metadata.nextRevalidateSeconds}s</strong>
</div>
<div className="cache-item">
<span>Memory TTL</span>
<strong>{Math.round(metadata.memoryTtlMs / 1000)}s</strong>
</div>
<div className="cache-item">
<span>Generated</span>
<strong>{formatDateTime(metadata.generatedAt)}</strong>
</div>
</aside>
);
}
@@ -0,0 +1,57 @@
import type { VideoDashboardData } from '@/entities/video/model/types';
import { formatDateTime } from '@/shared/lib/date';
type VideoDashboardProps = {
dashboard: VideoDashboardData;
};
export function VideoDashboard({ dashboard }: VideoDashboardProps) {
return (
<section className="panel" aria-labelledby="video-dashboard-title">
<div className="panel-header">
<div>
<h2 id="video-dashboard-title">Registry</h2>
</div>
</div>
<div className="panel-body">
<div className="stats-grid">
<Metric label="Total" value={dashboard.stats.total} />
<Metric label="Draft" value={dashboard.stats.draft} />
<Metric label="Published" value={dashboard.stats.published} />
<Metric label="Archived" value={dashboard.stats.archived} />
</div>
<div className="video-list">
{dashboard.videos.length > 0 ? (
dashboard.videos.map((video) => (
<article className="video-card" key={video.id}>
<div>
<h3>{video.title}</h3>
<div className="video-meta">
<span className={`pill status-${video.status}`}>{video.status}</span>
<span className="pill">{video.owner}</span>
<span className="pill">{formatDateTime(video.createdAt)}</span>
</div>
</div>
<a className="pill" href={video.sourceUrl} rel="noreferrer" target="_blank">
Source
</a>
</article>
))
) : (
<div className="empty-state">No videos registered</div>
)}
</div>
</div>
</section>
);
}
function Metric({ label, value }: { label: string; value: number }) {
return (
<div className="metric">
<span>{label}</span>
<strong>{value}</strong>
</div>
);
}
+20
View File
@@ -0,0 +1,20 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"plugins": [
{
"name": "next"
}
]
},
"include": [
"next-env.d.ts",
"src/**/*.ts",
"src/**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}