Agent Gian — Pitch Site
A pitch site is a paid deliverable for a Filipino small business: one Next.js + Tailwind v4 codebase, deployed to Vercel under a free *.vercel.app URL or a real domain, that loads under a second on phones and looks like a 7-figure brand. This skill captures every decision, every code template, and every Gemini prompt from the Carnage build so you never re-explain.
The reference build: carnageautoshop.vercel.app · source: C:/Users/gians/OneDrive/Desktop/Carnage Offroad Autoshop/carnage-offroad/.
When to invoke
Trigger this skill when the user says any of:
- "Build a pitch site for [business]"
- "Scaffold a marketing website for [business]"
- "I want a Carnage-style site"
- "Make a website I can sell to a small business"
- "Set up a one-page marketing site for my client"
- "Ship a 9-section marketing site"
Skip if the user wants a generic Next.js starter, an admin dashboard, an e-commerce checkout, or anything that needs a backend, auth, or DB writes. This is a pitch / marketing site only — every CTA is a phone call, FB DM, SMS, or email; no booking system, no payment, no user accounts.
Onboarding interview — one question at a time
Run via AskUserQuestion. Single multiple-choice per turn. Recommend one option in the question prose. Never combine the visual companion offer with other content.
Before question 1, explore project context: run pwd, check for an existing repo, read CLAUDE.md if present.
Q1 — business name + city
What's the business name and city? (e.g. "Bayan Coffee Roasters · Quezon City")
Free-text. Save as BUSINESS_NAME and BUSINESS_CITY.
Q2 — brand personality (visual companion required)
Offer the visual companion as its own message first: "I'll mock up 3 brand-personality directions side-by-side before we lock the look. One sec."
Then present:
A · Clean Pro — Inter throughout, charcoal + white, accent blue, gentle rounded corners. Reads like a Stripe / Linear pricing page. Best for: clinics, dental, accounting, B2B services.
B · Edgy Garage (Carnage's pick) — Anton display + Inter body, white + red accent, square clip-angle CTAs, italic skewed wordmark, Ken Burns hero. Reads like Off-road Magazine. Best for: workshops, autoshops, gyms, barbers, anything blue-collar premium.
C · Premium Build — Playfair Display + Inter, ivory + warm-black + brass accent, generous whitespace, pinned hero photography, no clip-angle. Reads like a Michelin-recommended restaurant or a boutique salon. Best for: cafés, salons, restaurants, boutique retail.
My pick: Edgy Garage if they sell trucks/tools/builds. Premium Build for everything else (cafés, beauty, food, hospitality). Clean Pro for clinics/B2B.
Save as BRAND_PERSONALITY.
Q3 — brand colors
The default for [BRAND_PERSONALITY] is [defaults below]. Override?
- Edgy Garage default: white #ffffff + red accent #ff1f1f, deep #c8102e
- Premium Build default: ivory #f8f5ee + warm-black #1a1612 + brass #b8893d
- Clean Pro default: white #ffffff + charcoal #111111 + blue accent #2563eb
Save 4 hex codes: --color-bg-base, --color-bg-dark, --color-accent, --color-accent-deep.
Q4 — 3-line motto
The hero shows a 3-line motto, all-caps, with the last line in the accent color. Carnage's is
BUILD IT. / LIFT IT. / DOMINATE ANY TERRAIN.What are yours?
Constraint: each line ≤ 4 short words. The last line is the punchline.
Q5 — tagline
One sentence: what they sell, for whom. Sub-15 words. Carnage's: "Lifts. Bumpers. Mags. Builds — for Hilux, Ranger, D-Max, Triton, BT-50."
Q6 — categories (5 to 9)
List 5–9 product or service categories. For each, give: slug · name · short label · 1-line tagline · 2–3 brand/sub-product samples.
Example (Carnage):
lift-kits · Lift Kits · 2" Suspension · Trail clearance for Hilux, Ranger, D-Max, Triton, BT-50. · 2" · OME · Bilstein
Save as CATEGORIES[].
Q7 — 3 trust pillars
The "Why [Business]" section uses 3 cards with red Anton numbers (01 / 02 / 03). Each card has a 3–6 word title and a 20-word body. What are your 3 pillars? Carnage's are: Free labor installation · Installment terms accepted · Trail-tested brands.
Q8 — brand-strip partners
List 6–10 brand-name partners shown in greyscale on the brand strip. These are name-drops — they don't need logos. Carnage's: Profender · ARB · Bilstein · Fox · Fuel Off-Road · Old Man Emu · Panther · Roll-N-Lock.
Q9 — process steps (4)
The Process section has 4 steps with icons (chat / key / wrench / truck). Adapt the verbs to the business. Carnage's: Inquire · Drop off · Build · Drive away. Coffee shop: Order · Roast · Brew · Sip. Salon: Consult · Cleanse · Style · Glow up.
Q10 — shop info
Need 7 fields:
- Address line 1 + line 2
- City + zip + region
- Mobile / primary phone (with country code, e.g. +639...)
- Landline (optional)
- FB page URL + handle (the part after fb.com/)
- Email (optional — leave blank to hide email button in quote modal)
- Hours (e.g. "Mon–Sat · 9:30am–5:30pm")
- Google Maps embed URL (Maps → Share → Embed a map → copy the iframe
src)- Google Maps share URL (Maps → Share → Copy link)
Q11 — vehicle / customer filter list
The product detail pages have filter chips (
All · Filter1 · Filter2 · …). For Carnage these were vehicle fitments: All · Hilux · Ranger · D-Max · Triton · BT-50 · Other. What's the equivalent for your business?
- Salon: All · Long hair · Short hair · Color · Bridal
- Café: All · Hot · Iced · Pastry · Single-origin
- Clinic: All · General · Pediatric · Cosmetic · Emergency
If the business doesn't need filters, skip this — the FilterChips component can be omitted from product pages.
Q12 — selling points
Three yes/no toggles:
- Free labor / free consultation? (booleans displayed in trust pillars)
- Installment plans? (booleans displayed in trust pillars)
- Best-seller badges on certain products? (red corner-cut badges)
Q13 — quote modal config
The Request a Quote modal sends via SMS (always) + Email (only if
SHOP.emailis set). Messenger was tried for Carnage and kept failing — it stays disabled. Confirm:
- Keep the floating quote button? (default yes)
- Use SMS as primary channel? (default yes — universal, works on every PH phone)
- Need email button? (only if owner has a real address)
After Q13, proceed straight to scaffolding. Do not ask for confirmation — the user has answered enough.
Architecture — file tree to scaffold
Mirror Carnage exactly:
<slug>/
├── app/
│ ├── globals.css
│ ├── layout.tsx
│ ├── page.tsx # 9-section homepage
│ ├── not-found.tsx # site-wide 404
│ └── products/
│ └── [slug]/
│ ├── page.tsx # generateStaticParams over CATEGORIES
│ └── not-found.tsx
├── components/
│ ├── Nav.tsx
│ ├── Footer.tsx
│ ├── DemoRibbon.tsx # "Sample demo · created by [you]"
│ ├── RequestQuote.tsx # client modal
│ ├── primitives/
│ │ ├── PrimaryCTA.tsx
│ │ ├── SecondaryCTA.tsx
│ │ ├── Microlabel.tsx
│ │ ├── SectionHeader.tsx
│ │ └── Reveal.tsx # client, IntersectionObserver
│ ├── home/
│ │ ├── Hero.tsx # client, 3-image rotation + Ken Burns
│ │ ├── ProductsGrid.tsx
│ │ ├── WhyCarnage.tsx # rename to Why<Business>
│ │ ├── BrandStrip.tsx
│ │ ├── HotPicks.tsx
│ │ ├── Process.tsx
│ │ ├── FacebookFeed.tsx
│ │ ├── Gallery.tsx # client, lightbox
│ │ └── Location.tsx
│ └── product/
│ ├── CategoryHero.tsx
│ ├── FilterChips.tsx # client, useState
│ ├── ProductCard.tsx
│ └── EmptyState.tsx
├── data/
│ ├── shop.ts # single source of truth
│ ├── copy.ts # every user-facing string
│ ├── categories.ts
│ ├── products.ts
│ └── gallery.ts
├── lib/
│ └── cn.ts # className joiner
├── public/
│ ├── hero/ # 3 hero variants (PNG, ~3840×1644)
│ ├── sections/ # 2 section banners
│ ├── categories/ # 1 image per category
│ ├── products/
│ │ └── placeholder.svg
│ ├── gallery/ # 8–12 real photos from FB
│ └── logo/ # favicon + OG image
├── tests/
│ └── smoke.spec.ts
├── package.json
├── tsconfig.json
├── next.config.ts
├── playwright.config.ts
├── postcss.config.mjs
├── eslint.config.mjs
└── README.md
Tech stack + startup commands
# Working dir example: C:/Users/gians/Downloads/<slug>/ (mind path-with-space quoting on Windows)
npx create-next-app@latest <slug> --ts --tailwind --eslint --app --no-src-dir --turbopack --import-alias "@/*" --use-npm --no-git
cd <slug>
# init repo
git init
git branch -M main
# install runtime deps
npm install yet-another-react-lightbox @vercel/analytics
# install playwright (dev)
npm install -D @playwright/test
npx playwright install --with-deps chromium
# type-check gate (run after every file edit)
npx tsc --noEmit
After every task step: npx tsc --noEmit && git add <specific files> && git commit -m "feat(<area>): <change>". Never git add -A — stage by name. One logical change per commit (Agent Gian rule §V). Auto-push if user opted in.
Tailwind v4 brand tokens — full app/globals.css
Tailwind v4 uses CSS-only config — no tailwind.config.js. All tokens live inside the @theme block and become utilities automatically (bg-bg-base, text-accent, etc.). Replace the four color hexes with the user's picks from Q3.
@import "tailwindcss";
@theme {
--color-bg-base: #ffffff; /* page background */
--color-bg-alt: #f9f9f9; /* alternating section background */
--color-bg-dark: #0a0a0a; /* dark sections (Why / Gallery / EmptyState) */
--color-accent: #ff1f1f; /* primary accent — links, CTAs, microlabels */
--color-accent-deep: #c8102e; /* CTA hover */
--color-ink: #111111; /* body text */
--color-ink-muted: #666666; /* secondary text */
--color-ink-faint: #888888; /* tertiary text + dark-section sub */
--color-border-soft: #eeeeee; /* hairline borders */
--font-display: var(--font-anton), 'Impact', 'Arial Black', sans-serif;
--font-body: var(--font-inter), -apple-system, BlinkMacSystemFont, sans-serif;
}
* {
box-sizing: border-box;
}
html, body {
margin: 0;
padding: 0;
background: var(--color-bg-base);
color: var(--color-ink);
font-family: var(--font-body);
-webkit-font-smoothing: antialiased;
}
/* Hero animation: Ken Burns drift */
@keyframes ken-burns {
0% { transform: scale(1) translate(0, 0); }
100% { transform: scale(1.12) translate(-2%, -1.5%); }
}
.ken-burns {
animation: ken-burns 8s ease-in-out infinite alternate;
transform-origin: 60% 70%;
will-change: transform;
}
/* Angled-cut button (garage feel — drop for Premium Build personality) */
.clip-angle {
clip-path: polygon(6% 0, 100% 0, 94% 100%, 0 100%);
}
/* Skew helper for the logo wordmark */
.skew-logo {
transform: skew(-5deg);
}
/* Parallax background — works on desktop, fallback handled per-component on iOS */
.bg-fixed-cover {
background-attachment: fixed;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
}
/* Reduced motion — kill animations */
@media (prefers-reduced-motion: reduce) {
.ken-burns { animation: none; }
.bg-fixed-cover { background-attachment: scroll; }
* { transition: none !important; }
}
/* Skip-to-content link (a11y) */
.skip-link {
position: absolute;
top: -100px;
left: 0;
background: var(--color-accent);
color: #fff;
padding: 8px 12px;
z-index: 100;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1px;
font-size: 12px;
}
.skip-link:focus { top: 0; }
For Premium Build, swap --font-display to Playfair Display, drop .clip-angle and .skew-logo, swap .ken-burns for a softer 12s zoom-out keyframe. For Clean Pro, drop the display font (Inter throughout), drop .clip-angle, soften the Ken Burns to a 14s pan.
Fonts — next/font/google setup
// app/layout.tsx (top portion)
import { Anton, Inter } from 'next/font/google';
const anton = Anton({
subsets: ['latin'],
weight: '400',
variable: '--font-anton',
display: 'swap',
});
const inter = Inter({
subsets: ['latin'],
variable: '--font-inter',
display: 'swap',
});
// then on <html>
<html lang="en" className={`${anton.variable} ${inter.variable}`}>
For Premium Build, replace Anton with Playfair_Display. For Clean Pro, drop the display font import — pass only inter.variable.
Root layout — full app/layout.tsx
import type { Metadata } from 'next';
import { Anton, Inter } from 'next/font/google';
import { Analytics } from '@vercel/analytics/next';
import { RequestQuote } from '@/components/RequestQuote';
import { DemoRibbon } from '@/components/DemoRibbon';
import './globals.css';
const anton = Anton({
subsets: ['latin'],
weight: '400',
variable: '--font-anton',
display: 'swap',
});
const inter = Inter({
subsets: ['latin'],
variable: '--font-inter',
display: 'swap',
});
export const metadata: Metadata = {
title: '<BUSINESS_NAME> · <BUSINESS_CITY>',
description: '<TAGLINE>',
metadataBase: new URL('https://<DOMAIN-OR-VERCEL-URL>'),
icons: {
icon: '/logo/<slug>.jpg',
apple: '/logo/<slug>.jpg',
},
openGraph: {
title: '<BUSINESS_NAME>',
description: '<TAGLINE>',
type: 'website',
images: [{ url: '/logo/<slug>.jpg', width: 1200, height: 630, alt: '<BUSINESS_NAME>' }],
},
twitter: {
card: 'summary_large_image',
title: '<BUSINESS_NAME>',
description: '<TAGLINE>',
images: ['/logo/<slug>.jpg'],
},
robots: { index: true, follow: true },
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={`${anton.variable} ${inter.variable}`}>
<body>
<a href="#main" className="skip-link">Skip to content</a>
<DemoRibbon />
{children}
<RequestQuote />
<Analytics />
</body>
</html>
);
}
Homepage composition — app/page.tsx
import { Nav } from '@/components/Nav';
import { Footer } from '@/components/Footer';
import { Hero } from '@/components/home/Hero';
import { ProductsGrid } from '@/components/home/ProductsGrid';
import { WhyCarnage } from '@/components/home/WhyCarnage';
import { BrandStrip } from '@/components/home/BrandStrip';
import { HotPicks } from '@/components/home/HotPicks';
import { Process } from '@/components/home/Process';
import { FacebookFeed } from '@/components/home/FacebookFeed';
import { Gallery } from '@/components/home/Gallery';
import { Location } from '@/components/home/Location';
export default function HomePage() {
return (
<>
<Nav />
<main id="main">
<Hero />
<ProductsGrid />
<WhyCarnage />
<BrandStrip />
<HotPicks />
<Process />
<FacebookFeed />
<Gallery />
<Location />
</main>
<Footer />
</>
);
}
Section 1 — Hero (components/home/Hero.tsx)
Client component. Three images crossfade every 7s with parallax + Ken Burns. 3-line motto in Anton uppercase, last line in accent color. Two CTAs (call + catalog). Indicator dots bottom-right.
'use client';
import { useEffect, useState } from 'react';
import { COPY } from '@/data/copy';
import { SHOP } from '@/data/shop';
import { PrimaryCTA } from '@/components/primitives/PrimaryCTA';
import { SecondaryCTA } from '@/components/primitives/SecondaryCTA';
import { cn } from '@/lib/cn';
const HERO_IMAGES: string[] = [
'/hero/<slug>-golden.png',
'/hero/<slug>-stormy.png',
'/hero/<slug>-shop.png',
];
const ROTATION_MS = 7000;
const FADE_MS = 1200;
export function Hero() {
const [active, setActive] = useState(0);
useEffect(() => {
if (HERO_IMAGES.length <= 1) return;
const id = setInterval(() => {
setActive((prev) => (prev + 1) % HERO_IMAGES.length);
}, ROTATION_MS);
return () => clearInterval(id);
}, []);
return (
<section
id="hero"
className="relative h-[80vh] min-h-[540px] overflow-hidden text-white bg-[var(--color-bg-dark)]"
>
{HERO_IMAGES.map((src, i) => (
<div
key={src}
aria-hidden="true"
className={cn(
'absolute inset-0 bg-fixed-cover transition-opacity ease-in-out',
i === active ? 'opacity-100' : 'opacity-0',
)}
style={{ backgroundImage: `url(${src})`, transitionDuration: `${FADE_MS}ms` }}
>
<div
className="absolute inset-0 ken-burns bg-cover bg-center"
style={{ backgroundImage: `url(${src})` }}
/>
</div>
))}
<div aria-hidden="true" className="absolute inset-0 bg-gradient-to-b from-black/30 via-black/10 to-black/80 z-[1]" />
<div className="relative z-10 max-w-6xl mx-auto h-full px-5 flex flex-col justify-end pb-10">
<span className="inline-block w-fit bg-[var(--color-accent)] text-white text-[10px] font-bold uppercase tracking-[2px] px-2 py-1 mb-3">
{COPY.hero.badge}
</span>
<h1 className="font-[family-name:var(--font-display)] uppercase tracking-wide text-4xl sm:text-5xl md:text-6xl leading-[0.95] drop-shadow-lg">
{COPY.hero.h1Line1}
<br />
{COPY.hero.h1Line2}
<br />
<span className="text-[var(--color-accent)]">{COPY.hero.h1Line3}</span>
</h1>
<p className="text-sm md:text-base mt-3 opacity-90 drop-shadow-md">{COPY.hero.tagline}</p>
<div className="flex gap-3 mt-5">
<PrimaryCTA href={`tel:${SHOP.phone}`}>{COPY.hero.ctaPrimary}</PrimaryCTA>
<SecondaryCTA href="#products" variant="light">{COPY.hero.ctaSecondary}</SecondaryCTA>
</div>
</div>
{HERO_IMAGES.length > 1 && (
<div className="absolute bottom-3 right-5 z-10 flex gap-1.5">
{HERO_IMAGES.map((src, i) => (
<span
key={src}
aria-hidden="true"
className={cn(
'block w-1.5 h-1.5 rounded-full transition-all duration-500',
i === active ? 'bg-[var(--color-accent)] w-4' : 'bg-white/40',
)}
/>
))}
</div>
)}
</section>
);
}
Section 2 — Products grid (components/home/ProductsGrid.tsx)
Full-bleed banner above + 9 image-backed cards with dark gradient, red left bar, hover zoom. Cards without an image fall back to a label-only tile.
import Image from 'next/image';
import Link from 'next/link';
import { CATEGORIES } from '@/data/categories';
import { COPY } from '@/data/copy';
import { SectionHeader } from '@/components/primitives/SectionHeader';
import { Reveal } from '@/components/primitives/Reveal';
export function ProductsGrid() {
return (
<section id="products" className="py-12">
<Reveal direction="fade" duration={900}>
<div className="relative w-full aspect-[21/9] sm:aspect-[21/9] md:aspect-[21/8] max-h-[420px] overflow-hidden">
<Image
src="/sections/<N>-categories.jpg"
alt="<BUSINESS_NAME> interior — all categories in one place"
fill
sizes="100vw"
priority={false}
className="object-cover"
/>
<div aria-hidden="true" className="absolute inset-0 bg-gradient-to-t from-white via-white/30 to-transparent" />
</div>
</Reveal>
<div className="px-5 max-w-6xl mx-auto -mt-6 md:-mt-10 relative z-10">
<Reveal>
<SectionHeader
label={COPY.products.label}
title={<>{COPY.products.h2Line1}<br />{COPY.products.h2Line2}</>}
sub={COPY.products.sub}
/>
</Reveal>
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 md:gap-4">
{CATEGORIES.map((cat, i) => (
<Reveal key={cat.slug} delay={i * 60}>
<Link
href={`/products/${cat.slug}`}
className={
cat.image
? 'group relative block rounded-md overflow-hidden h-32 md:h-40 hover:-translate-y-0.5 hover:shadow-lg transition-all'
: 'relative bg-[#f6f6f6] border border-[var(--color-border-soft)] rounded-md p-3 md:p-4 pl-5 md:pl-6 h-24 md:h-28 hover:-translate-y-0.5 hover:shadow-md transition-all flex flex-col justify-between'
}
>
{cat.image ? (
<>
<Image
src={cat.image}
alt={`${cat.name} — <BUSINESS_NAME>`}
fill
sizes="(max-width: 768px) 50vw, 33vw"
className="object-cover transition-transform duration-500 group-hover:scale-105"
/>
<div aria-hidden="true" className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent" />
<span aria-hidden="true" className="absolute top-0 left-0 h-full w-1 bg-[var(--color-accent)] z-10" />
<div className="absolute bottom-2.5 left-3 right-3 z-10">
<div className="text-xs md:text-sm font-bold uppercase tracking-wide text-white drop-shadow-lg">{cat.name}</div>
<div className="text-[10px] text-white/80 mt-0.5 drop-shadow">{cat.brandSamples}</div>
</div>
<div aria-hidden="true" className="absolute bottom-2 right-3 text-[var(--color-accent)] text-base z-10 drop-shadow">→</div>
</>
) : (
<>
<span className="absolute top-0 left-0 h-full w-1 bg-[var(--color-accent)]" aria-hidden="true" />
<div>
<div className="text-xs md:text-sm font-bold uppercase tracking-wide text-[var(--color-ink)]">{cat.name}</div>
<div className="text-[10px] text-[var(--color-ink-faint)] mt-0.5">{cat.brandSamples}</div>
</div>
<div className="text-[var(--color-accent)] text-base self-end" aria-hidden="true">→</div>
</>
)}
</Link>
</Reveal>
))}
</div>
</div>
</section>
);
}
Section 3 — Why [Business] (components/home/WhyCarnage.tsx → rename)
Dark section, 3 cards with red Anton numbers, copy lifted from Q7.
import { COPY } from '@/data/copy';
import { SectionHeader } from '@/components/primitives/SectionHeader';
import { Reveal } from '@/components/primitives/Reveal';
export function WhyCarnage() {
return (
<section className="bg-[var(--color-bg-dark)] text-white px-5 py-12">
<div className="max-w-6xl mx-auto">
<Reveal>
<SectionHeader
label={COPY.why.label}
title={<>{COPY.why.h2Line1}<br />{COPY.why.h2Line2}</>}
light
/>
</Reveal>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 md:gap-4">
{COPY.why.cards.map((card, i) => (
<Reveal key={card.title} delay={i * 100} direction="up">
<div className="bg-black/40 border border-white/5 rounded-md p-4 h-full">
<div className="font-[family-name:var(--font-display)] text-[var(--color-accent)] text-2xl leading-none">
{String(i + 1).padStart(2, '0')}
</div>
<h4 className="text-xs font-bold uppercase tracking-wide mt-2">{card.title}</h4>
<p className="text-[11px] text-[var(--color-ink-faint)] mt-1.5 leading-snug">{card.body}</p>
</div>
</Reveal>
))}
</div>
</div>
</section>
);
}
If renaming the file/export to Why<Business>, update the import in app/page.tsx and the cards source in data/copy.ts. Carnage-style works fine for any business — no rename strictly required.
Section 4 — Brand strip (components/home/BrandStrip.tsx)
Greyscale + opacity-60 by default, restores on hover.
import { COPY } from '@/data/copy';
import { Microlabel } from '@/components/primitives/Microlabel';
import { Reveal } from '@/components/primitives/Reveal';
export function BrandStrip() {
return (
<section className="bg-[var(--color-bg-alt)] px-5 py-6">
<div className="max-w-6xl mx-auto">
<Reveal>
<Microlabel className="text-center">{COPY.brands.label}</Microlabel>
</Reveal>
<div className="mt-4 flex flex-wrap items-center justify-center gap-4 md:gap-8 grayscale opacity-60 hover:opacity-100 hover:grayscale-0 transition-all">
{COPY.brands.list.map((brand, i) => (
<Reveal key={brand} delay={i * 60} as="span">
<span className="text-sm md:text-base font-extrabold uppercase tracking-wider text-[var(--color-ink)] inline-block">
{brand}
</span>
</Reveal>
))}
</div>
</div>
</section>
);
}
Section 5 — Hot picks (components/home/HotPicks.tsx)
Full-bleed banner + horizontal-scroll carousel of featured: true products.
import Image from 'next/image';
import { COPY } from '@/data/copy';
import { getFeaturedProducts } from '@/data/products';
import { SectionHeader } from '@/components/primitives/SectionHeader';
import { ProductCard } from '@/components/product/ProductCard';
import { Reveal } from '@/components/primitives/Reveal';
export function HotPicks() {
const items = getFeaturedProducts(8);
return (
<section className="bg-[var(--color-bg-alt)] py-12">
<Reveal direction="fade" duration={900}>
<div className="relative w-full aspect-[21/9] md:aspect-[21/8] max-h-[420px] overflow-hidden">
<Image
src="/sections/moving-fast.jpg"
alt="<BUSINESS_NAME> hot picks"
fill
sizes="100vw"
className="object-cover"
/>
<div aria-hidden="true" className="absolute inset-0 bg-gradient-to-t from-[var(--color-bg-alt)] via-[var(--color-bg-alt)]/30 to-transparent" />
</div>
</Reveal>
<div className="px-5 max-w-6xl mx-auto -mt-6 md:-mt-10 relative z-10">
<Reveal>
<SectionHeader label={COPY.hotPicks.label} title={COPY.hotPicks.h2} sub={COPY.hotPicks.sub} />
</Reveal>
<Reveal delay={120}>
<div
className="flex gap-3 overflow-x-auto pb-2 -mx-5 px-5"
style={{ scrollSnapType: 'x mandatory' }}
>
{items.map((p) => (
<div key={p.id} style={{ scrollSnapAlign: 'start' }}>
<ProductCard product={p} variant="carousel" />
</div>
))}
</div>
</Reveal>
</div>
</section>
);
}
Section 6 — Process (components/home/Process.tsx)
4 steps with red Anton numbers + outlined SVG icons (paste exact paths). Icons are currentColor so they inherit the accent.
import { COPY } from '@/data/copy';
import { SectionHeader } from '@/components/primitives/SectionHeader';
import { Reveal } from '@/components/primitives/Reveal';
const STEP_ICONS = [
// 01 — Inquire (chat bubble)
(
<svg key="inquire" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
<line x1="8" y1="10" x2="16" y2="10" />
<line x1="8" y1="13" x2="13" y2="13" />
</svg>
),
// 02 — Drop off (key)
(
<svg key="dropoff" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<circle cx="7" cy="15" r="4" />
<line x1="9.83" y1="12.17" x2="22" y2="0" />
<line x1="17" y1="5" x2="20" y2="8" />
<line x1="14" y1="8" x2="17" y2="11" />
</svg>
),
// 03 — Build (wrench)
(
<svg key="build" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.121 2.121 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
</svg>
),
// 04 — Drive away (truck)
(
<svg key="drive" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M1 3h13v13H1z" />
<path d="M14 8h4l3 3v5h-7V8z" />
<circle cx="5.5" cy="18.5" r="2.5" />
<circle cx="18.5" cy="18.5" r="2.5" />
</svg>
),
];
export function Process() {
return (
<section className="px-5 py-12 max-w-6xl mx-auto">
<Reveal>
<SectionHeader
label={COPY.process.label}
title={<>{COPY.process.h2Line1}<br />{COPY.process.h2Line2}</>}
/>
</Reveal>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 md:gap-6">
{COPY.process.steps.map((step, i) => (
<Reveal key={step.n} delay={i * 120} direction="up">
<div className="flex md:flex-col gap-3 md:gap-2">
<div className="flex md:flex-col items-start gap-2 md:gap-1">
<div className="text-[var(--color-accent)] w-9 h-9 md:w-10 md:h-10">
{STEP_ICONS[i]}
</div>
<div className="font-[family-name:var(--font-display)] text-[var(--color-ink-faint)] text-base md:text-sm leading-none mt-1">
{step.n}
</div>
</div>
<div className="flex-1">
<h4 className="text-xs font-bold uppercase tracking-wide text-[var(--color-ink)]">{step.title}</h4>
<p className="text-[11px] text-[var(--color-ink-muted)] mt-1 leading-snug">{step.body}</p>
</div>
</div>
</Reveal>
))}
</div>
</section>
);
}
For non-Carnage businesses, swap the icon paths: coffee shop = mug / bag-of-beans / pour-over / cup. Salon = scissors / wash / comb / mirror. Use feathericons.com — copy 24×24 viewBox, stroke-width 1.5.
Section 7 — Facebook feed (components/home/FacebookFeed.tsx)
import { COPY } from '@/data/copy';
import { SHOP } from '@/data/shop';
import { SectionHeader } from '@/components/primitives/SectionHeader';
import { Reveal } from '@/components/primitives/Reveal';
export function FacebookFeed() {
const fbPluginUrl = `https://www.facebook.com/plugins/page.php?href=${encodeURIComponent(
SHOP.fbUrl,
)}&tabs=timeline&width=500&height=500&small_header=true&adapt_container_width=true&hide_cover=false&show_facepile=true`;
return (
<section id="updates" className="px-5 py-12 max-w-6xl mx-auto">
<Reveal>
<SectionHeader label={COPY.facebook.label} title={COPY.facebook.h2} />
</Reveal>
<Reveal delay={120}>
<div className="flex justify-center">
<iframe
src={fbPluginUrl}
title="<BUSINESS_NAME> Facebook page"
width={500}
height={500}
style={{ border: 'none', overflow: 'hidden', minHeight: 500, maxWidth: '100%' }}
loading="lazy"
allow="encrypted-media"
/>
</div>
<noscript>
<p className="text-center text-sm mt-4">
<a href={SHOP.fbUrl} target="_blank" rel="noopener" className="text-[var(--color-accent)] underline">
Visit our Facebook page
</a>
</p>
</noscript>
</Reveal>
</section>
);
}
If the business has Instagram instead of FB, swap the iframe for SnapWidget free embed and rename labels accordingly.
Section 8 — Gallery (components/home/Gallery.tsx)
Client component. 3-col mobile / 4-col desktop. wide items span 2 cols. Video tiles get a red play badge. Lightbox is yet-another-react-lightbox with the Video plugin.
'use client';
import { useState } from 'react';
import Image from 'next/image';
import Lightbox from 'yet-another-react-lightbox';
import Video from 'yet-another-react-lightbox/plugins/video';
import 'yet-another-react-lightbox/styles.css';
import { COPY } from '@/data/copy';
import { GALLERY } from '@/data/gallery';
import { SectionHeader } from '@/components/primitives/SectionHeader';
import { Reveal } from '@/components/primitives/Reveal';
import { cn } from '@/lib/cn';
export function Gallery() {
const [openIndex, setOpenIndex] = useState<number | null>(null);
const slides = GALLERY.map((item) => {
if (item.type === 'video') {
return {
type: 'video' as const,
sources: [{ src: item.src, type: 'video/mp4' }],
poster: item.thumb,
autoPlay: true,
};
}
return { src: item.src, alt: item.caption ?? '' };
});
return (
<section id="gallery" className="bg-[var(--color-bg-dark)] text-white px-5 py-12">
<div className="max-w-6xl mx-auto">
<Reveal>
<SectionHeader label={COPY.gallery.label} title={COPY.gallery.h2} sub={COPY.gallery.sub} light />
</Reveal>
<div className="grid grid-cols-3 md:grid-cols-4 gap-1 md:gap-2">
{GALLERY.map((item, i) => (
<Reveal
key={`${item.src}-${i}`}
delay={i * 50}
className={cn(item.wide && 'col-span-2')}
>
<button
type="button"
=> setOpenIndex(i)}
className={cn(
'relative w-full bg-gray-800 rounded-sm overflow-hidden aspect-square transition-transform duration-300 hover:scale-[1.02]',
item.wide && 'aspect-[2/1]',
)}
aria-label={item.caption ?? `Gallery item ${i + 1}`}
>
<Image
src={item.thumb ?? item.src}
alt={item.caption ?? ''}
fill
sizes="(max-width: 768px) 33vw, 25vw"
className="object-cover"
/>
{item.type === 'video' && (
<span aria-hidden="true" className="absolute inset-0 flex items-center justify-center">
<span className="w-8 h-8 rounded-full bg-[var(--color-accent)] flex items-center justify-center">
<span className="block w-0 h-0 border-l-[8px] border-l-white border-y-[5px] border-y-transparent ml-0.5" />
</span>
</span>
)}
</button>
</Reveal>
))}
</div>
<Lightbox
open={openIndex !== null}
index={openIndex ?? 0}
close={() => setOpenIndex(null)}
slides={slides}
plugins={[Video]}
/>
</div>
</section>
);
}
Section 9 — Location (components/home/Location.tsx)
import { COPY } from '@/data/copy';
import { SHOP, formatAddress } from '@/data/shop';
import { SectionHeader } from '@/components/primitives/SectionHeader';
import { PrimaryCTA } from '@/components/primitives/PrimaryCTA';
import { SecondaryCTA } from '@/components/primitives/SecondaryCTA';
import { Reveal } from '@/components/primitives/Reveal';
export function Location() {
return (
<section id="visit" className="px-5 py-12 max-w-6xl mx-auto">
<Reveal>
<SectionHeader label={COPY.location.label} title={COPY.location.h2} />
</Reveal>
<div className="grid gap-4 md:grid-cols-[3fr_2fr]">
<Reveal direction="left" delay={80}>
<div className="rounded-md overflow-hidden border border-[var(--color-border-soft)] aspect-[4/3] md:aspect-auto md:h-[300px]">
<iframe
src={SHOP.mapsEmbedUrl}
title="<BUSINESS_NAME> on Google Maps"
width="100%"
height="100%"
style={{ border: 0 }}
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
allowFullScreen
/>
</div>
</Reveal>
<Reveal direction="right" delay={160}>
<div className="text-sm">
<div className="mb-4">
<div className="text-[10px] font-bold uppercase tracking-widest text-[var(--color-accent)]">Address</div>
<p className="mt-1 text-[var(--color-ink)]">{formatAddress()}</p>
</div>
<div className="mb-4">
<div className="text-[10px] font-bold uppercase tracking-widest text-[var(--color-accent)]">Hours</div>
<p className="mt-1 text-[var(--color-ink)]">{SHOP.hours}</p>
</div>
<div className="flex flex-wrap gap-2 mt-4">
<SecondaryCTA href={SHOP.mapsUrl} variant="dark" external>
{COPY.location.ctaMaps}
</SecondaryCTA>
<PrimaryCTA href={`tel:${SHOP.phone}`}>{COPY.location.ctaCall}</PrimaryCTA>
</div>
</div>
</Reveal>
</div>
</section>
);
}
Product detail pages — app/products/[slug]/page.tsx
Async server component. generateStaticParams over CATEGORIES. generateMetadata per category. notFound() on unknown slug.
import { notFound } from 'next/navigation';
import type { Metadata } from 'next';
import { CATEGORIES, getCategoryBySlug } from '@/data/categories';
import { getProductsByCategory } from '@/data/products';
import { Nav } from '@/components/Nav';
import { Footer } from '@/components/Footer';
import { CategoryHero } from '@/components/product/CategoryHero';
import { FilterChips } from '@/components/product/FilterChips';
import { EmptyState } from '@/components/product/EmptyState';
type Params = { slug: string };
export function generateStaticParams(): Params[] {
return CATEGORIES.map((c) => ({ slug: c.slug }));
}
export async function generateMetadata({ params }: { params: Promise<Params> }): Promise<Metadata> {
const { slug } = await params;
const category = getCategoryBySlug(slug);
if (!category) return { title: 'Category not found · <BUSINESS_NAME>' };
return {
title: `${category.name} · <BUSINESS_NAME>`,
description: category.tagline,
};
}
export default async function CategoryPage({ params }: { params: Promise<Params> }) {
const { slug } = await params;
const category = getCategoryBySlug(slug);
if (!category) notFound();
const products = getProductsByCategory(category.slug);
return (
<>
<Nav />
<main id="main">
<CategoryHero category={category} />
<FilterChips products={products} />
<EmptyState />
</main>
<Footer />
</>
);
}
app/products/[slug]/not-found.tsx:
import Link from 'next/link';
import { Nav } from '@/components/Nav';
import { Footer } from '@/components/Footer';
import { PrimaryCTA } from '@/components/primitives/PrimaryCTA';
export default function NotFound() {
return (
<>
<Nav />
<main id="main" className="px-5 py-20 max-w-3xl mx-auto text-center">
<h1 className="font-[family-name:var(--font-display)] uppercase tracking-wide text-4xl">Category not found</h1>
<p className="text-sm text-[var(--color-ink-muted)] mt-2">That product line doesn't exist (yet).</p>
…(truncated)