Freeframe

Documentation

Roast

v1.0.3Updated Oct 17, 2026A bold shop for a specialty coffee roaster, with a subscription builder, brew guides and a working cart.

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

sh
pnpm install
pnpm dev          # http://localhost:3000

Other 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:

css
@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:

ts
{
  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.

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) email
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:

tsx
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):

sh
SITE_URL=https://shop.example.com pnpm build

The 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#

sh
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 an id from COFFEES; 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 id drops 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 in src/app/fonts.ts.
  • The demo's links break under a sub-folder. Build with DEMO_BASE_PATH set 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).

Search documentation

Search the guides and every template's documentation