Overview#
Roast is a shop for First Crack, a fictional specialty coffee roaster in Baltimore. It sells ten coffees in three bag sizes and six grinds, builds coffee subscriptions with a price per cup and the next delivery dates, and has a cart drawer, a three-step checkout, brew guides with a recipe scaler, a wholesale page with a volume estimator, a sourcing page and a contact page with an FAQ. This is the Next.js version (Next.js 16 App Router, React 19, TypeScript, Tailwind CSS v4), version 1.0.0, ported page for page from the HTML template. It exports to static files. Live demo: https://freeframe.dev/templates/roast
The store is a front end only. Nothing is charged, sent or stored on a server until you connect your own services (see Forms).
Quick start#
Requirements: Node.js 24 or newer and pnpm (npm works too).
pnpm install
pnpm dev # http://localhost:3000Other scripts:
| Script | What it does |
|---|---|
pnpm build |
Static export to out/ |
pnpm start |
Serve out/ (npx serve out) |
pnpm lint |
ESLint |
pnpm typecheck |
next typegen and TypeScript 7 tsc --noEmit |
pnpm build:demo |
Build for freeframe.dev under /demos/nextjs/roast into demo/ |
Pages#
| Route | File | Purpose and features |
|---|---|---|
/ |
src/app/page.tsx |
Hero with two bag illustrations, tasting-note ticker, "This week's roasts" (coffees with featured), roast levels, subscription steps, brew guide links, sourcing figures, reviews |
/shop |
src/app/shop/page.tsx, ShopView.tsx |
Filters for roast, origin and process, sort, live count, removable filter chips, empty state, URL parameters |
/product?id=kochere |
src/app/product/page.tsx, ProductView.tsx |
Gallery (bag and beans), sizes with prices, grind, quantity, add to cart, tasting profile, origin details, brew recipe, related coffees |
/subscribe |
src/app/subscribe/page.tsx, PlanBuilder.tsx, StartForm.tsx |
Subscription builder with live summary and delivery dates, sign-up form |
/checkout |
src/app/checkout/page.tsx, CheckoutView.tsx |
Details, delivery and payment steps, order summary, confirmation, empty state |
/brew |
src/app/brew/page.tsx, BrewGuides.tsx |
Six brewing methods in tabs (/brew#aeropress) and a recipe scaler |
/wholesale |
src/app/wholesale/page.tsx, WholesalePricing.tsx, WholesaleForm.tsx |
Benefits, price tiers, volume estimator, enquiry form |
/about |
src/app/about/page.tsx |
Story, how we buy, prices paid per coffee, producers |
/contact |
src/app/contact/page.tsx, ContactForm.tsx |
Contact form, roastery hours, FAQ (/contact#faq) |
| 404 | src/app/not-found.tsx |
Page not found (exported as 404.html) |
Project structure#
src/
app/
layout.tsx <html>, skip link, announcement bar, header, footer, cart drawer, toast
globals.css Tailwind import, @theme tokens, component classes, reveal and back-to-top styles
fonts.ts Onest and Unbounded with next/font/local
page.tsx home page; one folder per other route (page.tsx + its client components)
not-found.tsx 404 page
icon.svg favicon
opengraph-image.png social sharing image (1200×630)
components/
SiteHeader.tsx logo, menu (NAV), cart button, mobile menu
SiteFooter.tsx footer links, newsletter form, copyright year
CoffeeCard.tsx product card used on home, shop and product pages
Art.tsx, ArtLink.tsx SVG artwork in a div/span, or as a link
RoastMeter.tsx three roast-level dots
RevealObserver.tsx scroll reveal
BackToTop.tsx back-to-top button
Logo.tsx, Year.tsx
cart/ CartUIProvider (drawer, toast, addToCart), CartDrawer, ShippingProgress, Toast
forms/ ValidatedForm, NewsletterForm
data/
coffees.ts coffees, sizes, grinds, shipping, subscription styles, brew methods, wholesale tiers
lib/
art.ts draws the bag and bean SVGs
cart.ts cart store (localStorage + useSyncExternalStore), totals
plan.ts subscription plan: validation, prices, delivery dates
validation.ts form validation rules and error messages
storage.ts safe localStorage read/write
format.ts money format, plurals, URL parameters
metadata.ts per-page title, description and Open Graph
fonts/ woff2 files and their OFL licenses
next.config.ts static export, basePath from DEMO_BASE_PATH
template.json gallery metadata (not used by the site build)Customising#
Brand and colours#
The tokens are in the @theme block of src/app/globals.css:
| Token | Use |
|---|---|
--color-butter |
page background |
--color-cream |
cards and fields |
--color-oat, --color-line |
soft panels, hairlines |
--color-sun |
yellow blocks and stickers |
--color-cobalt, --color-cobalt-dark, --color-sky |
brand blue, hover, tint |
--color-navy |
text and the 2px outlines |
--color-muted |
secondary text |
--color-tomato, --color-tomato-dark |
decoration; tomato text and errors |
--color-leaf |
success |
Every utility (bg-cobalt, text-navy, border-navy...) follows the tokens. To make the brand green:
@theme {
--color-cobalt: #1f6b4a;
--color-cobalt-dark: #164d35;
--color-sky: #d6efe2;
}The "sticker" look comes from the component classes in the same file: .card (2px navy border and a 5px
offset shadow), .btn-primary, .btn-sun, .btn-outline, .btn-light, .sticker, .tag, .option and
.pill (radio cards), .field. The theme colour of the browser bar is themeColor in
src/app/layout.tsx.
Typography#
Headings use Unbounded (font-display), text uses Onest (font-sans). src/app/fonts.ts loads them with
next/font/local from src/fonts/: the same files Google Fonts serves, one per unicode range, all under the
family names "Onest" and "Unbounded", which --font-sans and --font-display in globals.css use. The
bag labels in src/lib/art.ts name the same families.
To use other fonts, put their woff2 files in src/fonts/, change the src, weight and family name in
fonts.ts (or switch to next/font/google), and update --font-sans, --font-display and the two
font-family attributes in art.ts.
Content#
Page copy is in each route's page.tsx and its components. The shop's data is in src/data/coffees.ts:
| Export | What it holds |
|---|---|
COFFEES |
every coffee: id, name, origin, region, process, roast, 250 g price, rating, reviews, badge, featured, notes, profile, producer, altitude, variety, harvest, bag colour, brew method, blurb |
ORIGINS, ROASTS, PROCESSES |
filter keys and labels |
SIZES |
bag sizes; price = 250 g price × factor, rounded to whole dollars |
GRINDS |
grind options |
FREE_SHIPPING, SHIPPING, MAX_QTY |
shipping threshold and options, most bags per cart line |
STYLES, SUB_DISCOUNT, DOSE, FREQUENCIES, MAX_BAGS, ROAST_DAY, DELIVERY_LAG |
subscriptions |
METHODS |
brew recipes (ratio, cup size, grind, temperature, time) |
WHOLESALE_DOSE, TIERS |
wholesale estimator and price table |
Add a coffee by adding an object to COFFEES:
{
id: "santa-elena", name: "Santa Elena", origin: "guatemala", region: "Acatenango",
process: "honey", roast: "medium", price: 20, rating: 4.8, reviews: 0,
notes: ["Plum", "Brown sugar", "Cocoa"], profile: { acidity: 3, sweetness: 4, body: 3 },
producer: "Finca Santa Elena", altitude: "1,700 m", variety: "Bourbon", harvest: "March 2026",
bag: "#c2562e", brew: "v60",
blurb: "Stone fruit and brown sugar from volcanic soil.",
},It appears in the shop, its filter counts and related coffees, and at /product?id=santa-elena. Add
featured: 5 to show it on the home page. A new origin, roast or process needs a key in ORIGINS,
ROASTS or PROCESSES (the shop sidebar is built from them).
Copy that repeats a setting is plain text: "Free shipping over $45" in the announcement bar
(layout.tsx), the home hero and the shop and FAQ notes, the wholesale tier table caption, the
"Based on 18 g" line and the prices paid on /about. Update it when you change the data.
Images and artwork#
There are no photos. src/lib/art.ts draws every product image as SVG from the coffee's data: art(coffee, 0, id) is the bag (colour bag, a roast mark, origin and process on the label, roast dots), art(coffee, 1, id) the roasted beans. The images are 400×500 (4:5); keep that ratio if you use photos: replace the
Art and ArtLink uses (and the inner HTML of CoffeeCard and the product gallery) with next/image
or <img>. Illustrations on /about are inline SVG in the page.
The favicon is src/app/icon.svg and the social image src/app/opengraph-image.png (1200×630). The
HTML template's assets/img/og-image.svg is its editable source.
Navigation, header and footer#
The menu is NAV in src/components/SiteHeader.tsx: href, label and the paths that mark it as the
current page (aria-current="page"). The mobile menu uses the same list plus Contact. Footer columns are
COFFEE_LINKS and HELP_LINKS in src/components/SiteFooter.tsx. The announcement bar and skip link are in
src/app/layout.tsx.
Features#
Cart and cart drawer#
src/lib/cart.ts stores the cart (see Saved data) and exposes useCart(),
cart.add/setQty/clear, cartLine() and cartTotals(). CartUIProvider adds addToCart(id, size, grind, qty), which also bumps the header badge and shows the toast. The header's cart button (a link to
/checkout without JavaScript) opens CartDrawer: lines with quantity steppers (1 to MAX_QTY), remove,
free-shipping progress (ShippingProgress, a role="progressbar") and the subtotal.
Accessibility: the drawer is a modal dialog; focus moves to its close button, Tab is trapped inside, Esc or the overlay closes it and focus returns to the cart button. Steppers keep focus while the list updates. Reduced motion turns off the slide.
Shop filters#
ShopView.tsx filters COFFEES by roast, origin and process (any option within a group, every group
together), sorts (featured, price-asc, price-desc, roast, name) and shows the count in an
aria-live region. Active filters appear as chips; removing one moves focus to the next chip or the count.
The state is mirrored in the URL (/shop?roast=light,medium&origin=ethiopia&sort=price-asc), and links in
that form work from anywhere: names and values are case-insensitive, values may be comma-separated or
repeated, and unknown values are ignored. On phones the filters fold behind a "Filters" button with
aria-expanded.
Product page#
ProductFromURL reads ?id= (case-insensitive; an unknown or missing id shows the first coffee) and
ProductView renders it: the gallery buttons use aria-pressed, sizes are radio cards with prices from
unitPrice(), and the quantity input is clamped to 1–MAX_QTY when it loses focus. The page title is set
to the coffee's name. "Build a plan" links to /subscribe?style= with the closest subscription style.
Subscription builder#
PlanBuilder.tsx has five steps (style, bag size, bags per delivery, frequency, grind) and a sticky summary.
planPrice() in src/lib/plan.ts works out the price per bag (STYLES price × size factor × (1 −
SUB_DISCOUNT)), per delivery, per cup (DOSE grams per cup), a monthly estimate and the saving against
shop prices. deliveryDates() returns the next three deliveries: the first roast day (ROAST_DAY, Monday)
after today plus DELIVERY_LAG days, then every freq weeks. Dates are only worked out in the browser, so
the static HTML never shows a stale date. Each "Change" button scrolls to its step and focuses the current
choice. ?style=bright preselects a style. The plan is saved as you go; StartForm.tsx validates the
sign-up and shows a demo confirmation with the first delivery date.
Checkout#
CheckoutView.tsx shows the three steps one at a time (aria-current="step" on the progress list); each
step is validated before the next, and focus moves to the step's heading. Delivery options (SHIPPING)
update the summary; standard is free from FREE_SHIPPING. The payment step reviews the entered details with
Edit buttons, formats the card number in groups of four and the expiry as MM / YY. "Place order (demo)"
shows a made-up order number, clears the cart and moves focus to the confirmation. Connect a payment
provider (Stripe Checkout, for example) in submit().
Brew guides and recipe scaler#
BrewGuides.tsx implements the ARIA tabs pattern: arrow keys, Home and End move between tabs and only the
selected tab is in the Tab order. /brew#cold (any case) opens a tab; picking a tab updates the hash. The
scaler multiplies the open method's METHODS cup size by the number of cups and divides by its ratio
(stronger and lighter change the ratio by one; espresso ignores strength).
Wholesale estimator#
WholesalePricing.tsx turns drinks a day and days open into kilos a week (WHOLESALE_DOSE grams per drink),
picks the tier from TIERS, highlights its row and shows the weekly spend.
Back to top and scroll reveals#
BackToTop.tsx appears after about one screen, scrolls to the top (instantly with reduced motion), moves
focus to <main>, lifts itself above the toast and hides while it would cover an element marked
data-back-to-top-hide. RevealObserver.tsx fades in .reveal elements as they scroll into view, and
everything above them; with reduced motion everything is shown at once.
Forms#
| Form | Where | Validates |
|---|---|---|
| Newsletter | src/components/forms/NewsletterForm.tsx (footer) |
|
| Contact | src/app/contact/ContactForm.tsx |
name, email, topic, optional order number (FC-12345), message of 20+ characters |
| Wholesale | src/app/wholesale/WholesaleForm.tsx |
business, name, email, optional phone, type, volume, at least one interest |
| Subscription | src/app/subscribe/StartForm.tsx |
name, email, ZIP code, terms checkbox |
| Checkout | src/app/checkout/CheckoutView.tsx |
contact and address, ZIP, optional phone, card name, number, expiry, security code |
Rules come from each field's attributes (required, type, data-validate, minLength, data-label)
and are applied by src/lib/validation.ts; errors appear under the field, linked with
aria-describedby, and focus moves to the first problem. ValidatedForm swaps a valid form for a success
message. The demo forms don't send anything. To connect one, change its onValid, for example with
Formspree:
onValid={(form) => {
fetch("https://formspree.io/f/your-id", {
method: "POST",
body: new FormData(form),
headers: { Accept: "application/json" },
});
return <p>Thanks, we got your message.</p>;
}}Every field has a name, so FormData includes it. If you remove the static export (see below), you can
post to a Route Handler instead (src/app/api/contact/route.ts with export async function POST(request: Request)) or call a Server Action.
Saved data (localStorage)#
| Key | Shape | Validation |
|---|---|---|
firstcrack-cart |
[{ id, size, grind, qty }] |
normalizeCart() in src/lib/cart.ts keeps only known coffee ids (any case), sizes and grinds (others fall back to 250 g and whole bean), whole quantities from 1 to MAX_QTY, and merges duplicates. Corrupt data is replaced with the cleaned version. Prices are never stored. |
firstcrack-plan |
{ style, size, bags, freq, grind } |
normalizePlan() in src/lib/plan.ts falls back to DEFAULT_PLAN for anything unknown and limits bags to MAX_BAGS. |
The cart is shared across open tabs (storage events). To reset, run
localStorage.removeItem("firstcrack-cart") in the browser console, or clear the site's data. If storage
is blocked, the shop still works for the current page.
SEO and sharing#
Each route sets its title and description with pageMeta() from src/lib/metadata.ts (which also writes
Open Graph tags). The image is src/app/opengraph-image.png; set SITE_URL when building so its URL is
absolute (metadataBase in layout.tsx):
SITE_URL=https://shop.example.com pnpm buildThe 404 page is noindex. Add src/app/sitemap.ts and src/app/robots.ts if you want them; the product
page is one route with a query string, so list /product?id=… per coffee in the sitemap.
Accessibility#
Built in: a skip link to <main>, landmarks with labels, one h1 per page, aria-current in the menus,
visible focus outlines (:focus-visible), the modal cart drawer with a focus trap, ARIA tabs on /brew,
aria-pressed gallery buttons, aria-live counts and totals, labelled radio cards with visible focus,
progress bars with labels, and errors linked to their fields. Colour pairs meet WCAG AA. Reduced motion stops
the ticker, reveals and transitions.
When editing: keep one h1 per page, give every new input a label, keep text on --color-sun and
--color-butter dark (text-navy), and test new colours for contrast.
Building and deploying#
pnpm build # static site in out/
pnpm start # preview out/next.config.ts sets output: "export", images.unoptimized and basePath from DEMO_BASE_PATH (for a
sub-folder, e.g. DEMO_BASE_PATH=/shop pnpm build). Deploy out/ to any static host (Netlify, Cloudflare
Pages, GitHub Pages, S3) or import the repository into Vercel. demo/ is the freeframe.dev demo build and can
be deleted. To use server features (Route Handlers, Server Actions), remove output: "export" and deploy to
a Node.js host or Vercel.
TypeScript 7 setup#
@typescript/native provides TypeScript 7's tsc for pnpm typecheck; typescript is aliased to
@typescript/typescript6 because typescript-eslint and parts of the Next.js toolchain need the TypeScript 6
API. Replace both with typescript@7 once typescript-eslint supports it.
Troubleshooting#
- A product link shows the wrong coffee.
?id=must be anidfromCOFFEES; unknown ids show the first coffee on purpose. - Prices didn't change after editing the copy. Prices come from
src/data/coffees.ts(price,SIZES,STYLES,SUB_DISCOUNT,SHIPPING), not from the page text. - The cart lost an item after a data change. Removing or renaming a coffee
iddrops it from saved carts the next time they are read; that is the validation working. - No delivery dates in the HTML source. They depend on today's date, so they are rendered in the browser only.
- Fonts fall back to the system font. Check that
src/fonts/still has the woff2 files named insrc/app/fonts.ts. - The demo's links break under a sub-folder. Build with
DEMO_BASE_PATHset to that folder.
License#
MIT, see the repository license (included as LICENSE.txt in the download). The SVG artwork is part of the
template and covered by the same license. Onest and Unbounded are under the SIL Open Font License 1.1
(src/fonts/OFL-onest.txt, src/fonts/OFL-unbounded.txt).