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.
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.yamlCustomizing#
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:
@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:
--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:
{
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.",
},idis used in URLs (/product?id=sidama-bensa) and in the saved cart: lower case, no spaces.origin,roastandprocessmust be keys ofORIGINS,ROASTSandPROCESSES. A new origin also needs an option inFILTER_GROUPSinapp/data/shop.ts.priceis the 12 oz price in dollars;brewis a key ofMETHODS;bagis the bag color.featured: 1to4puts the coffee on the home page in that order;badgeadds 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.
Navigation, header and footer#
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) |
<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:
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 passesnoindex: true. - Canonical,
og:urlandog:imageare absolute:SITE_URLplus the base path. Build with your domain:SITE_URL=https://www.example.com pnpm generate(the default ishttps://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>inroutesinnuxt.config.ts, updateproductHref()and addProductstructured data (JSON-LD). - Add a
public/robots.txtand a sitemap for production.
Accessibility#
- Skip link, landmarks, one
h1per 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-motionturns 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#
pnpm generate # static site in .output/public/
pnpm preview # serve it locallyDeploy .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 lintcan't find.nuxt/eslint.config.mjs. Runpnpm install(orpnpm typecheck) first; it runsnuxt prepare./product?id=…shows Kochere Lot 7. Unknown ids fall back to the first coffee; check the id matchesCOFFEES.- A new coffee doesn't appear under a filter. Its
origin,roastorprocessmust be a key ofORIGINS,ROASTSorPROCESSES, and a new origin needs an option inapp/data/shop.ts. - The cart forgets items.
localStorageis blocked (some private windows or strict privacy settings), or the coffeeidchanged. - Delivery dates look a day off. They use the visitor's own time zone and clock. Change
ROAST_DAY(0 = Sunday) andDELIVERY_LAGto 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 toroutesinnuxt.config.ts. - Social previews have no image. Build with
SITE_URLset 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.