Freeframe

Documentation

Roast

v1.0.0Updated Nov 12, 2026A bold shop for a specialty coffee roaster, with a subscription builder, brew guides and a working cart.

Overview#

Roast is a complete online shop for First Crack Coffee, a fictional specialty coffee roaster in an old trolley barn in Baltimore, Maryland: home, a filterable coffee shop, product pages with bag sizes, grinds, tasting notes and a brew recipe, a subscription builder with the price per cup and the next delivery dates, 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. Every coffee "photo" is SVG drawn from the coffee data, so there are no photos to replace. This is the Nuxt 4 + Vue 3 + TypeScript + Tailwind CSS v4 version, version 1.0.0. It generates to static files. See it live at freeframe.dev/templates/roast.

Quick start#

Requirements: Node.js 24 or newer and pnpm.

sh
pnpm install
pnpm dev          # http://localhost:3000
Command What it does
pnpm dev Development server at http://localhost:3000
pnpm generate Static site in .output/public/
pnpm build Node.js server build in .output/ (pages are still prerendered)
pnpm preview Serve the last build locally
pnpm lint ESLint (flat config from @nuxt/eslint)
pnpm typecheck nuxt prepare, then type-check with vue-tsc
pnpm build:demo Build for /demos/nuxt/roast into demo/ (only used for the freeframe.dev demo)

pnpm install runs nuxt prepare, which writes the generated types and ESLint config to .nuxt/.

Pages#

Route File What's on it
/ app/pages/index.vue Hero with two coffee bags, tasting-note ticker, this week's roasts (featured coffees), find your roast, subscription teaser, brew guides, sourcing numbers, reviews
/shop app/pages/shop.vue Roast, origin and process filters, sort, live count, filter chips and the coffee grid
/product?id=… app/pages/product.vue Bag and beans gallery, bag size, grind, quantity and add to cart, tasting profile, origin details, brew recipe, related coffees
/subscribe app/pages/subscribe.vue + app/components/SubscribeForm.vue Five-step plan builder, plan summary with prices and delivery dates, sign-up form (/subscribe#start)
/checkout app/pages/checkout.vue Details, delivery and payment steps next to the order summary, then the order confirmation
/brew app/pages/brew.vue Six brew guides in tabs (/brew#cold) and the recipe scaler
/wholesale app/pages/wholesale.vue + app/components/WholesaleForm.vue Account benefits, price tiers, volume estimator and inquiry form (/wholesale#enquiry)
/about app/pages/about.vue How we buy, prices paid this season, producers, visit the roastery
/contact app/pages/contact.vue + app/components/ContactForm.vue Contact form, roastery hours and FAQ (/contact#faq)
404 app/error.vue Not-found page, generated as 404.html

There is one product page: the coffee is read from ?id= in the browser once the page is mounted (the shop's filters from the address too). The generated HTML shows the first coffee, Kochere Lot 7, until then.

Project structure#

roast/
├─ app/
│  ├─ app.vue                  <NuxtLayout><NuxtPage /></NuxtLayout>, pages keyed by path and query
│  ├─ error.vue                404 page
│  ├─ router.options.ts        query strings written with encodeURIComponent
│  ├─ layouts/default.vue      skip link, shipping bar, header, footer, cart drawer, toast, back to top; reads the cart
│  ├─ pages/                   one file per page (index, shop, product, subscribe, checkout, brew, wholesale, about, contact)
│  ├─ components/
│  │  ├─ SiteHeader.vue        logo, menu, Contact, cart button with badge, phone menu
│  │  ├─ SiteFooter.vue        link columns, newsletter, copyright year
│  │  ├─ SiteLogo.vue          the First Crack mark and name
│  │  ├─ CartDrawer.vue        slide-out cart (dialog with focus trap)
│  │  ├─ CartToast.vue         "added to cart" toast
│  │  ├─ ShippingProgress.vue  free-shipping message and progress bar
│  │  ├─ CoffeeCard.vue        coffee card (home, shop, related coffees)
│  │  ├─ CoffeeArt.vue         a coffee's SVG bag or beans
│  │  ├─ RoastMeter.vue        three roast-level dots
│  │  ├─ NewsletterForm.vue, ContactForm.vue, SubscribeForm.vue, WholesaleForm.vue
│  │  ├─ FormSuccess.vue       the focused message a sent form turns into
│  │  └─ BackToTop.vue         back-to-top button
│  ├─ composables/
│  │  ├─ useCart.ts            the saved cart: lines, totals, shipping
│  │  ├─ useCartUI.ts          drawer, toast and add to cart
│  │  ├─ useFormValidation.ts  inline validation for every form
│  │  ├─ useCurrentPage.ts     aria-current for the menus
│  │  ├─ usePageSeo.ts         title, description, canonical and Open Graph tags
│  │  └─ useYear.ts            copyright year
│  ├─ data/
│  │  ├─ coffees.ts            the catalog and every store setting (sizes, grinds, shipping, subscriptions, brew methods, wholesale tiers)
│  │  ├─ brew.ts               the brew guides' text and steps
│  │  ├─ shop.ts               filter groups and sort options
│  │  ├─ nav.ts, footer.ts     menu and footer links
│  ├─ utils/
│  │  ├─ art.ts                bag and bean artwork
│  │  ├─ plan.ts               subscription plan: prices, delivery dates, validation
│  │  ├─ validation.ts         form rules and messages
│  │  ├─ format.ts             fmt(), plural(), makeRef(), urlParams()...
│  │  ├─ safeStorage.ts        localStorage with an in-memory fallback
│  │  └─ storage.ts            JSON read and write
│  ├─ plugins/
│  │  ├─ reveal.client.ts      scroll reveals
│  │  ├─ same-page.client.ts   links to the page already showing
│  │  ├─ keep-hash.client.ts   keeps the #hash as typed (brew guides)
│  │  └─ stable-build.server.ts  reproducible builds
│  └─ assets/
│     ├─ css/main.css          Tailwind entry: fonts, @theme tokens, components
│     └─ fonts/                Onest and Unbounded (woff2) and their OFL texts
├─ public/img/                 favicon.svg, og-image.png, og-image.svg
├─ nuxt.config.ts              base path, head, SITE_URL, prerendered routes
└─ package.json, tsconfig.json, eslint.config.mjs, pnpm-workspace.yaml

Customizing#

Brand and colors#

The design tokens are in the @theme block of app/assets/css/main.css. Every utility such as bg-cobalt or text-muted comes from them.

Token Default Used for
--color-butter #fff6df Page background
--color-cream #fffcf4 Cards and form fields
--color-oat #f6e9c8 Soft panels
--color-sun #ffcd4a Yellow bands, stickers, the back-to-top button
--color-cobalt / --color-cobalt-dark #2338b5 / #18268a Brand color, primary buttons, links
--color-sky #d9e0ff Selected options, highlight cards
--color-navy #141a3c Text, outlines and the offset "sticker" shadows
--color-muted #4b4f6e Secondary text
--color-tomato / --color-tomato-dark #e0522f / #b03417 Decoration / error text, stars, the cart badge
--color-leaf #23714a Savings text, the order confirmation check mark

To change the brand color, edit the tokens; the dev server picks the change up at once:

css
@theme {
  --color-cobalt: #0f6b5c;
  --color-cobalt-dark: #0a4d42;
}

The bag artwork and the logo use literal colors: the navy outline #141a3c in app/utils/art.ts, the logo in app/components/SiteLogo.vue and public/img/favicon.svg, and theme-color in nuxt.config.ts. Keep text contrast at 4.5:1 or more if you change the dark or muted colors.

The component classes in main.css give the template its look: .btn-primary, .btn-sun, .btn-outline, .btn-light, .card (outlined card with the offset shadow), .sticker, .tag, .eyebrow, .link, .field, .option and .pill (radio cards and pills that highlight when their input is checked) and .check.

Typography#

Unbounded for headings (font-display) and Onest for body text (font-sans), self-hosted from app/assets/fonts/ with @font-face rules at the top of main.css. They are the files Google Fonts serves, one per character range, so the browser only downloads the ones a page needs. To swap a font, put its woff2 files in app/assets/fonts/, change the @font-face rules and the names in @theme:

css
--font-sans: "Onest", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--font-display: "Unbounded", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;

The bag artwork writes the origin in Unbounded and the process in Onest (artInner() in app/utils/art.ts); change the font-family there too.

Content#

Page copy is in the page files. Everything the shop calculates comes from app/data/coffees.ts:

Export What it holds
COFFEES The catalog: one object per coffee (type Coffee)
ORIGINS, ROASTS, PROCESSES Filter keys and their labels
SIZES Bag sizes: id, label, grams and factor (price = 12 oz price × factor, rounded to whole dollars)
GRINDS Grind options
FREE_SHIPPING, SHIPPING Free-shipping threshold and the delivery methods with prices
MAX_QTY Largest quantity per cart line (20)
STYLES, SUB_DISCOUNT, DOSE, FREQUENCIES, MAX_BAGS Subscription styles and prices, discount, grams per cup, schedules, bags per delivery
ROAST_DAY, DELIVERY_LAG Roast weekday and days until delivery, for the delivery dates
METHODS Brew recipes: ratio, milliliters per cup, grind, temperature, time
TIERS, WHOLESALE_DOSE Wholesale price tiers (shown in the price table) and grams per drink

To add a coffee, add an object to COFFEES:

ts
{
  id: "sidama-bensa", name: "Bensa Natural", origin: "ethiopia", region: "Sidama",
  process: "natural", roast: "light", price: 23, rating: 4.8, reviews: 12, badge: "New",
  notes: ["Strawberry", "Cacao", "Lime"], profile: { acidity: 4, sweetness: 5, body: 3 },
  producer: "Bensa smallholders", altitude: "2,100 m", variety: "Ethiopian landraces", harvest: "January 2026",
  bag: "#e86a8f", brew: "v60",
  blurb: "Strawberry jam and cacao, with a lime-bright finish.",
},
  • id is used in URLs (/product?id=sidama-bensa) and in the saved cart: lower case, no spaces.
  • origin, roast and process must be keys of ORIGINS, ROASTS and PROCESSES. A new origin also needs an option in FILTER_GROUPS in app/data/shop.ts.
  • price is the 12 oz price in dollars; brew is a key of METHODS; bag is the bag color.
  • featured: 1 to 4 puts the coffee on the home page in that order; badge adds a sticker.

The brew guides' text and steps are in app/data/brew.ts (GUIDES; each id is also a key of METHODS). The prices-paid table on the sourcing page and the shipping copy ("Free shipping over $45") are plain markup: update them when you change FREE_SHIPPING or SHIPPING.

Images and artwork#

There are no photos. Every coffee "photo" is SVG drawn by artInner(coffee, view, id) in app/utils/art.ts and shown by <CoffeeArt :coffee="coffee" :view="1" />: view 0 is the bag (color from coffee.bag, origin, process and roast dots on the label, a sun, waves or moon for the roast), view 1 the roasted beans. artLabel() is the picture's accessible name.

To use photos instead, render an <img> in CoffeeArt.vue (keep the 4:5 aspect ratio of the frames, for example 1200×1500 px, and put the files in public/img/). Other illustrations (roast icons, the sourcing globe) are inline SVG in the pages.

The social image is public/img/og-image.png (1200×630), rendered from og-image.svg. Edit the SVG and export a PNG of the same size; social sites don't display SVG. The favicon is public/img/favicon.svg.

The menu items are in app/data/nav.ts (NAV, plus CONTACT, which the header shows next to the cart); each item's pages are the routes on which it is marked with aria-current="page" (product pages mark "Shop coffee"). The footer columns are FOOTER_COFFEE and FOOTER_HELP in app/data/footer.ts. The announcement bar above the header is in app/layouts/default.vue. A new page also needs its route in routes in nuxt.config.ts.

Features#

Shop filters#

app/pages/shop.vue. The checkboxes in #filters (groups roast, origin, process from FILTER_GROUPS) filter COFFEES: options in one group are combined with OR, groups with AND. #sort orders by featured, price, roast level or name (SORTS). The result count (#result-count) is a live region; active filters appear as chips in #active-filters that remove themselves on click (focus moves to the next chip, or to the count), and the "Clear all filters" buttons reset everything and focus the count. On phones the form is collapsed behind #filters-toggle (aria-expanded).

The address keeps the state, so filtered views can be shared and linked: /shop?roast=light,medium&origin=ethiopia&process=washed&sort=price-asc. Parameter names and values are case-insensitive, values can be comma-separated or repeated, and anything that isn't one of the template's own keys is ignored. The page rewrites the address with history.replaceState as filters change; a link to the shop from the shop itself (the footer's "Light roasts") starts it afresh, like a page load (app/plugins/same-page.client.ts and the page key in app/app.vue).

Product page#

app/pages/product.vue. ?id= picks the coffee (case-insensitive); a missing or unknown id shows the first coffee. The tab title, text, tasting profile bars (profile), origin details, brew recipe (from METHODS[coffee.brew]), gallery, size cards and grind options all come from the data. Prices per size come from unitPrice(); the add button shows the line total and the quantity is kept between 1 and MAX_QTY (a typed value is repaired when the field loses focus). The gallery thumbnails are buttons with aria-pressed. Related coffees prefer the same roast; opening one shows its page from the top.

Cart and drawer#

app/composables/useCart.ts holds the cart and useCartUI.ts the drawer and toast. The "Add 12 oz" buttons on every coffee card add a 12 oz whole bean bag; adding shows a toast for four seconds with a "View cart" button and pops the header badge. The header cart button opens #cart-drawer (CartDrawer.vue): it traps focus, closes with Escape, the close button or the overlay, and returns focus to the button that opened it. Lines have a quantity stepper and a remove button, and focus stays on the control you used (or moves to the close button when it is gone). The free-shipping bar (ShippingProgress.vue) is a role="progressbar". The header badge and the cart button's label follow the cart, also across open tabs (the storage event, in app/layouts/default.vue). The cart button is a link to /checkout, so it still works without JavaScript.

Subscription builder#

app/pages/subscribe.vue. The plan is { style, size, bags, freq, grind } (Plan in app/utils/plan.ts). Each change updates the summary ([data-plan] elements) and the prices on the option cards, and saves the plan. planPrice() works out the bag price (STYLES price × size factor × (1 − SUB_DISCOUNT)), the price per delivery, the price per cup (DOSE grams per cup), an average monthly cost and the saving against shop prices. The "Change" buttons ([data-edit]) scroll to their step and focus its current choice. ?style=bright (the product pages' "Build a plan" link) preselects a style.

Delivery dates come from deliveryDates(from, freq, count): the first delivery is DELIVERY_LAG days after the next ROAST_DAY after today, then one every freq weeks, formatted as "Thu, Oct 15" in the visitor's time zone. They depend on today, so they appear once the page is mounted and never go stale.

Checkout#

app/pages/checkout.vue. Three steps ([data-step]) with a step indicator (aria-current="step"). Each submit validates only the current step, then moves on and focuses the next heading; Back returns to the previous step. The payment step shows a review of the details with Edit buttons ([data-goto]). Delivery methods come from SHIPPING; standard is free from FREE_SHIPPING. Card number and expiry are formatted as you type. Placing the order (placeOrder()) shows #checkout-done with an order number (FC- and four characters, from makeRef()) and empties the cart. The confirmation says a confirmation email is on its way and that the coffee is roasted on Monday and shipped on Tuesday, like the banner and the footer: change them together if yours differ. The note at the top says payments are in test mode and gives the test card number; remove it when you connect a real provider. No payment is taken. To take real payments, send the cart to your provider (Stripe Checkout, PayPal, Shopify Buy Button...) in placeOrder() instead of showing the confirmation.

Brew guides#

app/pages/brew.vue. A WAI-ARIA tab list: arrow keys, Home and End move between tabs, and the address hash opens one (/brew#cold, case-insensitive, also when it changes while the page is open); choosing a tab writes its hash. The recipe scaler multiplies the open method's cup by the cups slider and divides by its ratio (strength moves the ratio by one; espresso keeps 1:2).

Wholesale estimator#

app/pages/wholesale.vue: drinks a day × WHOLESALE_DOSE × days open gives pounds per week, the matching tier from TIERS and the weekly spend. The matching row of the price table gets the class is-current.

Scroll reveal, ticker and back to top#

Elements with .reveal fade in when scrolled into view, and everything above them is revealed too (app/plugins/reveal.client.ts). Don't put .reveal on an element that also has a :class binding: Vue would drop the is-visible class the next time it updates it. The home page's tasting-note ticker scrolls with CSS (.ticker). Both stop for visitors who prefer reduced motion. BackToTop.vue appears after scrolling about one screen, returns to the top (instantly with reduced motion), moves focus to #main and lifts itself above the toast; remove <BackToTop /> from app/layouts/default.vue (and the .back-to-top styles in main.css) to drop it.

Things that read the browser#

The cart, the plan, the address (?id=, the shop filters, ?style=, the brew #hash), the clock (delivery dates, the copyright year in useYear()) and matchMedia are only read in onMounted() or event handlers, so the generated HTML is the same for every visitor and hydration never mismatches. Keep new browser-only code there too.

Forms#

Every form uses useFormValidation(formRef, onValid) from app/composables/useFormValidation.ts, with the rules in app/utils/validation.ts (the checkout uses the same rules step by step). An error appears when a filled-in field loses focus and updates while typing; on submit every field is checked, the first problem gets focus and the form's [data-form-status] reads "Please fix the highlighted fields." Each error is linked to its field with aria-describedby.

Attribute Rule
required Must not be empty (checkboxes must be checked)
type="email", type="tel" Must look like an email address / phone number
minlength Minimum length
data-label Name used in the error message
data-msg Message for a required checkbox or a group
data-validate zip, order, card, expiry or cvc
data-group (on a fieldset) At least one checkbox checked (data-min-checked for more)
html
<form ref="form" novalidate>
  <label for="c-email" class="field-label">Email</label>
  <input id="c-email" name="email" type="email" required data-label="Email" class="field" v-bind="described('c-email')">
  <p v-if="'c-email' in errors" id="c-email-error" class="field-error">{{ errors["c-email"] }}</p>
  <button type="submit" class="btn-primary">Send</button>
  <p data-form-status role="status">{{ status }}</p>
</form>
Form File Behavior
Newsletter NewsletterForm.vue (every footer) Replaced with a thank-you message
Subscription sign-up SubscribeForm.vue (/subscribe#start) Confirms the saved plan, first delivery date and a reference
Wholesale WholesaleForm.vue (/wholesale#enquiry) Replaced with a thank-you message and a reference
Contact ContactForm.vue (/contact) Replaced with a thank-you message and a reference
Checkout app/pages/checkout.vue Shows the order confirmation

The newsletter thank-you asks subscribers to look out for a confirmation email. The contact message promises a reply within one business day and the wholesale one a reply within two business days (as the wholesale page says); the subscription confirmation gives the first delivery date and says you email two days before every roast. Each shows a reference (FC- and four characters, from makeRef()), and the contact form's order number field accepts the same shape. Change the wording to match what really happens. Nothing is sent until you connect the forms, although their messages read as if it had been. To collect submissions, post the form in its callback, for example in ContactForm.vue:

ts
const { errors, status, described } = useFormValidation(form, async (f) => {
  const res = await fetch("https://formspree.io/f/your-id", {
    method: "POST",
    body: new FormData(f),
    headers: { Accept: "application/json" },
  });
  if (res.ok) reference.value = makeRef("FC");
});

Every field has a name, so FormData carries all the values; keep a name on any field you add. With the static build, post to an external service; to handle it inside the project, add a server route such as server/api/contact.post.ts and deploy with pnpm build instead of pnpm generate.

Saved data (localStorage)#

Key Value Used for
firstcrack-cart JSON array of { id, size, grind, qty } Cart contents
firstcrack-plan JSON object { style, size, bags, freq, grind } The subscription plan being built

Both are validated every time they are read (normalizeCart() in useCart.ts, normalizePlan() in app/utils/plan.ts): ids, sizes, grinds and styles must be the template's own keys (any letter case), quantities are whole numbers from 1 to MAX_QTY (bags 1 to MAX_BAGS), frequencies must be in FREQUENCIES, duplicate cart lines are merged, and corrupt or tampered data is replaced with the cleaned version. Prices are never stored. Storage goes through safeStorage (app/utils/safeStorage.ts): if the browser blocks it, the shop still works but forgets the cart on reload. To reset, run localStorage.removeItem("firstcrack-cart") and localStorage.removeItem("firstcrack-plan") in the browser console, or clear the site's data.

SEO and sharing#

  • Each page calls usePageSeo({ title, description }) (app/composables/usePageSeo.ts), which sets the title, description, canonical link, Open Graph tags and the Twitter card. The product page's tab title names the coffee shown. The 404 page passes noindex: true.
  • Canonical, og:url and og:image are absolute: SITE_URL plus the base path. Build with your domain: SITE_URL=https://www.example.com pnpm generate (the default is https://firstcrack.example).
  • The product page is one static page, so search engines see the generic product description. If product search traffic matters, add app/pages/product/[id].vue, list each /product/<id> in routes in nuxt.config.ts, update productHref() and add Product structured data (JSON-LD).
  • Add a public/robots.txt and a sitemap for production.

Accessibility#

  • Skip link, landmarks, one h1 per page and a logical heading order.
  • aria-current="page" in the menus, aria-current="step" in the checkout.
  • Keyboard support everywhere: the cart drawer traps focus and closes with Escape, the phone menu closes with Escape, the brew tabs follow the WAI-ARIA tabs pattern, radio cards keep native radio behavior.
  • Live regions for the result count, the plan summary, the scaler and estimator results, toasts and form status; errors are linked to their fields.
  • Visible focus outlines, AA contrast, and prefers-reduced-motion turns off reveals, the ticker and smooth scrolling.

When editing, keep one h1 per page, give new inputs a <label> and a name, and keep icon-only buttons' aria-label.

Building and deploying#

sh
pnpm generate     # static site in .output/public/
pnpm preview      # serve it locally

Deploy .output/public/ to any static host (Netlify, Cloudflare Pages, GitHub Pages, S3) or import the repository into Vercel or Netlify, which detect Nuxt. Pages are written as shop.html, product.html and so on; most static hosts serve /shop from shop.html automatically, and 404.html is the not-found page.

For a site in a sub-folder, set DEMO_BASE_PATH when building (DEMO_BASE_PATH=/coffee pnpm generate): it becomes app.baseURL in nuxt.config.ts. demo/, if present, is the freeframe.dev demo build and can be deleted.

tsconfig.json only references the configs Nuxt writes to .nuxt/, so pnpm typecheck checks the app and config with Nuxt's strict settings. typescript is aliased to @typescript/typescript6, the TypeScript 6 API that vue-tsc needs.

Troubleshooting#

  • pnpm lint can't find .nuxt/eslint.config.mjs. Run pnpm install (or pnpm typecheck) first; it runs nuxt prepare.
  • /product?id=… shows Kochere Lot 7. Unknown ids fall back to the first coffee; check the id matches COFFEES.
  • A new coffee doesn't appear under a filter. Its origin, roast or process must be a key of ORIGINS, ROASTS or PROCESSES, and a new origin needs an option in app/data/shop.ts.
  • The cart forgets items. localStorage is blocked (some private windows or strict privacy settings), or the coffee id changed.
  • Delivery dates look a day off. They use the visitor's own time zone and clock. Change ROAST_DAY (0 = Sunday) and DELIVERY_LAG to match your schedule.
  • Hydration warnings after editing. Something reads the cart, the address or the clock while rendering; move it into onMounted() or an event handler.
  • A new page is missing from .output/public. Add its route to routes in nuxt.config.ts.
  • Social previews have no image. Build with SITE_URL set to your domain.

License#

Freeframe Commercial License: use it in unlimited websites and apps for yourself and your clients; it may not be resold or redistributed as a template. The full terms, with your licensee details, are in LICENSE.txt and at https://freeframe.dev/license#commercial. Third-party components keep their own licenses. Onest and Unbounded are licensed under the SIL Open Font License. First Crack Coffee, its coffees, producers and the people quoted are fictional.

Search documentation

Search the guides and every template's documentation