diff --git a/postcss.config.mjs b/postcss.config.mjs new file mode 100644 index 0000000..297374d --- /dev/null +++ b/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + '@tailwindcss/postcss': {}, + }, +}; + +export default config; diff --git a/public/dashcam-product.svg b/public/dashcam-product.svg new file mode 100644 index 0000000..9e4e447 --- /dev/null +++ b/public/dashcam-product.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/app/(dev)/[devRoot]/showcase/layout.tsx b/src/app/(dev)/[devRoot]/showcase/layout.tsx new file mode 100644 index 0000000..1efb933 --- /dev/null +++ b/src/app/(dev)/[devRoot]/showcase/layout.tsx @@ -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; +} diff --git a/src/app/(dev)/[devRoot]/showcase/page.tsx b/src/app/(dev)/[devRoot]/showcase/page.tsx new file mode 100644 index 0000000..f18733d --- /dev/null +++ b/src/app/(dev)/[devRoot]/showcase/page.tsx @@ -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 ; +} diff --git a/src/app/globals.css b/src/app/globals.css index cece514..2a3ea5e 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,34 +1,113 @@ +@import "tailwindcss"; + :root { color-scheme: light; - --bg: #f6f6f3; - --panel: #ffffff; - --panel-muted: #efede7; - --text: #1f2528; - --muted: #667075; - --line: #d8d5cc; - --accent: #156f5b; - --accent-strong: #0c4f41; - --warning: #9a5c12; - --danger: #a33a3a; - --info: #345f9f; - --shadow: 0 10px 24px rgb(31 37 40 / 8%); + --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, -body { +html { min-height: 100%; + scroll-behavior: smooth; } body { + min-height: 100%; margin: 0; - background: var(--bg); - color: var(--text); - font-family: - Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + background: var(--surface-0); + color: var(--text-primary); + font-family: var(--font-ui); + text-rendering: optimizeLegibility; } button, @@ -37,6 +116,29 @@ 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; @@ -48,38 +150,31 @@ select { align-items: flex-start; justify-content: space-between; gap: 24px; - border-bottom: 1px solid var(--line); + border-bottom: 1px solid var(--border-subtle); padding-bottom: 18px; } .eyebrow { margin: 0 0 6px; - color: var(--accent); + color: var(--accent-strong); font-size: 0.78rem; font-weight: 700; text-transform: uppercase; } -h1, -h2, -h3, -p { - overflow-wrap: anywhere; -} - -h1 { +.page-shell h1 { margin: 0; font-size: 2rem; line-height: 1.1; } -h2 { +.page-shell h2 { margin: 0; font-size: 1.1rem; line-height: 1.25; } -h3 { +.page-shell h3 { margin: 0; font-size: 0.95rem; line-height: 1.25; @@ -94,10 +189,10 @@ h3 { } .panel { - background: var(--panel); - border: 1px solid var(--line); - border-radius: 8px; - box-shadow: var(--shadow); + background: var(--surface-1); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-soft); } .panel-header { @@ -105,7 +200,7 @@ h3 { align-items: flex-start; justify-content: space-between; gap: 16px; - border-bottom: 1px solid var(--line); + border-bottom: 1px solid var(--border-subtle); padding: 16px; } @@ -124,7 +219,7 @@ h3 { } .field span { - color: var(--muted); + color: var(--text-muted); font-size: 0.82rem; font-weight: 650; } @@ -133,25 +228,25 @@ h3 { .field select { min-height: 42px; width: 100%; - border: 1px solid var(--line); - border-radius: 6px; - background: #fff; - color: var(--text); + 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 rgb(21 111 91 / 16%); + outline: 3px solid color-mix(in srgb, var(--accent) 22%, transparent); } .submit-button { min-height: 42px; border: 0; - border-radius: 6px; + border-radius: var(--radius-md); background: var(--accent); - color: #fff; + color: var(--text-on-accent); cursor: pointer; font-weight: 750; } @@ -168,15 +263,15 @@ h3 { .metric { min-height: 76px; - border: 1px solid var(--line); - border-radius: 8px; - background: var(--panel-muted); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + background: var(--surface-2); padding: 12px; } .metric span { display: block; - color: var(--muted); + color: var(--text-muted); font-size: 0.76rem; font-weight: 700; text-transform: uppercase; @@ -198,9 +293,9 @@ h3 { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 14px; - border: 1px solid var(--line); - border-radius: 8px; - background: #fff; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + background: var(--surface-1); padding: 14px; } @@ -214,8 +309,8 @@ h3 { .pill { min-height: 24px; border-radius: 999px; - background: var(--panel-muted); - color: var(--muted); + background: var(--surface-2); + color: var(--text-muted); padding: 4px 9px; font-size: 0.76rem; font-weight: 700; @@ -226,7 +321,7 @@ h3 { } .pill.status-published { - color: var(--accent); + color: var(--success); } .pill.status-archived { @@ -242,15 +337,15 @@ h3 { .cache-item { min-height: 42px; - border: 1px solid var(--line); - border-radius: 8px; - background: var(--panel); + 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(--muted); + color: var(--text-muted); font-size: 0.7rem; font-weight: 700; text-transform: uppercase; @@ -263,9 +358,9 @@ h3 { } .empty-state { - border: 1px dashed var(--line); - border-radius: 8px; - color: var(--muted); + border: 1px dashed var(--border-subtle); + border-radius: var(--radius-lg); + color: var(--text-muted); padding: 22px; text-align: center; } @@ -296,7 +391,7 @@ h3 { padding-top: 16px; } - h1 { + .page-shell h1 { font-size: 1.55rem; } @@ -306,3 +401,12 @@ h3 { grid-template-columns: 1fr; } } + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 25d2435..3c782c2 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -12,7 +12,7 @@ type RootLayoutProps = { export default function RootLayout({ children }: RootLayoutProps) { return ( - + {children} ); diff --git a/src/entities/dashcam/model/data.ts b/src/entities/dashcam/model/data.ts new file mode 100644 index 0000000..90ecea0 --- /dev/null +++ b/src/entities/dashcam/model/data.ts @@ -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', + }, + }, + }, +]; diff --git a/src/entities/dashcam/model/types.ts b/src/entities/dashcam/model/types.ts new file mode 100644 index 0000000..7ebc42f --- /dev/null +++ b/src/entities/dashcam/model/types.ts @@ -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; + specs: Record; +}; diff --git a/src/shared/lib/cn.ts b/src/shared/lib/cn.ts new file mode 100644 index 0000000..e359646 --- /dev/null +++ b/src/shared/lib/cn.ts @@ -0,0 +1,5 @@ +type ClassValue = string | false | null | undefined; + +export function cn(...classes: ClassValue[]) { + return classes.filter(Boolean).join(' '); +} diff --git a/src/shared/ui/atoms/Badge.tsx b/src/shared/ui/atoms/Badge.tsx new file mode 100644 index 0000000..1f9715b --- /dev/null +++ b/src/shared/ui/atoms/Badge.tsx @@ -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 = { + 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 ( + + {children} + + ); +} diff --git a/src/shared/ui/atoms/Button.tsx b/src/shared/ui/atoms/Button.tsx new file mode 100644 index 0000000..f2ebc66 --- /dev/null +++ b/src/shared/ui/atoms/Button.tsx @@ -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 & { + href: string; + }; + +type NativeButtonProps = BaseProps & + ButtonHTMLAttributes & { + href?: undefined; + }; + +type ButtonProps = LinkProps | NativeButtonProps; + +const variantClasses: Record = { + 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 = { + 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 ( + + {children} + + ); + } + + const buttonProps = props as NativeButtonProps; + const { type = 'button', ...restButtonProps } = buttonProps; + + return ( + + ); +} diff --git a/src/shared/ui/atoms/EvidenceChip.tsx b/src/shared/ui/atoms/EvidenceChip.tsx new file mode 100644 index 0000000..fab9432 --- /dev/null +++ b/src/shared/ui/atoms/EvidenceChip.tsx @@ -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 = { + verified: 'Verified', + claimed: 'Claimed', + unknown: 'Unknown', +}; + +const stateClasses: Record = { + 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 = { + verified: 'check', + claimed: 'info', + unknown: 'circle', +}; + +const methodLabels: Record = { + 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 ( + + + {labels[state]} + + ); +} diff --git a/src/shared/ui/atoms/Icon.tsx b/src/shared/ui/atoms/Icon.tsx new file mode 100644 index 0000000..4cd40f5 --- /dev/null +++ b/src/shared/ui/atoms/Icon.tsx @@ -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 ( + + ); +} + +const paths: Record = { + camera: ( + <> + + + + ), + check: , + circle: , + info: ( + <> + + + + + ), + shield: , + signal: ( + <> + + + + + ), + spark: ( + <> + + + + ), + warning: ( + <> + + + + + ), +}; diff --git a/src/shared/ui/atoms/ScoreMeter.tsx b/src/shared/ui/atoms/ScoreMeter.tsx new file mode 100644 index 0000000..a7e5e02 --- /dev/null +++ b/src/shared/ui/atoms/ScoreMeter.tsx @@ -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 ( +
+
+ {label} + {normalizedValue.toFixed(1)} +
+
+
+
+
+ ); +} diff --git a/src/shared/ui/molecules/ProductCard.tsx b/src/shared/ui/molecules/ProductCard.tsx new file mode 100644 index 0000000..fe7c3fa --- /dev/null +++ b/src/shared/ui/molecules/ProductCard.tsx @@ -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 ( +
+ {children} +
+ ); +} + +function Media({ product }: { product: DashcamProduct }) { + return ( +
+
+ +
+
+ + {product.award} + + score {product.score.toFixed(1)} +
+
+

evidence log

+

{product.evidence}

+
+
+
+ ); +} + +function Header({ product }: { product: DashcamProduct }) { + return ( +
+
+

+ {product.brand} +

+

+ {product.model} +

+
+

{product.tagline}

+
+ ); +} + +function Snapshot({ product }: { product: DashcamProduct }) { + const specKeys = ['sensor', 'resolution', 'bitrate'] as const; + + return ( +
+ {specKeys.map((key) => { + const spec = product.specs[key]; + + return ( +
+
{spec.label}
+
+ {spec.value} + +
+
+ ); + })} +
+ ); +} + +function ScoreStrip({ product }: { product: DashcamProduct }) { + return ( +
+ {scoreDimensions.map((dimension) => ( + + ))} +
+ ); +} + +function Verdict({ product }: { product: DashcamProduct }) { + return ( +
+
+

+ + Editorial override note +

+

{product.editorialNote}

+
+
+ {product.strengths.map((strength) => ( + + {strength} + + ))} +
+
+ ); +} + +function Actions({ product }: { product: DashcamProduct }) { + return ( +
+
+

{product.price}

+

+ {product.priceNote} / {product.seller} +

+
+ +
+ ); +} + +export const ProductCard = { + Actions, + Header, + Media, + Root, + ScoreStrip, + Snapshot, + Verdict, +}; + +const mediaToneClasses: Record = { + brass: '[--media-accent:#8F7660]', + graphite: '[--media-accent:#687175]', + oxblood: '[--media-accent:#7B4B49]', + petrol: '[--media-accent:#56798A]', + titanium: '[--media-accent:#A8A095]', +}; diff --git a/src/shared/ui/molecules/WinnerRail.tsx b/src/shared/ui/molecules/WinnerRail.tsx new file mode 100644 index 0000000..24ba4e5 --- /dev/null +++ b/src/shared/ui/molecules/WinnerRail.tsx @@ -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 ( +
+ {products.map((product) => ( + +
+ {product.award} +

+ {product.brand} {product.model} +

+
+
+

{product.price}

+ + {product.score.toFixed(1)} + +
+
+ ))} +
+ ); +} diff --git a/src/shared/ui/organisms/PageHero.tsx b/src/shared/ui/organisms/PageHero.tsx new file mode 100644 index 0000000..20bddac --- /dev/null +++ b/src/shared/ui/organisms/PageHero.tsx @@ -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 ( +
+
+
+ 2026 guide + verified / claimed / unknown +
+
+

+ Best Dashcams 2026 +

+

+ Evidence-led rankings for drivers who care about plate detail, parking mode, heat + stability, and readable specs before checkout. +

+
+
+ + +
+
+ + +
+ ); +} diff --git a/src/shared/ui/organisms/ProductComparisonTable.tsx b/src/shared/ui/organisms/ProductComparisonTable.tsx new file mode 100644 index 0000000..abd2de2 --- /dev/null +++ b/src/shared/ui/organisms/ProductComparisonTable.tsx @@ -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 ( +
+
+

hardware first

+

+ Compact comparison matrix +

+

+ The first rows show decisive hardware specs. Outcome rows and editorial issues follow. +

+
+ +
+ + + + + + {products.map((product) => ( + + ))} + + + + {comparisonSpecRows.map((row) => ( + + + {products.map((product) => { + const spec = product.specs[row.key]; + + return ( + + ); + })} + + ))} + + + + {products.map((product) => ( + + ))} + + + {scoreDimensions.map((dimension) => ( + + + {products.map((product) => ( + + ))} + + ))} + + + + {products.map((product) => ( + + ))} + + +
Dashcam hardware and outcome comparison
+ Spec + + + {product.award} + + + {product.brand} {product.model} + +
+ {row.label} + +
+ {spec.value} + +
+
+ Score + + + {product.score.toFixed(1)} + +
+ {dimension.label} + + + {product.scores[dimension.key].toFixed(1)} + +
+ Known issue + + {product.knownIssues[0]} +
+
+
+ ); +} diff --git a/src/shared/ui/templates/BestDashcamsPage.tsx b/src/shared/ui/templates/BestDashcamsPage.tsx new file mode 100644 index 0000000..276de4f --- /dev/null +++ b/src/shared/ui/templates/BestDashcamsPage.tsx @@ -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 ( +
+ + +
+ + + +
+
+
+

+ tested winners +

+

+ Fast decisions, visible evidence +

+
+ +
+ +
+
+ + + + + + + + +
+ +
+ {useCasePicks.slice(0, 2).map((product) => ( +
+ + + + + + +
+ ))} +
+
+ +
+ {useCasePicks.slice(2).map((product) => ( +
+ + + + + + +
+ ))} +
+
+ + + +
+
+
+

+ seller comparison +

+

+ Prices stay secondary to evidence +

+
+ affiliate-safe layout +
+ +
+ {products.map((product) => ( +
+
+

+ {product.seller} +

+

+ {product.brand} {product.model} +

+
+
+

+ {product.price} +

+

{product.priceNote}

+
+ +
+ ))} +
+
+ +
+
+ + Methodology + +

How winners are selected

+
+
+

+ Scores are grouped into video, night, parking, reliability, usability, and value. + Category winners use different weights over the same dimensions. +

+

+ 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. +

+
+
+
+
+ ); +} + +function SiteHeader() { + return ( +
+
+ + + + + videoreg.ru + + +
+
+ ); +}