VINdicated is a nonprofit website built with Next.js 16, React 19, Tailwind CSS v4, and TypeScript. It helps car buyers navigate dealerships without being taken advantage of, providing free guides, research, and know-your-rights resources.
- Getting Started
- Tech Stack
- Project Structure
- Routes
- Complex UI Systems
- Data Flows
- Design System
- Environment Variables
pnpm install
pnpm devOpen http://localhost:3000. The app auto-reloads on file changes.
pnpm build # production build
pnpm start # serve production build
pnpm lint # ESLint with auto-fix
pnpm lint:fix # Prettier formattingImportant: This project uses Next.js 16 with React 19 — APIs and conventions may differ from older versions. Read
node_modules/next/dist/docs/before writing new code.
| Layer | Technology |
|---|---|
| Framework | Next.js 16 (App Router) |
| UI | React 19, Tailwind CSS v4 |
| Language | TypeScript 5 |
Brevo (@getbrevo/brevo) |
|
| Fonts | Space Grotesk (headings), DM Sans (body) via next/font/google |
| Package manager | pnpm |
app/ # Next.js App Router pages & layouts
layout.tsx # Root layout — fonts, Nav, Footer, CarCursor
page.tsx # Home page
globals.css # Design tokens (CSS vars), animations, base styles
about/page.tsx # About Us
contact/
layout.tsx # Contact layout (sets per-section metadata)
page.tsx # Contact page with server action form
documents/
layout.tsx
page.tsx # Interactive document decoder (Carfax, Buyers Guide)
fraud/
layout.tsx
page.tsx # Fraud prevention: Pink Slip explainer, red flags, KYR
inspection/
layout.tsx
page.tsx # Pre-purchase inspection guide with engine diagram
research/page.tsx # Research & studies
rights/page.tsx # Know Your Rights (law reference cards)
not-found.tsx # 404 page
sitemap.ts # Auto-generated XML sitemap
robots.ts # robots.txt
actions/
email.ts # Server Action: contact form submission
components/
layout/
Nav.tsx # Sticky navbar with dropdowns & mobile overlay
Footer.tsx # Site footer with nav columns
sections/ # Page-level section components (homepage only)
RoadScene.tsx # Animated road banner (homepage hero backdrop)
HomeHero.tsx # Hero with animated counter stats
HomeDashboard.tsx # Animated gauge bar widget
HomeSections.tsx # HomeCards, HomeQuote, HomeFounder (all exported from here)
HomeCards.tsx # Re-export from HomeSections
HomeQuote.tsx # Re-export from HomeSections
HomeFounder.tsx # Re-export from HomeSections
StudyCard.tsx # Research study card widget
ui/
index.tsx # All shared UI primitives (see Design System)
CarCursor.tsx # Custom SVG car cursor with click-burst animation
lib/
brevo.ts # Brevo email client wrapper
constants.ts # Design tokens (JS), navLinks, footerNav
public/ # Static assets (images, icons, illustrations)
| Route | File | Purpose |
|---|---|---|
/ |
app/page.tsx |
Home page — RoadScene banner, hero stats, dashboard gauges, cards, quote, founder section |
/about |
app/about/page.tsx |
About page — discrimination research stats, mission (Educate/Empower/Vindicate), founder story, services |
/inspection |
app/inspection/page.tsx |
Pre-purchase inspection guide — what a PPI is, interactive engine diagram, inspection checklist, dealer locator |
/fraud |
app/fraud/page.tsx |
Fraud prevention — interactive Pink Slip document explainer, dealer red flags accordion, Know Your Rights laws, post-scam checklist |
/documents |
app/documents/page.tsx |
Document decoder — interactive annotated walkthrough of a Carfax report and Buyers Guide |
/research |
app/research/page.tsx |
Research page — methodology, active studies (UCLA collaboration), evidence base stats, references |
/rights |
app/rights/page.tsx |
Know Your Rights — plain-English reference cards for federal and California consumer protection laws |
/contact |
app/contact/page.tsx |
Contact page — form (client component) that submits via Server Action to Brevo email API |
/not-found |
app/not-found.tsx |
404 page |
/sitemap.xml |
app/sitemap.ts |
Auto-generated sitemap (uses NEXT_PUBLIC_SITE_URL) |
/robots.txt |
app/robots.ts |
Auto-generated robots file |
The navbar uses lib/constants.ts#navLinks to define dropdown children. Sub-sections use #hash anchors within their parent routes:
| Route | Section anchors |
|---|---|
/about |
#mission, #story, #vindicated-from |
/inspection |
#engine-diagram, #what-to-inspect, #where-to-get, #dealer-locators |
/fraud |
#red-flags, #know-your-rights, #after |
/research |
#study1, #study2, #get-involved |
/documents |
#ex-carfax1, #ex-carfax2, #ex-buyers-guide |
Replaces the browser cursor site-wide with a purple SVG car.
- Rendered once in
app/layout.tsxas a client component. - On mount, injects two DOM elements into
<body>:#car-cursor(the SVG car) and#click-burst(particle container). mousemovepositions the cursor element viastyle.left/top.mousedownadds.clicking(scales down via CSS) and spawns 8<span>particles in#click-burst. Each particle has--dx/--dyCSS variables set to random radial offsets; acburstCSS keyframe animates them outward and fades them out (450ms).mouseupremoves.clicking.- All event listeners and DOM nodes are cleaned up in the
useEffectreturn function. cursor: none !importantinglobals.csshides the native cursor everywhere.
Decorative animated banner that sits between the fixed Nav and the home hero, creating the illusion of a car driving along a night road.
- Stars: 5 absolutely positioned dots with
star-twinkleCSS animation (random delays and durations for asynchronous blinking). - Road stripes: 5
<div>elements with staggered negativeanimation-delayvalues onroad-stripeskeyframe, creating the appearance of continuous stripe flow from right to left. - Car: A
<Image>element with thecar-driveCSS animation — enters fromleft: -340px, exits atleft: 110%over 13 seconds, loops infinitely. - No JavaScript animation logic; purely declarative CSS.
Scroll-triggered fade-in + slide-up wrapper used to animate nearly every page section.
- Creates a
<div>with classfade-up(starts atopacity: 0; transform: translateY(30px)). - On mount, attaches an
IntersectionObserverwiththreshold: 0.1to the wrapper. - When the element enters the viewport, adds the
visibleclass (viaclassList.add), which CSS transitions toopacity: 1; translateY(0). - Observer is disconnected on unmount.
Four stat numbers in the home hero that animate from 0 to their target values on page load.
- Each stat has a corresponding DOM
id(stat1–stat4), target value, prefix/suffix, and animation duration. - On mount, a
600mssetTimeoutfiresanimateCount()for each stat. animateCountusesrequestAnimationFramewith a cubic ease-out (1 - (1 - t)^3) to interpolate the displayed number. Text is updated each frame viael.textContent.- A
triggeredref prevents re-running on hot reload.
Animated horizontal progress bars in the "Impact at a Glance" section.
- Each gauge
<div>starts withwidth: 0(via.gauge-fillCSS class). - On mount,
setTimeoutcalls are staggered by200msper gauge, setting each bar'sstyle.widthto its target percentage. - CSS
transition: width 1.6s easehandles the smooth animation. - A
triggeredref prevents re-running.
Used in two places with the same interaction model:
PinkSlipExplainer (app/fraud/page.tsx):
- Displays a California Certificate of Title image with 6 numbered
<button>pins positioned absolutely over the image. - State:
active: number | nulltracked withuseState. - Clicking a pin sets it as active; clicking again deactivates (toggle). Only one pin can be active at a time.
- When a pin is active:
- The corresponding highlight overlay (
position: absolute,pointer-events: none) fades in viaopacitytransition, drawing a colored border+background rectangle over the relevant document section. - The explanation panel on the right renders the
cardData[active]card withslide-upCSS animation. - Navigation dots below the card let the user jump directly to any pin.
- The corresponding highlight overlay (
- The document image is
brightness(0.88) saturate(0.75)by default;brightness(1)when any pin is active.
DocExhibit (app/documents/page.tsx):
- A reusable component (same concept) used for the Carfax report and Buyers Guide exhibits.
- Same
activestate pattern; same pin/highlight/card architecture.
Engine Diagram (app/inspection/page.tsx):
- Similar pin system over an engine photo with 10 color-coded pins (green = easy to check, gold = watch carefully, red = serious red flag, purple = informational).
- Selecting a pin shows a sticky side panel with part name, description, and what to look for.
Fixed top navbar with scroll-aware styling, desktop dropdowns, and a mobile fullscreen overlay.
- Scroll effect:
window.scrolllistener setsscrolledstate. Background opacity transitions from0.85to0.97whenscrollY > 50. - Desktop dropdowns: Hover intent handled via a debounced close timer (
closeTimerref, 300ms).onMouseEnteron a<li>clears the timer and opens that item's dropdown;onMouseLeavestarts the 300ms timer to close it. This prevents the dropdown from flickering when moving the mouse between the trigger and the menu. - Mobile menu: Hamburger button toggles
menuOpen. When open, a fullscreen overlay (position: fixed; inset: 0) renders all links. Automatically closes on route change via ausePathnameeffect. - Active state:
isActive()checkspathname === hreffor/andpathname.startsWith(href)for all other routes, providing underline decoration. - Nav links and dropdown items are sourced from
lib/constants.ts#navLinks.
Client-side form component that calls a Next.js Server Action.
ContactFormis a"use client"component managing state:submitted,isSubmitting,error.- On submit, prevents default, collects
FormData, calls thesubmitContactFormServer Action. - Shows inline success/error feedback. Success state auto-dismisses after 5 seconds via
setTimeout. - The form and all state are co-located within the page file rather than a separate file.
Single-open accordion used for the fraud red flags list.
openIndex: number | nullstate. Clicking an item setsopenIndexto that item's index, ornullif it was already open (toggle).- Expand/collapse icon is a
+character rotated 45° via CSStransformwhen open. - Supports a
defaultOpenprop per item (finds the firstdefaultOpen: trueitem as initial state).
User fills out contact form (app/contact/page.tsx)
→ form onSubmit handler
→ calls submitContactForm(data) [app/actions/email.ts] — Next.js Server Action
→ validates required fields (email, message)
→ calls sendEmail() [lib/brevo.ts] × 2:
1. Admin notification → getvindicated@outlook.com
(subject: "New Contact Form Submission: {topic}", reply-to: user's email)
2. Auto-response → user's email
(subject: "We've received your message - VINdicated")
→ returns { success: true } or { success: false, error: string }
→ ContactForm updates UI state (submitted / error)
Key files: app/actions/email.ts, lib/brevo.ts
External service: Brevo Transactional Email API (BREVO_API_KEY env var required)
All nav and footer links are defined as a single source of truth in lib/constants.ts:
lib/constants.ts (navLinks, footerNav)
→ components/layout/Nav.tsx (desktop dropdowns + mobile menu)
→ components/layout/Footer.tsx (footer column links)
To add, remove, or rename a route in the nav or footer, only lib/constants.ts needs to change.
Each page exports a Metadata object (Next.js App Router convention). The root layout (app/layout.tsx) sets global defaults including metadataBase (used to resolve relative OG image URLs):
app/layout.tsx
metadataBase = NEXT_PUBLIC_SITE_URL ?? "https://getvindicated.org"
title.template = "%s | VINdicated"
app/about/page.tsx (example)
title: "About Us" → renders as "About Us | VINdicated"
openGraph.url: "/about" → resolved relative to metadataBase
app/sitemap.ts returns a static array of all public routes. It reads NEXT_PUBLIC_SITE_URL for the base URL and is automatically served at /sitemap.xml by Next.js.
CSS custom properties are defined once in globals.css and mirrored as a JS object in lib/constants.ts#colors. Inline styles in components that need dynamic values (e.g., conditionally applied colors) reference the CSS variable strings directly ("var(--color-vivid)").
app/globals.css (:root { --color-vivid: #5a3069; ... })
→ consumed by Tailwind v4 utilities and component inline styles
lib/constants.ts (colors: { vivid: "#5a3069", ... })
→ available for JS-side color references if needed
All shared UI primitives are exported from components/ui/index.tsx:
| Component | Purpose |
|---|---|
Button |
Primary and outline CTAs. Renders as <Link> when href is provided, <button> otherwise. Supports external prop for target="_blank". |
PageHero |
Full-width page header with kicker label, large heading, and optional subtitle. Used on every inner page. |
SectionLabel |
Small uppercase-style label above section headings. |
SectionTitle |
Large <h2> with responsive fluid font sizing via clamp(). |
Divider |
Full-width <hr> with --color-border styling. |
FadeUp |
Scroll-triggered fade-in wrapper (see Complex UI Systems above). |
Pullquote |
Styled <blockquote> with left border and cite line. |
WarningBox |
Red-accented callout box for danger warnings. |
InfoBox |
Purple-accented callout box for informational notes. |
Checklist |
Arrow-prefixed list of bold label + descriptive text pairs. |
Accordion |
Single-open expandable list (see Complex UI Systems above). |
CardGrid |
Responsive grid of numbered cards with optional link. Hover turns background to brand purple. |
Tag |
Small pill label for tagging research studies. |
- Headings (
h1–h4): Space Grotesk, loaded vianext/font/google, CSS variable--font-heading - Body: DM Sans, CSS variable
--font-body <em>in headings: Renders as underlined text (not italic) viaglobals.css. The underline color is--color-accent(lavender).
| Token | Hex | Usage |
|---|---|---|
--color-bg-page |
#0d0814 |
Page background |
--color-bg-surface |
#160d24 |
Card/section background |
--color-bg-mid |
#2a1040 |
Mid-level surface |
--color-brand |
#3f234a |
Road color, brand fills |
--color-vivid |
#5a3069 |
Primary CTA, active states |
--color-light |
#b088cc |
Primary text accent, nav active |
--color-accent |
#cf8bd8 |
Heading em underlines, icon accents |
--color-border |
rgba(255,255,255,0.08) |
Borders, dividers |
--color-red |
#d63b3b |
Danger states, fraud warnings |
--color-gold |
#c9a84c |
Caution states, road stripes |
--color-green |
#2d9e6b |
Safe/informational states |
| Variable | Required | Description |
|---|---|---|
BREVO_API_KEY |
Yes (for contact form) | Brevo transactional email API key |
NEXT_PUBLIC_SITE_URL |
No | Base URL for sitemap and OG metadata. Defaults to https://getvindicated.org |
Copy .env.example to .env.local and fill in BREVO_API_KEY to enable the contact form in local development.