design system
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
'@tailwindcss/postcss': {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -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,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} />;
|
||||||
|
}
|
||||||
+165
-61
@@ -1,34 +1,113 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
--bg: #f6f6f3;
|
--font-ui: Geist, Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||||
--panel: #ffffff;
|
sans-serif;
|
||||||
--panel-muted: #efede7;
|
--font-tech: "IBM Plex Mono", Iosevka, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
|
||||||
--text: #1f2528;
|
"Liberation Mono", monospace;
|
||||||
--muted: #667075;
|
|
||||||
--line: #d8d5cc;
|
--surface-0: #f2eee6;
|
||||||
--accent: #156f5b;
|
--surface-1: #fbfaf6;
|
||||||
--accent-strong: #0c4f41;
|
--surface-2: #e8e1d5;
|
||||||
--warning: #9a5c12;
|
--surface-3: #d8cfc0;
|
||||||
--danger: #a33a3a;
|
--surface-inverse: #151a1c;
|
||||||
--info: #345f9f;
|
|
||||||
--shadow: 0 10px 24px rgb(31 37 40 / 8%);
|
--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;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
html,
|
html {
|
||||||
body {
|
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
|
scroll-behavior: smooth;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
|
min-height: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background: var(--bg);
|
background: var(--surface-0);
|
||||||
color: var(--text);
|
color: var(--text-primary);
|
||||||
font-family:
|
font-family: var(--font-ui);
|
||||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
text-rendering: optimizeLegibility;
|
||||||
}
|
}
|
||||||
|
|
||||||
button,
|
button,
|
||||||
@@ -37,6 +116,29 @@ select {
|
|||||||
font: inherit;
|
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 {
|
.page-shell {
|
||||||
width: min(1180px, calc(100% - 32px));
|
width: min(1180px, calc(100% - 32px));
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
@@ -48,38 +150,31 @@ select {
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
padding-bottom: 18px;
|
padding-bottom: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.eyebrow {
|
.eyebrow {
|
||||||
margin: 0 0 6px;
|
margin: 0 0 6px;
|
||||||
color: var(--accent);
|
color: var(--accent-strong);
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1,
|
.page-shell h1 {
|
||||||
h2,
|
|
||||||
h3,
|
|
||||||
p {
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 2rem;
|
font-size: 2rem;
|
||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
.page-shell h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
line-height: 1.25;
|
line-height: 1.25;
|
||||||
}
|
}
|
||||||
|
|
||||||
h3 {
|
.page-shell h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
line-height: 1.25;
|
line-height: 1.25;
|
||||||
@@ -94,10 +189,10 @@ h3 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.panel {
|
.panel {
|
||||||
background: var(--panel);
|
background: var(--surface-1);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--border-subtle);
|
||||||
border-radius: 8px;
|
border-radius: var(--radius-lg);
|
||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-header {
|
.panel-header {
|
||||||
@@ -105,7 +200,7 @@ h3 {
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,7 +219,7 @@ h3 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.field span {
|
.field span {
|
||||||
color: var(--muted);
|
color: var(--text-muted);
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
}
|
}
|
||||||
@@ -133,25 +228,25 @@ h3 {
|
|||||||
.field select {
|
.field select {
|
||||||
min-height: 42px;
|
min-height: 42px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--border-subtle);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-md);
|
||||||
background: #fff;
|
background: var(--surface-1);
|
||||||
color: var(--text);
|
color: var(--text-primary);
|
||||||
padding: 9px 10px;
|
padding: 9px 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.field input:focus,
|
.field input:focus,
|
||||||
.field select:focus {
|
.field select:focus {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
outline: 3px solid rgb(21 111 91 / 16%);
|
outline: 3px solid color-mix(in srgb, var(--accent) 22%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.submit-button {
|
.submit-button {
|
||||||
min-height: 42px;
|
min-height: 42px;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-md);
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: #fff;
|
color: var(--text-on-accent);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: 750;
|
font-weight: 750;
|
||||||
}
|
}
|
||||||
@@ -168,15 +263,15 @@ h3 {
|
|||||||
|
|
||||||
.metric {
|
.metric {
|
||||||
min-height: 76px;
|
min-height: 76px;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--border-subtle);
|
||||||
border-radius: 8px;
|
border-radius: var(--radius-lg);
|
||||||
background: var(--panel-muted);
|
background: var(--surface-2);
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric span {
|
.metric span {
|
||||||
display: block;
|
display: block;
|
||||||
color: var(--muted);
|
color: var(--text-muted);
|
||||||
font-size: 0.76rem;
|
font-size: 0.76rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -198,9 +293,9 @@ h3 {
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--border-subtle);
|
||||||
border-radius: 8px;
|
border-radius: var(--radius-lg);
|
||||||
background: #fff;
|
background: var(--surface-1);
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,8 +309,8 @@ h3 {
|
|||||||
.pill {
|
.pill {
|
||||||
min-height: 24px;
|
min-height: 24px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: var(--panel-muted);
|
background: var(--surface-2);
|
||||||
color: var(--muted);
|
color: var(--text-muted);
|
||||||
padding: 4px 9px;
|
padding: 4px 9px;
|
||||||
font-size: 0.76rem;
|
font-size: 0.76rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -226,7 +321,7 @@ h3 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.pill.status-published {
|
.pill.status-published {
|
||||||
color: var(--accent);
|
color: var(--success);
|
||||||
}
|
}
|
||||||
|
|
||||||
.pill.status-archived {
|
.pill.status-archived {
|
||||||
@@ -242,15 +337,15 @@ h3 {
|
|||||||
|
|
||||||
.cache-item {
|
.cache-item {
|
||||||
min-height: 42px;
|
min-height: 42px;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--border-subtle);
|
||||||
border-radius: 8px;
|
border-radius: var(--radius-lg);
|
||||||
background: var(--panel);
|
background: var(--surface-1);
|
||||||
padding: 7px 10px;
|
padding: 7px 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cache-item span {
|
.cache-item span {
|
||||||
display: block;
|
display: block;
|
||||||
color: var(--muted);
|
color: var(--text-muted);
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -263,9 +358,9 @@ h3 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.empty-state {
|
.empty-state {
|
||||||
border: 1px dashed var(--line);
|
border: 1px dashed var(--border-subtle);
|
||||||
border-radius: 8px;
|
border-radius: var(--radius-lg);
|
||||||
color: var(--muted);
|
color: var(--text-muted);
|
||||||
padding: 22px;
|
padding: 22px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
@@ -296,7 +391,7 @@ h3 {
|
|||||||
padding-top: 16px;
|
padding-top: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
.page-shell h1 {
|
||||||
font-size: 1.55rem;
|
font-size: 1.55rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,3 +401,12 @@ h3 {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@ type RootLayoutProps = {
|
|||||||
|
|
||||||
export default function RootLayout({ children }: RootLayoutProps) {
|
export default function RootLayout({ children }: RootLayoutProps) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html id="top" lang="en">
|
||||||
<body>{children}</body>
|
<body>{children}</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,5 @@
|
|||||||
|
type ClassValue = string | false | null | undefined;
|
||||||
|
|
||||||
|
export function cn(...classes: ClassValue[]) {
|
||||||
|
return classes.filter(Boolean).join(' ');
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user