Overview#
Roast is a shop for First Crack Coffee, a fictional specialty coffee roaster in Baltimore: a home page, a coffee shop with roast, origin and process filters, product pages with bag sizes, grinds, a tasting profile and a brew recipe, a subscription builder that shows the price per cup and the next delivery dates, a slide-out cart, a three-step demo checkout, brew guides with a recipe scaler, a wholesale page with a volume estimator and enquiry form, a sourcing page, contact and FAQ, and a 404 page. Every coffee bag and bean illustration is drawn in SVG from the product data, so there are no photos to replace before launch. This is the Astro 7 + TypeScript + Tailwind CSS v4 version, version 1.0.0. It builds to static HTML files with no UI framework, and can be hosted anywhere. See it live at freeframe.dev/templates/roast.
Quick start#
Requirements: Node.js 24 or newer and pnpm (npm works too).
pnpm install
pnpm dev # http://localhost:4321With npm: npm install, then npm run dev.
| Command | What it does |
|---|---|
pnpm dev |
Development server at http://localhost:4321 with hot reload |
pnpm build |
Static site in dist/ |
pnpm preview |
Serve dist/ locally after a build |
pnpm check |
astro check: type-checks .astro files and TypeScript |
pnpm typecheck |
Generate Astro's types, then type-check with tsc (TypeScript 7) |
pnpm lint |
ESLint (TypeScript and Astro rules) |
pnpm build:demo |
Build for /demos/astro/roast into demo/ (only used for the demo on freeframe.dev) |
Pages#
Every file in src/pages/ becomes one page. Each page passes a page prop to <Layout>, which becomes
<body data-page="…">; src/scripts/main.ts reads it to decide which page features to start, so keep it
when you copy a page.
| URL | File | page |
What's on it |
|---|---|---|---|
/ |
src/pages/index.astro |
home |
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/pages/shop.astro |
shop |
Coffee grid with roast, origin and process filters, sort, live count, removable filter chips and shareable URLs |
/product |
src/pages/product.astro |
product |
One coffee, chosen by ?id=: bag and beans gallery, bag size, grind, quantity, tasting profile, origin details, brew recipe, related coffees |
/subscribe |
src/pages/subscribe.astro |
subscribe |
Subscription builder (style, bag size, bags, frequency, grind), live summary with price per cup and next deliveries, sign-up form |
/checkout |
src/pages/checkout.astro |
checkout |
Details, delivery and payment steps, order summary, confirmation; an empty-cart message when there is nothing to buy |
/brew |
src/pages/brew.astro |
brew |
Six brew methods in tabs (#v60, #aeropress, #french, #moka, #cold, #espresso) and a recipe scaler |
/wholesale |
src/pages/wholesale.astro |
wholesale |
Benefits, price tiers, volume estimator and enquiry form (#enquiry) |
/about |
src/pages/about.astro |
about |
How we buy, prices paid per coffee, producers, visit call to action |
/contact |
src/pages/contact.astro |
contact |
Contact form, roastery address and hours, FAQ (#faq) |
| 404 | src/pages/404.astro |
404 |
Not-found page (most hosts show 404.html automatically) |
Project structure#
roast/
├─ src/
│ ├─ pages/ one .astro file per page (see above)
│ ├─ layouts/
│ │ └─ Layout.astro <head> (title, meta, Open Graph, fonts), <body data-page>, header, footer, cart drawer, scripts
│ ├─ components/
│ │ ├─ Logo.astro the logo link (bean mark + "First Crack")
│ │ ├─ SiteHeader.astro announcement bar, header, cart button, mobile menu
│ │ ├─ SiteFooter.astro footer links, newsletter form, copyright
│ │ └─ CartDrawer.astro cart drawer, overlay and toast markup (filled by the script)
│ ├─ data/
│ │ ├─ coffees.ts coffees, sizes, grinds, shipping, subscription styles, brew methods, wholesale tiers
│ │ ├─ faq.ts contact page questions and answers
│ │ └─ nav.ts header, mobile menu and footer links
│ ├─ lib/
│ │ └─ site.ts site name, theme colour and the url() helper
│ ├─ scripts/
│ │ ├─ main.ts all interactivity: artwork, cart, shop, product, subscription, checkout, brew, wholesale, forms
│ │ └─ back-to-top.ts the back-to-top button
│ └─ styles/
│ └─ global.css Tailwind import, brand tokens (@theme), component classes
├─ public/img/ favicon.svg, og-image.svg (source), og-image.png (social image)
├─ astro.config.mjs site URL, base path, file-style output
├─ eslint.config.mjs, tsconfig.json, package.json
└─ README.md, DOCS.md, CHANGELOG.mdCustomising#
Brand and colours#
The design tokens live in the @theme block of src/styles/global.css. Every utility such as bg-cobalt,
text-navy or border-navy comes from them, so changing a value re-skins the whole site.
| 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 blocks, stickers, secondary buttons |
--color-cobalt / --color-cobalt-dark |
#2338b5 / #18268a |
Brand colour, primary buttons, links |
--color-sky |
#d9e0ff |
Selected options, highlighted 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 and stars |
--color-leaf |
#23714a |
Success text |
@theme {
--color-cobalt: #0f6b5c; /* a deep green instead of cobalt */
--color-cobalt-dark: #0a4d42;
}Keep text colours at WCAG AA contrast against butter and cream. The theme colour of the browser UI is
themeColor in src/lib/site.ts. Component classes (btn-primary, btn-sun, btn-outline,
btn-light, card, sticker, tag, eyebrow, field, option, pill, check) are defined in the
same file; the artwork uses the navy outline colour #141a3c directly in art() in src/scripts/main.ts.
Typography#
Two Google Fonts are loaded in src/layouts/Layout.astro: Unbounded (headings, font-display) and
Onest (body, font-sans). To swap them, change the Google Fonts <link> and the --font-display
and --font-sans tokens in src/styles/global.css. The bag labels in the SVG artwork name the fonts too
(font-family="Unbounded, …" and "Onest, …" in art()).
Content#
Page copy is plain markup in src/pages/*.astro. The data the script works from lives in
src/data/coffees.ts:
| Export | What it is |
|---|---|
COFFEES |
Every coffee: id, name, origin, region, process, roast, price (250 g, dollars), rating, reviews, optional badge and featured, notes, profile (acidity, sweetness, body, 1–5), producer, altitude, variety, harvest, bag (illustration colour), brew (a METHODS key), blurb |
ORIGINS, ROASTS, PROCESSES |
The keys a coffee can use and their display names (also the shop filters) |
SIZES |
Bag sizes; a size's price is Math.round(price × factor) |
GRINDS |
Grind options for the product page, cart and subscription |
FREE_SHIPPING, SHIPPING, MAX_QTY |
Free-shipping threshold, delivery methods and prices, highest quantity per line |
STYLES, SUB_DISCOUNT, DOSE, FREQUENCIES, MAX_BAGS, ROAST_DAY, DELIVERY_LAG |
Subscription styles and prices, discount, grams per cup, schedules, bags per delivery, roast weekday and days to delivery |
METHODS |
Brew methods: ratio, cup size, grind, temperature and time (product recipes and the brew scaler) |
WHOLESALE_DOSE, TIERS |
Grams per drink and the price tiers of the wholesale estimator |
To add a coffee, add an object to COFFEES:
{
id: 'santa-barbara', name: 'Santa Bárbara', origin: 'honduras', region: 'Santa Bárbara',
process: 'washed', roast: 'medium', price: 20, rating: 4.8, reviews: 12,
notes: ['Peach', 'Honey', 'Almond'], profile: { acidity: 3, sweetness: 4, body: 3 },
producer: 'Finca La Montañita', altitude: '1,500 m', variety: 'Pacas', harvest: 'March 2026',
bag: '#c86b98', brew: 'v60',
blurb: 'Soft stone fruit and a honey finish.'
},It appears in the shop, the filter counts, related coffees and the cart right away, and its page is
/product?id=santa-barbara. A new origin, roast or process needs a key in ORIGINS, ROASTS or
PROCESSES; the shop's filter checkboxes are rendered from those objects in src/pages/shop.astro.
Some numbers also appear as copy (the "Free shipping over $45" announcement, the FAQ, the wholesale tier table, the delivery notes on the checkout options). Update the copy when you change the settings.
Images and artwork#
There are no photos. art(coffee, view) in src/scripts/main.ts draws each coffee as an SVG: view 0
is the bag (colour from bag, a roast mark, the origin and the process on the label) and view 1 is a
pile of beans in the roast's colour. Any element with data-art="<coffee id>" (and optionally
data-view="1") is filled with the artwork when the page loads, as in the home page hero. The drawings
are 400 × 500 (4:5); to use photos, replace the art(…) calls in coffeeCard(), renderDrawer(),
renderCheckout() and initProduct() with <img> tags of the same aspect ratio and a meaningful alt.
The illustrations on the sourcing page and the icons are inline SVG in the page files. The social image is
public/img/og-image.png (1200 × 630), exported from public/img/og-image.svg; the favicon is
public/img/favicon.svg.
Navigation, header and footer#
The header and mobile menu are rendered from mainNav and contactLink in src/data/nav.ts; the footer
columns from footerCoffee, footerHelp and socials. Each page passes current (for example
current="shop") to <Layout>, and the link with the matching key gets aria-current="page". To add
a page, create src/pages/<name>.astro using <Layout> and add a link to mainNav. Write internal links
with url() so they keep working under a base path.
Features#
Shop filters#
initShop() in src/scripts/main.ts. The checkboxes in #filters filter by roast, origin and process
(options in a group are combined with "or", groups with "and"); #sort orders by featured, price, roast or
name. The result count (#result-count) is a polite live region, active filters show as removable chips,
and the URL is kept in sync (/shop?roast=light,medium&origin=ethiopia&sort=price-asc), so filtered views
can be shared and linked to, as the footer does. Parameter names and values are read case-insensitively,
unknown values are ignored, and the URL is rewritten in canonical form. On phones the filters fold behind a
"Filters" button with aria-expanded.
Product page#
initProduct(). Reads ?id= (case-insensitive; unknown or missing ids show the first coffee), sets the
title, text and links, draws the gallery (bag and beans, aria-pressed thumbnails), renders the size
options with prices from SIZES and the grind options from GRINDS, and keeps the add-to-cart total up to
date. Quantities are clamped to 1–MAX_QTY. The brew card shows the recipe from METHODS[coffee.brew]
for one cup, and "Build a plan" links to the subscription builder with the matching style.
Cart drawer#
The cart button in the header, the "View cart" link in toasts and any [data-cart-open] open the drawer.
It lists each line with its quantity stepper, remove button and line total, shows progress to free
shipping (an ARIA progress bar) and the subtotal. Focus moves into the drawer and is trapped there; Escape,
the close button and the overlay close it and return focus. "Add 250 g" buttons on coffee cards
([data-add]) add a 250 g whole-bean bag and show a toast. Other open tabs update through the storage
event.
Subscription builder#
initSubscribe(). The five steps are fieldsets (#step-style, #step-size, #step-bags, #step-freq,
#step-grind) inside #plan-form; the summary shows the plan, the price per delivery, the price per cup
(DOSE grams per cup), an approximate monthly cost, the saving compared with shop prices and the next three
delivery dates. "Change" buttons scroll to their step and focus its current choice. The plan is saved as you
go and restored on the next visit; ?style=bright (used by product pages) preselects a style.
Delivery dates come from deliveryDates(): the next ROAST_DAY after today plus DELIVERY_LAG days,
then every freq weeks, formatted in US English. They are calculated in the browser, so they are always
relative to the visitor's today.
Checkout#
initCheckout(). Three steps in #checkout-form (details, delivery, payment); each step is validated
before moving on, Back and Edit buttons move between steps, and the step list marks the current step with
aria-current="step". The summary lists the cart, shipping for the chosen method (standard is free from
FREE_SHIPPING) and the total. The card number and expiry are formatted as you type. Placing the order
shows a confirmation with a random order number and empties the cart; no payment is taken and nothing is
sent. To take real payments, send the cart to a hosted checkout such as Stripe Checkout from a server or
serverless function instead of collecting card details in this form.
Brew guides#
initBrew(). The method buttons are an ARIA tablist: arrow keys, Home and End move between them, and the
open tab is written to the URL hash, so /brew#aeropress (any letter case) opens that method. The recipe
scaler multiplies the method's cup size by the number of cups and divides by its ratio (one point stronger
or lighter with the strength menu; espresso ignores strength).
Wholesale estimator#
initWholesale(). Drinks per day × WHOLESALE_DOSE grams × days open gives kilos per week, which picks a
tier from TIERS and a weekly cost. The matching row of the price table ([data-tier]) is highlighted.
Back to top button#
src/scripts/back-to-top.ts adds a button that appears after about one screen of scrolling, returns to
the top (instantly with reduced motion) and moves focus to the main content. It lifts itself above toasts.
Mark your own fixed UI with data-back-to-top-avoid or data-back-to-top-hide. To remove it, delete the
file, its import in src/layouts/Layout.astro and the .back-to-top styles in src/styles/global.css.
Forms#
All forms are validated by setupValidation() (or, for checkout, validate() per step) in
src/scripts/main.ts: errors appear on blur and submit, the first invalid field is focused, and each error
is linked with aria-describedby. Rules come from attributes:
| Attribute | Rule |
|---|---|
required |
Must not be empty (checkboxes must be ticked) |
type="email" |
Must look like an email address |
type="tel" |
Digits, spaces and +()-. only |
minlength |
Minimum length |
data-label |
Name used in the error message |
data-validate |
zip, order, card, expiry or cvc |
data-group (on a fieldset) |
At least one checkbox ticked; message from data-msg |
| Form | Where | Behaviour |
|---|---|---|
Newsletter (data-form="newsletter") |
Every footer | Replaced with a thank-you message |
Contact (data-form="contact") |
/contact |
Replaced with a thank-you message |
Wholesale (data-form="wholesale") |
/wholesale#enquiry |
Replaced with a thank-you message |
Subscription (data-form="subscribe") |
/subscribe#start |
Replaced with a summary of the plan |
Checkout (#checkout-form) |
/checkout |
Shows the order confirmation |
Nothing is sent. To collect submissions, post the form in its callback in section 14 of
src/scripts/main.ts, for example to Formspree:
$$<HTMLFormElement>('[data-form="contact"]').forEach((form) => setupValidation(form, () => {
fetch("https://formspree.io/f/your-id", {
method: "POST", body: new FormData(form), headers: { Accept: "application/json" },
}).then((res) => {
successSwap(form, res.ok ? "<p>Thanks for writing.</p>" : "<p>Something went wrong. Please email us.</p>");
});
}));FormData only includes fields with a name; every field already has one. To handle submissions with
your own code instead, add an Astro adapter (for example @astrojs/node, @astrojs/netlify or
@astrojs/vercel) and an API endpoint such as
src/pages/api/contact.ts with export const prerender = false, then post to it.
Saved data (localStorage)#
| Key | Value | Used for |
|---|---|---|
firstcrack-cart |
JSON array of { id, size, grind, qty } |
Cart contents |
firstcrack-plan |
JSON { style, size, bags, freq, grind } |
The subscription plan being built |
Both are validated every time they are read. normalizeCart() keeps only known coffee ids (matched
case-insensitively), replaces unknown sizes and grinds with the defaults, clamps quantities to
1–MAX_QTY, merges duplicate lines and drops anything else; tampered data is overwritten with the cleaned
version. normalizePlan() does the same for the plan, falling back to DEFAULT_PLAN field by field. Prices
are never stored: every total is calculated from src/data/coffees.ts. If storage is blocked, the shop
still works but forgets the cart after a reload. To reset, run
localStorage.removeItem("firstcrack-cart") (or "firstcrack-plan") in the browser console.
SEO and sharing#
-
Each page passes
titleanddescriptionto<Layout>, which outputs<title>, the meta description,og:title,og:descriptionand asummary_large_imageTwitter card. The product page also setsdocument.titlefrom the coffee. -
The canonical URL,
og:urland the absoluteog:imageURL are built fromsiteinastro.config.mjs, which readsSITE_URL(defaulthttps://roast.example.com). Set it to your domain when you build:shSITE_URL=https://example.com pnpm build -
The 404 page passes
noindex, which adds<meta name="robots" content="noindex">and leaves out the canonical URL. -
/productis one page filled in by JavaScript from?id=, so search engines that don't run scripts see the default coffee. If product search traffic matters, generate a page per coffee withgetStaticPaths()fromCOFFEES(for examplesrc/pages/coffee/[id].astro). -
For a sitemap, add
@astrojs/sitemap(pnpm astro add sitemap) and apublic/robots.txtthat points to it.
Accessibility#
Built in: a skip link, landmarks, visible focus rings, a focus-trapped cart drawer that closes with Escape,
a mobile menu with aria-expanded that closes with Escape, the cart count in the cart button's label,
polite toasts, keyboard-operable radio cards and pills, an ARIA tablist on the brew page, aria-pressed
gallery thumbnails, text alternatives on the artwork, live regions for the shop count, plan summary,
scaler and estimator, an ARIA progress bar for free shipping, inline form errors linked to fields,
reduced-motion support (no reveal animations, no ticker, instant scrolling) and WCAG AA contrast. Content
still shows without JavaScript except the parts drawn from the catalogue (artwork, coffee grids, cart and
prices).
When editing, keep one <h1> per page, keep a <label> and data-label on every form field, and keep
coffee names meaningful (they are the artwork's text alternative).
Building and deploying#
pnpm build # static site in dist/
pnpm preview # preview dist/ locallydist/ is plain HTML, CSS and JavaScript. Pages are written as shop.html rather than
shop/index.html (build.format: "file" in astro.config.mjs), and links point to /shop without a
trailing slash. Netlify, Cloudflare Pages and GitHub Pages serve /shop from shop.html automatically;
Vercel does with cleanUrls (below).
- Netlify / Cloudflare Pages: build command
pnpm build, publish directorydist. - Vercel: import the repository; it detects Astro and static output needs no adapter. Add a
vercel.jsonwith{ "cleanUrls": true }so/shopservesshop.html. - GitHub Pages: use the official
Astro GitHub Action. For a project site
(
username.github.io/repo), build withDEMO_BASE_PATH=/repo. - Any static host: upload the contents of
dist/. If the host doesn't map/shoptoshop.html, changebuild.formatto"directory"inastro.config.mjs.
To serve the site from a sub-folder, set DEMO_BASE_PATH when building, e.g.
DEMO_BASE_PATH=/roast pnpm build. It becomes Astro's base, and every link written with url(),
including the links the script builds, includes it.
Tailwind finds class names in src/ automatically, including the ones inside strings in
src/scripts/main.ts, as long as they're written in full. demo/ (if present) is the build used for the
freeframe.dev demo; you can delete it.
TypeScript 7 setup#
package.json installs two TypeScript packages on purpose. @typescript/native is TypeScript 7 and
provides the tsc that pnpm typecheck runs. typescript is an alias for @typescript/typescript6,
the TypeScript 6 API that astro check and typescript-eslint load. When those tools support TypeScript
7, replace both with a single typescript@7. tsconfig.json extends Astro's strict preset.
Troubleshooting#
- A new class has no effect. Write class names in full (not built from pieces) so Tailwind can find them.
- A product page shows Kochere Lot 7 instead. The
?id=doesn't match any coffeeid. - A coffee is missing from a filter. Its
origin,roastorprocessmust be a key ofORIGINS,ROASTSorPROCESSES. - The cart or plan is forgotten.
localStorageis blocked, or the coffee'sidchanged. - Delivery dates look a day off. They use the visitor's local date and
ROAST_DAY/DELIVERY_LAG; change those two numbers to match your roasting schedule. - Links or images break when served from a sub-folder. Build with
DEMO_BASE_PATH, and write internal links withurl()(in.astrofiles and insrc/scripts/main.ts) rather than plain"/..."strings. - Words run together after editing. Astro removes line breaks between text and a following inline
element. Keep the text and the element on one line, or add
{" "}. - Social previews show the wrong domain or no image. Set
SITE_URLto your domain at build time.
License#
MIT. The code and artwork are free for personal and commercial use; the download includes the license text
in LICENSE.txt. Unbounded and Onest are licensed under the SIL Open Font License.