WDB-0001 · Noor El-Amin (claude-sonnet-4-6) (high)```tsx App.tsx
import React, { useState, useRef } from "react";
import { motion, useInView } from "framer-motion";
import {
Coffee, MapPin, Mail, Package,
Repeat, BookOpen, Send, Clock, Truck,
} from "lucide-react";
/* ── Animation config ──────────────────────────────────────── */
const SPRING = { type: "spring" as const, stiffness: 120, damping: 14 };
const SOFT = { type: "spring" as const, stiffness: 80, damping: 18 };
/* ── Data ──────────────────────────────────────────────────── */
const COFFEES = [
{ id: "cudgegong", name: "Cudgegong House Blend", region: "Central & South America", process: "Blend", notes: ["Milk chocolate","Brown sugar","Roasted almond"], p250: 19, p1kg: 60 },
{ id: "ethiopia-guji", name: "Ethiopia Guji", region: "Guji Zone, Ethiopia", process: "Washed", notes: ["Jasmine","Stone fruit","Black tea"], p250: 24, p1kg: 78 },
{ id: "colombia-huila", name: "Colombia Huila", region: "Huila, Colombia", process: "Washed", notes: ["Red apple","Caramel","Milk chocolate"], p250: 21, p1kg: 68 },
{ id: "guatemala-antigua", name: "Guatemala Antigua", region: "Antigua, Guatemala", process: "Washed", notes: ["Cocoa","Toasted almond","Orange peel"], p250: 22, p1kg: 72 },
{ id: "brazil-cerrado", name: "Brazil Cerrado", region: "Cerrado, Brazil", process: "Natural", notes: ["Dark chocolate","Hazelnut","Dried fig"], p250: 20, p1kg: 64 },
{ id: "sugarcane-decaf", name: "Sugarcane Decaf (Colombia)", region: "Colombia", process: "Sugarcane Decaf", notes: ["Cocoa","Malt","Dried plum"], p250: 22, p1kg: 72 },
];
const STOCKISTS = [
{ name: "Ironbark Espresso", location: "Orange" },
{ name: "The Lane Cafe", location: "Bathurst" },
{ name: "Riverstone Coffee House", location: "Dubbo" },
{ name: "Small Grind", location: "Newtown, Sydney" },
{ name: "Meridian Coffee", location: "Surry Hills, Sydney" },
];
const CARD_RADII = ["22px 18px 24px 16px","18px 24px 16px 22px","24px 16px 22px 18px",
"16px 22px 18px 24px","20px 24px 18px 22px","22px 16px 24px 20px"];
const BEAN_COLOUR = ["#C9795A","#D8A63F","#8B9A6F","#C9795A","#D8A63F","#8B9A6F"];
/* ── Scroll-reveal utility ─────────────────────────────────── */
function FadeUp({ children, delay = 0, className = "" }: {
children: React.ReactNode; delay?: number; className?: string;
}) {
const ref = useRef<HTMLDivElement>(null);
const inView = useInView(ref, { once: true, amount: 0.15 });
return (
<motion.div ref={ref} className={className}
initial={{ opacity: 0, y: 18 }}
animate={inView ? { opacity: 1, y: 0 } : {}}
transition={{ ...SOFT, delay }}>
{children}
</motion.div>
);
}
/* ── Section dividers ──────────────────────────────────────── */
function Divider({ from, to, variant = "wave" }: {
from: string; to: string; variant?: "wave" | "torn";
}) {
const d = variant === "wave"
? "M0 27 C200 9 360 46 540 27 C700 9 860 46 1060 27 C1220 9 1360 45 1440 27 L1440 54 L0 54 Z"
: "M0 18 C90 38 190 10 300 30 C420 50 530 16 650 36 C780 56 900 20 1020 40 C1140 60 1260 24 1360 42 C1410 50 1438 32 1440 22 L1440 54 L0 54 Z";
return (
<div aria-hidden="true" style={{ background: from, lineHeight: 0, overflow: "hidden" }}>
<svg viewBox="0 0 1440 54" preserveAspectRatio="none"
style={{ display: "block", width: "100%", height: 54 }}>
<path d={d} fill={to} />
</svg>
</div>
);
}
/* ── Illustrations ─────────────────────────────────────────── */
/* Hand-drawn 1962 Probat drum roaster - slightly uneven lines */
function ProBatRoaster() {
return (
<svg viewBox="0 0 360 300" aria-hidden="true"
style={{ width: "100%", maxWidth: 400, display: "block" }}>
<ellipse cx="186" cy="170" rx="155" ry="118" fill="#D8A63F" opacity="0.08" />
{/* Steam - uneven weight */}
<path d="M152 88 C148 70 156 56 152 40" stroke="#C9795A" strokeWidth="3.5" fill="none" strokeLinecap="round" />
<path d="M174 82 C170 64 178 50 174 32" stroke="#C9795A" strokeWidth="2.5" fill="none" strokeLinecap="round" />
<path d="M198 86 C194 68 202 54 198 38" stroke="#C9795A" strokeWidth="3" fill="none" strokeLinecap="round" />
{/* Hopper */}
<path d="M154 116 L164 86 L204 86 L214 116 Z" fill="#D8A63F" opacity="0.72" />
<rect x="160" y="83" width="48" height="8" rx="3" fill="#D8A63F" opacity="0.85" />
{/* Drum */}
<ellipse cx="186" cy="124" rx="102" ry="22" fill="#D8A63F" opacity="0.72" />
<rect x="84" y="124" width="204" height="96" fill="#C9795A" opacity="0.58" />
<ellipse cx="186" cy="220" rx="102" ry="22" fill="#C9795A" opacity="0.72" />
{/* Front face */}
<ellipse cx="288" cy="172" rx="54" ry="54" fill="#F4EDE0" stroke="#C9795A" strokeWidth="3.5" />
<ellipse cx="288" cy="172" rx="42" ry="42" fill="none" stroke="#C9795A" strokeWidth="1.5" opacity="0.42" />
<circle cx="288" cy="165" r="16" fill="#D8A63F" opacity="0.28" stroke="#C9795A" strokeWidth="2.5" />
<circle cx="288" cy="165" r="7" fill="#D8A63F" opacity="0.65" />
{([[265,150],[310,150],[265,191],[310,191]] as [number,number][]).map(([x,y]) => (
<circle key={`${x}${y}`} cx={x} cy={y} r="4.5" fill="#C9795A" opacity="0.68" />
))}
{/* Gauge */}
<circle cx="166" cy="170" r="19" fill="#F4EDE0" stroke="#6B6154" strokeWidth="2" />
<path d="M166 153 L166 170 L177 176" stroke="#C9795A" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
<circle cx="166" cy="170" r="3.5" fill="#C9795A" />
{/* Exhaust pipe */}
<rect x="79" y="130" width="14" height="60" rx="5" fill="#8B9A6F" opacity="0.58" />
<ellipse cx="86" cy="130" rx="7" ry="4" fill="#8B9A6F" opacity="0.72" />
{/* Drum ribs */}
{[144,170,196].map(y => (
<line key={y} x1="84" y1={y} x2="234" y2={y} stroke="#2E2A24" strokeWidth="1" opacity="0.09" />
))}
{/* Legs */}
{[99,171,243].map(x => (
<g key={x}>
<rect x={x} y={238} width="14" height="48" rx="4" fill="#6B6154" opacity="0.70" />
<rect x={x-4} y={282} width="22" height="6" rx="2" fill="#6B6154" opacity="0.46" />
</g>
))}
{/* Cooling tray */}
<ellipse cx="91" cy="232" rx="60" ry="17" fill="#EADFCB" stroke="#C9795A" strokeWidth="2.5" />
{[69,79,89,99,109].map(x => (
<line key={x} x1={x} y1="222" x2={x} y2="241" stroke="#C9795A" strokeWidth="1" opacity="0.3" />
))}
{/* Scattered beans */}
<g transform="rotate(22 45 202)">
<ellipse cx="45" cy="202" rx="8" ry="13" fill="#C9795A" opacity="0.42" />
<line x1="45" y1="191" x2="45" y2="213" stroke="#2E2A24" strokeWidth="1.5" opacity="0.25" />
</g>
<g transform="rotate(-16 326 248)">
<ellipse cx="326" cy="248" rx="7" ry="11" fill="#D8A63F" opacity="0.42" />
<line x1="326" y1="238" x2="326" y2="258" stroke="#2E2A24" strokeWidth="1.5" opacity="0.21" />
</g>
</svg>
);
}
/* Two rivers converging to a coffee bean */
function RiverMotif() {
return (
<svg viewBox="0 0 220 178" aria-hidden="true" style={{ width: 220, display: "block" }}>
<ellipse cx="110" cy="148" rx="65" ry="28" fill="#D8A63F" opacity="0.1" />
<path d="M14 18 C32 36 52 54 66 74 C80 92 92 110 110 140"
stroke="#D8A63F" strokeWidth="5" fill="none" strokeLinecap="round" opacity="0.56" />
<path d="M22 13 C40 31 60 49 73 70 C86 90 98 108 110 140"
stroke="#D8A63F" strokeWidth="2" fill="none" strokeLinecap="round" opacity="0.26" />
<path d="M206 18 C188 36 168 54 154 74 C140 92 128 110 110 140"
stroke="#8B9A6F" strokeWidth="5" fill="none" strokeLinecap="round" opacity="0.56" />
<path d="M198 13 C180 31 160 49 147 70 C134 90 122 108 110 140"
stroke="#8B9A6F" strokeWidth="2" fill="none" strokeLinecap="round" opacity="0.26" />
<ellipse cx="110" cy="148" rx="12" ry="18" fill="#C9795A" opacity="0.68" />
<path d="M110 132 C108 138 109 144 110 148 C111 153 110 158 110 164"
stroke="#F4EDE0" strokeWidth="1.8" fill="none" strokeLinecap="round" />
<path d="M89 134 C94 130 98 135 103 132" stroke="#D8A63F" strokeWidth="1.5" fill="none" strokeLinecap="round" opacity="0.5" />
<path d="M117 132 C122 128 126 133 131 130" stroke="#8B9A6F" strokeWidth="1.5" fill="none" strokeLinecap="round" opacity="0.5" />
</svg>
);
}
/* Coffee bag with subscription refresh arrow */
function CoffeeBagArt() {
return (
<svg viewBox="0 0 220 270" aria-hidden="true" style={{ width: 210, display: "block", margin: "0 auto" }}>
<ellipse cx="110" cy="148" rx="102" ry="100" fill="#C9795A" opacity="0.07" />
<rect x="40" y="90" width="140" height="158" rx="10" fill="#C9795A" opacity="0.62" />
<path d="M40 90 Q40 66 62 63 L158 63 Q180 66 180 90 Z" fill="#D8A63F" opacity="0.76" />
<path d="M65 90 C67 98 67 108 65 118" stroke="#F4EDE0" strokeWidth="1.5" opacity="0.25" strokeLinecap="round" />
<path d="M155 90 C153 98 153 108 155 118" stroke="#F4EDE0" strokeWidth="1.5" opacity="0.25" strokeLinecap="round" />
<rect x="54" y="108" width="112" height="90" rx="8" fill="#F4EDE0" opacity="0.92" />
<line x1="70" y1="125" x2="150" y2="125" stroke="#C9795A" strokeWidth="3.5" strokeLinecap="round" opacity="0.34" />
<line x1="78" y1="136" x2="142" y2="136" stroke="#6B6154" strokeWidth="2" strokeLinecap="round" opacity="0.24" />
<ellipse cx="110" cy="168" rx="13" ry="20" fill="#C9795A" opacity="0.2" />
<path d="M110 149 C108 155 109 162 110 168 C111 174 110 180 110 187"
stroke="#C9795A" strokeWidth="1.8" fill="none" strokeLinecap="round" opacity="0.36" />
<rect x="40" y="238" width="140" height="12" rx="4" fill="#C9795A" opacity="0.8" />
<rect x="56" y="255" width="52" height="22" rx="8" fill="#D8A63F" opacity="0.8" />
<rect x="114" y="255" width="48" height="22" rx="8" fill="#8B9A6F" opacity="0.65" />
{/* Subscription refresh arrow */}
<path d="M186 64 A32 32 0 1 1 174 32" stroke="#8B9A6F" strokeWidth="3.5" fill="none" strokeLinecap="round" />
<path d="M171 30 L176 33 L173 39" stroke="#8B9A6F" strokeWidth="3.5" fill="none" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
/* Hand-drawn gooseneck kettle and V60 pour-over */
function PourOverArt() {
return (
<svg viewBox="0 0 200 240" aria-hidden="true" style={{ width: 185, display: "block", margin: "0 auto" }}>
<ellipse cx="100" cy="138" rx="82" ry="82" fill="#8B9A6F" opacity="0.08" />
<rect x="12" y="56" width="46" height="32" rx="6" fill="#D8A63F" opacity="0.54" />
<path d="M58 65 L58 88 L45 88" stroke="#D8A63F" strokeWidth="3" fill="none" strokeLinecap="round" strokeLinejoin="round" />
<path d="M45 88 Q35 90 32 98" stroke="#D8A63F" strokeWidth="3" fill="none" strokeLinecap="round" />
<path d="M18 62 C12 62 8 66 8 72" stroke="#D8A63F" strokeWidth="3" fill="none" strokeLinecap="round" />
<path d="M32 98 C33 107 34 114 34 120"
stroke="#8B9A6F" strokeWidth="2" fill="none" strokeLinecap="round" opacity="0.5" strokeDasharray="3 4" />
<path d="M66 118 L100 186 L134 118 Z" fill="#C9795A" opacity="0.5" />
<path d="M66 118 L100 186 L134 118" stroke="#C9795A" strokeWidth="2.5" fill="none" strokeLinejoin="round" />
<path d="M100 120 L100 184" stroke="#F4EDE0" strokeWidth="1" opacity="0.35" />
<path d="M80 124 C85 130 93 132 100 186" stroke="#F4EDE0" strokeWidth="1" fill="none" opacity="0.35" />
<path d="M120 124 C115 130 107 132 100 186" stroke="#F4EDE0" strokeWidth="1" fill="none" opacity="0.35" />
<line x1="62" y1="118" x2="138" y2="118" stroke="#C9795A" strokeWidth="3" strokeLinecap="round" />
<path d="M100 188 Q98 194 100 198 Q102 194 100 188 Z" fill="#6B6154" opacity="0.6" />
<path d="M72 200 L80 236 L120 236 L128 200 Z"
fill="#EADFCB" stroke="#6B6154" strokeWidth="2" strokeLinejoin="round" />
<path d="M76 218 L80 236 L120 236 L124 218 Z" fill="#C9795A" opacity="0.16" />
<path d="M128 206 C142 206 144 222 128 222" stroke="#6B6154" strokeWidth="2.5" fill="none" strokeLinecap="round" />
{[88,100,112].map(x => (
<path key={x} d={`M${x} 195 C${x-2} 187 ${x+2} 180 ${x} 172`}
stroke="#D8A63F" strokeWidth="2" fill="none" strokeLinecap="round" opacity="0.42" />
))}
</svg>
);
}
/* ── Navigation ────────────────────────────────────────────── */
function Nav() {
return (
<nav className="nav">
<a href="#top" className="nav-logo" aria-label="Two Rivers Coffee Roasters home">
<span className="nav-logo-name">Two Rivers</span>
<span className="nav-logo-sub">Coffee Roasters · Mudgee NSW</span>
</a>
<ul className="nav-links" role="list">
{([ ["#range","Range"],["#story","Story"],["#subscribe","Subscribe"],
["#wholesale","Wholesale"],["#contact","Contact"] ] as [string,string][])
.map(([href, label]) => <li key={href}><a href={href}>{label}</a></li>)}
</ul>
<motion.a href="#range" className="nav-cta" whileHover={{ y: -2 }} transition={SPRING}>
Shop
</motion.a>
</nav>
);
}
/* ── Hero ──────────────────────────────────────────────────── */
function Hero() {
return (
<section className="hero" id="top">
<svg className="hero-blob" viewBox="0 0 500 400" aria-hidden="true">
<path d="M250 50 C352 18 446 78 462 182 C478 284 416 364 314 384 C216 402 116 360 76 258 C36 158 78 80 182 50 C212 40 232 56 250 50 Z"
fill="#D8A63F" opacity="0.08" />
</svg>
<div className="hero-inner">
<motion.div className="hero-copy"
initial={{ opacity: 0, y: 22 }} animate={{ opacity: 1, y: 0 }}
transition={{ ...SPRING, delay: 0.08 }}>
<p className="eyebrow">
<Coffee size={14} />
Mudgee, NSW · small batch since 2016
</p>
<h1 className="hero-h1">
Small batch,<br />fresh from<br />the drum.
</h1>
<p className="hero-lede">
Dan and Priya roast through the week on a restored 1962 Probat, and ship
within a few days. What arrives on your bench is genuinely fresh.
</p>
<div className="hero-actions">
<motion.a href="#range" className="btn btn-clay" whileHover={{ y: -3 }} transition={SPRING}>
Shop the range
</motion.a>
<motion.a href="#subscribe" className="btn btn-ghost" whileHover={{ y: -3 }} transition={SPRING}>
Monthly subscription
</motion.a>
</div>
<span className="hand-note"
style={{ display: "inline-block", marginTop: 18, transform: "rotate(-2.5deg)" }}>
ships within days of roasting
</span>
</motion.div>
<motion.div className="hero-art"
initial={{ opacity: 0, x: 28 }} animate={{ opacity: 1, x: 0 }}
transition={{ ...SPRING, delay: 0.22 }} aria-hidden="true">
<ProBatRoaster />
</motion.div>
</div>
<Divider from="var(--paper)" to="var(--oat)" variant="wave" />
</section>
);
}
/* ── Story ─────────────────────────────────────────────────── */
function Story() {
return (
<section className="section oat-bg" id="story">
<div className="container">
<div className="story-inner">
<FadeUp>
<p className="eyebrow">Our story</p>
<h2 className="section-h2">Started in a shed,<br />still feels that way.</h2>
<p className="body-copy">
Dan and Priya Halloran started Two Rivers in 2016, roasting for friends
out of a shed before the first cafe order turned it into a business.
Everything still comes off one machine: a 1962 Probat drum roaster
they stripped back and rebuilt by hand.
</p>
<p className="body-copy" style={{ marginTop: 16 }}>
They roast in small batches through the week and ship within a few days
of roasting, so what lands on your bench is fresh. Green beans are
bought a few sacks at a time and the roast profiles are dialled in
by taste, not by volume.
</p>
</FadeUp>
<FadeUp delay={0.18} className="story-art">
<RiverMotif />
<p className="story-caption">The two rivers meet just near the roastery.</p>
</FadeUp>
</div>
</div>
<Divider from="var(--oat)" to="var(--paper)" variant="torn" />
</section>
);
}
/* ── Range ─────────────────────────────────────────────────── */
function CoffeeCard({ c, idx }: { c: (typeof COFFEES)[number]; idx: number }) {
const [size, setSize] = useState<"250g" | "1kg">("250g");
return (
<FadeUp delay={idx * 0.07}>
<motion.article className="coffee-card" style={{ borderRadius: CARD_RADII[idx] }}
whileHover={{ y: -4 }} transition={SPRING}>
<div className="card-top">
<svg width="22" height="34" viewBox="0 0 22 34" aria-hidden="true">
<ellipse cx="11" cy="17" rx="9.5" ry="15" fill={BEAN_COLOUR[idx]} opacity="0.72" />
<path d="M11 3 C9.5 8 10.5 13 11 17 C11.5 21 11 26 11 31"
stroke="#F4EDE0" strokeWidth="1.6" fill="none" strokeLinecap="round" />
</svg>
<span className="process-tag">{c.process}</span>
</div>
<h3 className="card-h3">{c.name}</h3>
<p className="card-region">{c.region}</p>
<ul className="card-notes">
{c.notes.map(n => <li key={n}>{n}</li>)}
</ul>
<div className="card-bottom">
<div className="size-toggle">
<button type="button" className={`size-btn${size === "250g" ? " active" : ""}`}
onClick={() => setSize("250g")} aria-pressed={size === "250g"}>
250g · ${c.p250}
</button>
<button type="button" className={`size-btn${size === "1kg" ? " active" : ""}`}
onClick={() => setSize("1kg")} aria-pressed={size === "1kg"}>
1 kg · ${c.p1kg}
</button>
</div>
<motion.button type="button" className="btn btn-ochre btn-sm"
whileHover={{ y: -2 }} transition={SPRING}>
Add to bag · ${size === "250g" ? c.p250 : c.p1kg}
</motion.button>
</div>
</motion.article>
</FadeUp>
);
}
function Range() {
return (
<section className="section" id="range">
<div className="container">
<FadeUp className="section-header">
<p className="eyebrow"><Package size={14} /> The range</p>
<h2 className="section-h2">Six coffees,<br />roasted this week.</h2>
<p className="body-copy section-intro">
All available in 250g and 1 kg. Each lot is small - when a coffee
sells out, we wait for the next good sacks to arrive.
</p>
</FadeUp>
<div className="coffee-grid">
{COFFEES.map((c, i) => <CoffeeCard key={c.id} c={c} idx={i} />)}
</div>
</div>
<Divider from="var(--paper)" to="var(--oat)" variant="wave" />
</section>
);
}
/* ── Subscription ──────────────────────────────────────────── */
function Subscription() {
return (
<section className="section oat-bg" id="subscribe">
<div className="container">
<div className="two-col">
<FadeUp>
<p className="eyebrow"><Repeat size={14} /> Subscription</p>
<h2 className="section-h2">Get it fresh,<br />every time.</h2>
<p className="body-copy">
Pick a coffee, a size, and how often you want it, and we roast it
fresh and post it out.
</p>
<ul className="feature-list">
<li className="feature-item">
<span className="feature-icon"><Package size={16} /></span>
<span>Choose 250g or 1 kg</span>
</li>
<li className="feature-item">
<span className="feature-icon"><Clock size={16} /></span>
<span>Weekly, fortnightly, or monthly delivery</span>
</li>
<li className="feature-item">
<span className="feature-icon"><Truck size={16} /></span>
<span>Free shipping on subscription orders over $60</span>
</li>
<li className="feature-item">
<span className="feature-icon"><Repeat size={16} /></span>
<span>Pause, skip, or cancel any time from your account</span>
</li>
</ul>
<div style={{ display: "flex", alignItems: "center", gap: 16, marginTop: 32, flexWrap: "wrap" }}>
<motion.a href="#range" className="btn btn-clay" whileHover={{ y: -3 }} transition={SPRING}>
Start a subscription
</motion.a>
<span style={{
fontFamily: "Work Sans, sans-serif", fontSize: "0.875rem",
color: "var(--clay)", fontStyle: "italic", opacity: 0.82,
}}>
no lock-in
</span>
</div>
</FadeUp>
<FadeUp delay={0.2} className="center-art">
<CoffeeBagArt />
</FadeUp>
</div>
</div>
<Divider from="var(--oat)" to="var(--paper)" variant="torn" />
</section>
);
}
/* ── Wholesale ─────────────────────────────────────────────── */
function Wholesale() {
const [sent, setSent] = useState(false);
const [form, setForm] = useState({ cafe:"", contact:"", email:"", suburb:"", volume:"" });
const upd = (k: keyof typeof form) =>
(e: { target: { value: string } }) =>
setForm(f => ({ ...f, [k]: e.target.value }));
return (
<section className="section" id="wholesale">
<div className="container">
<div className="two-col">
<FadeUp>
<p className="eyebrow">Wholesale</p>
<h2 className="section-h2">Supplying cafes across<br />the Central West<br />and Sydney.</h2>
<p className="body-copy">
If you run a cafe and want to talk about a wholesale account, send us a