move next to web-front
This commit is contained in:
Vendored
+6
@@ -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.
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { NextConfig } from 'next';
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: 'standalone',
|
||||
turbopack: {
|
||||
root: process.cwd(),
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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 |
@@ -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} />;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
type ClassValue = string | false | null | undefined;
|
||||
|
||||
export function cn(...classes: ClassValue[]) {
|
||||
return classes.filter(Boolean).join(' ');
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export function formatDateTime(value: string) {
|
||||
return new Intl.DateTimeFormat('en', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
}).format(new Date(value));
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user