Overview#
Slice is a website for a pizzeria or takeout with online ordering, built for Crust Club, a fictional sourdough pizzeria with two shops in New Haven (Wooster Square and Westville). It has a filterable menu with a pizza customizer, a cart drawer on every page, a ZIP code delivery checker, set deals and a bundle builder, a checkout with time slots worked out from the opening hours, an animated order tracker, a locations page with a delivery-zone map, an about page and a catering page with a pizza calculator and inquiry form. This is the Nuxt version: Nuxt 4, Vue 3, TypeScript and Tailwind CSS v4, generated as a fully static site. The menu, bestsellers and deals are rendered at build time from typed data, so they are in the HTML. This guide covers version 1.0.1. See it live at freeframe.dev/templates/slice.
Quick start#
Requirements: Node.js 24 or newer and pnpm (npm works too).
pnpm install
pnpm dev # http://localhost:3000Try an order: check the ZIP code 06511 on the home page, add a pizza from the menu, apply the code SLICE10
in the cart and place the order. Nothing is sent or charged until you connect the checkout (see Forms).
pnpm generate writes the static site to .output/public/; pnpm preview serves it.
Pages#
| Route | File | Purpose | Notable sections and features |
|---|---|---|---|
/ |
app/pages/index.vue |
Home | Hero with delivery / pickup switch and ZIP code checker, bestsellers (from the menu data), deals, how ordering works, dough story, reviews, both shops with open-now badges |
/menu |
app/pages/menu.vue |
Menu | Sticky section nav with scrollspy, vegan / vegetarian / gluten-free / spicy filters, live count, item customizer, allergen guide (/menu#allergens) |
/deals |
app/pages/deals.vue |
Deals & bundles | Three set deals with a picker dialog, build-your-bundle with discount tiers (/deals#bundle), demo promo codes with copy buttons |
/checkout |
app/pages/checkout.vue |
Checkout | Delivery or pickup, ASAP or scheduled slot, contact and address, demo payment, order summary with promo code, confirmation with order tracker, empty state |
/locations |
app/pages/locations.vue |
Locations & delivery | SVG delivery-zone map, ZIP code checker, zones table, both shops with full hours (today highlighted) and open-now, delivery FAQ |
/about |
app/pages/about.vue |
Our story | Timeline, the four dough steps, suppliers, team, values |
/catering |
app/pages/catering.vue |
Catering & party orders | Packages, pizza calculator, party-order inquiry form, FAQ |
| any other | app/error.vue (generated as 404.html) |
Not found (404) | Links to the menu, home and locations |
Project structure#
slice/
app/
app.vue <NuxtLayout><NuxtPage /></NuxtLayout>, with a page key (see "Same-page links")
layouts/default.vue skip link, announcement bar, header, footer, cart drawer, toast, dialog, order bar
pages/ index, menu, deals, checkout, locations, about and catering (.vue)
error.vue 404 page
components/
SiteHeader.vue header, order label and button, mobile menu
SiteFooter.vue footer with shop details and newsletter
SiteLogo.vue the logo (light and dark)
MenuCard.vue a menu item card
DealCard.vue a set-deal card
ItemArt.vue an item's SVG picture
DietTags.vue vegan / vegetarian / GF / spicy badges
StoreStatus.vue, TodayHours.vue, HoursRows.vue open-now badge, today's hours, the hours table
ZoneChecker.vue, ZoneResult.vue delivery / pickup switch and ZIP code check
CartDrawer.vue, CartLine.vue, TotalsList.vue, DeliveryNotice.vue, PromoBox.vue
ItemSheet.vue, PizzaCustomiser.vue, DealPicker.vue, QtyStepper.vue, SheetClose.vue the item dialog
OrderBar.vue, ToastBox.vue, CartOpenButton.vue, OrderSummary.vue
OrderTracker.vue the confirmation with the order tracker
NewsletterForm.vue, FieldError.vue, SuccessBox.vue
BackToTop.vue back-to-top button
icon/ shared inline SVG icons (IconClose, IconPin...)
composables/
useOrderUI.ts drawer, item dialog, toast and promo codes, shared by every page
useShopNow.ts the shop clock for open-now badges and time slots
useValidation.ts inline form validation
usePageSeo.ts per-page title, description, canonical and Open Graph tags
useCurrentPage.ts aria-current for menu links
data/
shop.ts shops, hours, delivery zones, promo codes and order settings
menu.ts sizes, crusts, toppings, menu items, deals and bundle tiers
catering.ts calculator settings
site.ts header, mobile and footer links
utils/
art.ts the SVG drawing code (pizzas and food)
order.ts ZIP codes, the saved order and cart (localStorage), line info and totals
pricing.ts pizza, deal and bundle prices, diet tags and allergens
hours.ts shop clock, open-now status and time slots
validation.ts form field rules
format.ts money and number helpers
safeStorage.ts, storage.ts localStorage that never throws
plugins/
reveal.client.ts scroll reveals
same-page.client.ts links to the page already showing restart it, like a page load
stable-build.server.ts reproducible builds
assets/css/main.css Tailwind v4 @theme tokens, @font-face rules and component classes
assets/fonts/ Rubik and Shrikhand woff2 files and their licenses
router.options.ts keeps query strings exactly as written
public/img/ favicon.svg, hero-pizza.svg, og-image.png and its source og-image.svg
nuxt.config.ts base path, head defaults, SITE_URL and the prerendered routes
template.json gallery metadata (not part of your site)Customizing#
Brand and colors#
All colors and fonts are tokens in the @theme block at the top of app/assets/css/main.css. Each token becomes
Tailwind classes (--color-tomato gives bg-tomato, text-tomato, ring-tomato...).
| Token | Default | Used for |
|---|---|---|
--color-mozz |
#fff4e2 |
Page background |
--color-paper |
#fffaf2 |
Cards and fields |
--color-flour |
#f9e6c8 |
Soft panels, steppers |
--color-tomato |
#cc2a16 |
Brand red: hero, primary buttons, menu header |
--color-tomato-deep |
#94190b |
Button lip, red text on cream |
--color-cheese |
#ffc23a |
Yellow: deals band, stickers, secondary buttons |
--color-basil |
#1d6b3c |
Green: vegan tags, success, dough band |
--color-ink |
#2a1209 |
Text, footer, dark buttons |
--color-cocoa |
#6b4a3a |
Secondary text |
Each strong color has a -soft tint (tomato-soft, cheese-soft, basil-soft) for tags and card
backgrounds. To re-brand, change the values:
@theme {
--color-tomato: #1f4fd1; /* a blue pizzeria */
--color-tomato-deep: #12307f;
}Keep text on tomato and basil at 4.5:1 contrast or better. A few colors are written directly in SVG
artwork (pizza toppings in app/utils/art.ts, the map zones in ZONES[].color, og-image.svg) and in
theme-color in nuxt.config.ts.
Component classes live in @layer components in main.css: btn-primary, btn-cheese, btn-basil,
btn-ink, btn-ghost, btn-cream, card, tag with tag-vegan / tag-vegetarian / tag-gf /
tag-spicy, field, option (radio cards), chip (filter and checkbox pills), segmented (the Delivery /
Pickup switch), stepper, checker (the tablecloth band) and scallop-b (the scalloped bottom edge of hero
bands).
Typography#
Rubik (text, variable weights 400–900) and Shrikhand (display) are self-hosted from app/assets/fonts/, the
same woff2 files Google Fonts serves (latin and latin-ext subsets), declared with @font-face at the top of
app/assets/css/main.css, so nothing loads from Google. The tokens use the family names:
--font-sans: "Rubik", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--font-display: "Shrikhand", "Rubik", ui-sans-serif, system-ui, sans-serif;font-display (and the h-display class) is for big headings and the logo; everything else uses Rubik.
To swap a font, put its woff2 files in app/assets/fonts/, change the @font-face rules and the token in
main.css. Shrikhand has a single weight, so keep it for large text.
Content#
Page text lives in the page files. Everything you can order lives in typed data in app/data/:
| File | Export | What it holds |
|---|---|---|
shop.ts |
STORES |
Shops: id, name, address, phone, hours (Sunday first, 24-hour here and shown in 12-hour time, ["12:00", "22:00"] or null), pickupMins, deliveryMins |
shop.ts |
ZONES |
Delivery zones: ZIP codes in districts, the store that serves them, fee, min order, eta range in minutes and map color |
shop.ts |
FREE_DELIVERY_OVER |
Subtotal (after promo) that makes delivery free (50) |
shop.ts |
PROMOS |
Demo promo codes (see Promo codes) |
menu.ts |
SECTIONS |
Menu sections in order, with the blurb under each heading |
menu.ts |
SIZES |
Pizza sizes: delta from the Regular price and extra (price of each extra topping) |
menu.ts |
CRUSTS |
Crusts: price, optional sizes they're limited to, allergens, notVegan, glutenFree |
menu.ts |
TOPPINGS |
Extra toppings: kind (veg, dairy, honey, meat, fish) decides vegan / vegetarian tags |
menu.ts |
MENU |
Every item: section, name, price, desc, diet, allergens, art, optional best |
menu.ts |
DEALS |
Set deals with their slots |
menu.ts |
BUNDLE_TIERS |
Bundle discount tiers |
catering.ts |
APPETITE, CATERING_DISCOUNT |
Calculator settings |
Other settings in shop.ts: TIMEZONE ("America/New_York"), LOCALE and CURRENCY (prices are formatted with
Intl.NumberFormat in app/utils/format.ts), SLOT_STEP (minutes between time slots), SCHEDULE_DAYS,
MAX_QTY and TRACKER_STEP_SECONDS.
To add a pizza, add an entry to MENU. Pizza prices are the Regular (12″) price; the other sizes follow
SIZES. The picture is drawn from art:
{ id: "truffle", section: "pizzas", name: "Truffle Shuffle", price: 14,
desc: "Garlic cream, mushrooms, fior di latte and truffle oil.",
diet: ["vegetarian", "gf"], allergens: ["gluten", "milk"],
art: { sauce: "bianca", cheese: true, toppings: ["mushroom", "thyme"] } },For anything else, art picks a drawing: knots, sweet-knots, bread, wings, burrata, potatoes,
salad, dip (with color and swirl), tiramisu, cookie, gelato, can or bottle (with color and
band). diet uses vegan, vegetarian, gf and spicy; for pizzas gf means "can be made on the
gluten-free base" and shows as GF base. allergens uses the keys of ALLERGENS. Items with best: true
appear in the home page bestsellers. TypeScript checks the shapes, so a typo in a diet or allergen fails
pnpm typecheck.
Prices are never stored in the cart, so changing a price updates every page, saved cart and total.
Images and artwork#
There are no photos. Pizzas, sides, dips, desserts and drinks are drawn as SVG by app/utils/art.ts
(pizza() and food(), the same code as the HTML version), from each item's art settings, so new menu
items get a picture automatically. Topping drawings are in the PIECES object; add a key there to use a new
topping in art. Positions are seeded from the item id, so the same pizza always looks the same, and the
pictures drawn at build time match the ones drawn in the browser (the customizer preview and the cart).
<ItemArt item="margherita" class="size-40" /> (app/components/ItemArt.vue) shows an item's picture anywhere,
for example the decorative pizzas in page headers. public/img/hero-pizza.svg is a static file drawn with the
same code; replace it with any square image. If you prefer photos, change itemArt() in art.ts (or ItemArt.vue)
to return an <img>: menu cards show art in a square, the cart in a 64–80 px square.
The social image is public/img/og-image.png (1200×630). Edit og-image.svg, then export it as a PNG at the
same size, because most social sites don't display SVG. The favicon is public/img/favicon.svg.
Navigation, header and footer#
The header (SiteHeader.vue), footer (SiteFooter.vue), cart drawer, dialog and toast are in
app/layouts/default.vue, so they appear on every page. Menu links come from app/data/site.ts: NAV
(desktop), MOBILE_NAV (adds Home) and FOOTER_NAV. To add a page, create app/pages/<route>.vue (call
usePageSeo() with its title and description), add the route to routes in nuxt.config.ts and add it to
those lists; the current page is marked with aria-current="page". Use <NuxtLink to="/menu"> for internal
links.
In the HTML version every link is a full page load. Here links are client-side navigations, so
app/plugins/same-page.client.ts restarts a page when a link opens the page that is already showing (the Menu
link on the menu, or /menu?diet=vegan after the filters changed the address bar), and shows it from the top.
Shop names, addresses and phone numbers in the footer and zone checker come from STORES; the shop cards on
the home and locations pages are written in the page files, so change them there too.
Features#
Delivery checker#
<ZoneChecker /> (app/components/ZoneChecker.vue) is a Delivery / Pickup switch with a ZIP code check
(delivery) or a shop picker (pickup). <ZoneChecker compact /> is the small version in the cart drawer. It's
on the home page, the locations page, the checkout and in the drawer, and every copy shows the same saved
order.
checkPostcode() in app/utils/order.ts reads the 5-digit ZIP code (with an optional +4, 06511-1234 → 06511) and finds the zone in ZONES that lists it. The result shows the shop, fee, minimum and delivery
time, and warns when that shop is closed. A valid ZIP code outside every zone gets a "we don't deliver there
yet" message. Press Enter in the field to check. The choice is saved (see
Saved data) and shown in the header. The radios and the ZIP code field have
labels; the result is a polite live region.
Open now and opening hours#
storeStatus() in app/utils/hours.ts compares the current time in TIMEZONE (not the visitor's clock) with
STORES[].hours. <StoreStatus store="ancoats" /> shows "Open until 10:00 PM", "Closing soon, last orders 10:00 PM",
"Opens today 4:00 PM" or "Opens Tuesday 4:00 PM" with a colored dot; <TodayHours /> and <HoursRows /> show
today's hours and a table, Monday first, with today highlighted. They read the clock with useShopNow(),
which updates every minute. The generated HTML can't know the time, so these are empty until the page loads
(no hydration mismatch).
Menu filters and section nav#
The menu page (app/pages/menu.vue) renders every section and card at build time from SECTIONS and MENU,
and adds the filters once the page has loaded: the chips (aria-pressed toggle buttons) show items
that have all selected diets; empty sections and their nav links hide, the count updates in a live region and
"Clear" resets. The URL follows the filters, so links like /menu?diet=vegan or /menu?diet=vegan,spicy work
(gluten-free, veggie and hot are accepted too). /menu?item=margherita opens that item straight away.
The section nav sticks under the header and highlights the section in view (an IntersectionObserver
scrollspy).
Item customizer#
openCustomiser(id) from useOrderUI() (app/composables/useOrderUI.ts) opens the dialog in
app/components/ItemSheet.vue (PizzaCustomiser.vue) for a pizza: size (with the price of each size), crust, half and half, up
to six extra toppings and quantity. The picture, price, diet tags and allergens update as you choose. Rules
come from the data:
- Crusts with
sizes(gluten-free is Small only, stuffed crust is Regular and Large) switch the size for you, with a note explaining why; picking a size the crust doesn't come in switches back to classic. - Half and half is Regular and Large only and is charged at the pricier half (
pizzaUnit()inapp/utils/pricing.ts). - Extras add
SIZES[].extraeach; meat, fish, dairy and honey extras remove the vegan / vegetarian tags (pizzaDiet()), and their allergens are added (pizzaAllergens()).
For example a Large, half Margherita / half Green Machine with extra nduja costs $26.00 and is tagged Spicy
only. Sides, dips, desserts and drinks have no options, so addItem("cola") from useOrderUI() (the button on
each menu card) adds one straight to the cart with a toast; for pizzas it opens the customizer.
The dialog is the native <dialog> element, so focus stays inside it, Esc or a click on the backdrop closes
it and focus returns to the button that opened it. On phones it opens as a bottom sheet.
Cart drawer#
CartDrawer.vue opens from the header button, the mobile "View your order" bar (OrderBar.vue) and any
<CartOpenButton>. It shows the delivery checker, each line with picture, options, quantity stepper,
Edit (pizzas and deals reopen their dialog with "Update item"; the drawer comes back afterwards) and
Remove, notes for the kitchen (200 characters, saved as you type), a promo code field and the totals.
Above the checkout button it shows a minimum-order warning for the zone, free-delivery progress, or a prompt
to add a ZIP code; the checkout button is disabled (and focuses the ZIP code) until the order can go through.
totals() in app/utils/order.ts works out everything: subtotal, promo discount, delivery fee (free over
FREE_DELIVERY_OVER or with a delivery code; pickup is always free), the zone minimum and the total. The drawer
traps focus, closes on Esc or the backdrop, returns focus to the button that opened it, and keeps focus on the
same control when lines change.
Promo codes#
PROMOS holds the demo codes, applied in the drawer or at checkout:
| Code | Type | Effect |
|---|---|---|
SLICE10 |
percent |
10% off menu items (not deals or bundles) |
FREEDELIVERY |
delivery |
Free delivery |
FIRSTSLICE |
amount |
$5 off orders of $30 or more |
One code at a time. Unknown codes show an inline error linked to the field. Codes are checked in the browser, so anyone can see them in the source: a real shop must validate codes on its server.
Deals and bundles#
DEALS defines set deals. A slot is either a choice ({ choose: "pizzas", size: "regular", label: "Pizza one" }
or any section id) or a fixed item ({ item: "garlic-knots" }). art lists the pizzas drawn on the deal card
(DealCard.vue, rendered at build time). The deal picker (openDeal(), DealPicker.vue) shows a select per choice and how much
the deal saves against the same items bought separately (dealWorth()). Deal pizzas are classic crust with no
extras.
The bundle builder in app/pages/deals.vue lets you pick Regular pizzas, sides, desserts and drinks from BUNDLE_SECTIONS, up to
BUNDLE_MAX items. The best tier in BUNDLE_TIERS you qualify for applies to the whole bundle (two pizzas and
a side for 15% off, three pizzas and two sides for 20%), with a meter and a hint for the next tier. Add
bundle is enabled once a tier is unlocked. Bundles appear in the cart as one line.
Checkout and time slots#
app/pages/checkout.vue shows the empty state, the form, or the confirmation of the latest order. The form has four
steps:
- Delivery or pickup: the delivery checker.
- When: "As soon as possible" is offered only while the shop is open and can still make the order today;
otherwise "Schedule for later" is chosen.
timeSlots()builds slots everySLOT_STEPminutes for today and the next days (SCHEDULE_DAYS): the first slot is opening time plus the lead time (pickupMinsordeliveryMins), or now plus the lead time for today, and the last is closing time. - Your details: name, cell number, email, and for delivery a street address and rider instructions.
- Payment: card (number in groups of four, expiration date
MM / YY, CVC) or pay on delivery / at the counter. A note says no payment is taken on the site and any 16-digit number will do; remove it once you connect a payment provider.
On submit the fields are validated with inline errors and the order is checked against the delivery minimum and ZIP code. A valid order is saved as the latest order, the cart is cleared and the confirmation is shown. Nothing is sent: see Forms for where to connect a real order system.
Prices and totals don’t include sales tax, and the note under the checkout button says so. Add your rate when you connect a real order system, or change the note if your prices include it.
Order tracker#
OrderTracker.vue shows the order number (CC- and four characters, made in the browser by makeRef() in
app/utils/format.ts; use your ordering system's own number instead if it returns one), a summary, a note that
the receipt is on its way by email (the checkout asks for an email "for your receipt") and a tracker with four stages (received, in
the oven, out for delivery, delivered; for pickup the last two are "Boxed & ready" and "Picked up"). In the demo
each stage lasts TRACKER_STEP_SECONDS (8 seconds) from the time the order was placed, so it keeps its place
if you reload. A rider (or bag) icon moves along the bar, the current stage pulses and the status line is a live
region. Stages are in STAGES; to show real progress, replace the timer in OrderTracker with updates from
your kitchen or delivery system. Start a new order clears it.
Locations map#
The map on the locations page is inline SVG. Each zone is a <path data-map-zone="city"> filled with the
zone's color; ZONES[].color is used for the swatch in the zones table built from ZONES, so keep the two in
step. Hovering a row or zone highlights it in both, and a checked ZIP code highlights its zone. The map is decorative (it has a text
description); the table carries the same information. To draw your own area, replace the paths and pins in
app/pages/locations.vue and keep the ids in step with ZONES.
Catering calculator#
On the catering page, guests (10–300) and appetite (APPETITE: slices per person) give the number of large
pizzas (8 slices each), a suggested veggie / vegan / meaty mix, sides and an estimate using the average large
pizza price and CATERING_DISCOUNT (10% off 10+ pizzas, 15% off 20+). Use these numbers copies the guest
count and a summary into the inquiry form and moves focus to it.
Scroll reveal, motion and back to top#
Elements with .reveal fade in as they scroll into view (app/plugins/reveal.client.ts); anything above them is revealed
too, so jumping down the page never leaves gaps. The hero ingredients float (.bob), the round badge spins and
menu-card pizzas turn on hover. All motion is off for visitors who prefer reduced motion. The back-to-top button
(BackToTop.vue) appears after one screen; it lifts above the toast and the mobile order bar and hides while it
would cover the bundle summary. Remove it from app/layouts/default.vue and the .back-to-top styles to drop it.
Forms#
All forms validate in the browser with inline messages (rules in app/utils/validation.ts, wiring in
app/composables/useValidation.ts) and finish with an in-world confirmation. The catering inquiry
confirms the party order, promises a reply within one business day with a menu and a fixed quote (as the catering
page says; change both if yours differs) and shows a reference (CC- and four characters, from makeRef(); use
your form service's own reference instead if it returns one). The newsletter asks subscribers to look out for a
confirmation email. None of them send anything until you connect them.
| Form | Where | Validates |
|---|---|---|
Newsletter (data-form="newsletter") |
app/components/NewsletterForm.vue, footer of every page |
|
Catering inquiry (data-form="catering") |
app/pages/catering.vue |
Name, email, phone, guests 10–300, date at least two days ahead (in the shop's time zone), time 11:00 AM–10:00 PM, delivery or pickup, delivery ZIP code (5 digits, optional +4, only when delivering), consent |
Checkout (#checkout-form) |
app/pages/checkout.vue |
Delivery ZIP code in a zone, minimum order, a time slot, name, phone, email, address (delivery), card fields (card payment) |
Rules come from attributes on each field: required and data-label ("Email is required."), the input
type, data-validate (postcode, card, expiry, cvc), min / max and data-min-msg. Errors appear
in <p id="<field id>-error"> right after the field, linked with aria-describedby and aria-invalid, and the
first problem is focused.
To connect the inquiry form to a form service such as Formspree, post the data in
submit() in app/pages/catering.vue before showing the thank-you message:
async function submit() {
if (!v.validate()) return (formStatus.value = "Please fix the highlighted fields.");
const res = await fetch("https://formspree.io/f/your-id", {
method: "POST",
body: new FormData(v.form!),
headers: { Accept: "application/json" },
});
if (!res.ok) return (formStatus.value = "Sorry, that didn’t send. Please try again or call us.");
sent.value = { /* ... as before ... */ };
}The newsletter works the same way in NewsletterForm.vue. If you deploy with pnpm build (a Node.js server)
instead of pnpm generate, you can post to your own server route (for example server/api/inquiries.post.ts).
For real online ordering, submit() in app/pages/checkout.vue builds a placed object with the lines, totals, mode and
time; send that to your ordering system or payment provider (for example a Stripe Checkout session created by
your server) where the comment says so, and only show the confirmation when it succeeds. Never trust prices from
the browser: recalculate them on the server.
Saved data (localStorage)#
| Key | Shape | Notes |
|---|---|---|
crustclub-order |
{ mode: "delivery" | "pickup", postcode: "06511", store: "ancoats" } |
Delivery or pickup choice. The zone is worked out again from the ZIP code on every read. |
crustclub-cart |
{ lines: [...], promo: "SLICE10", notes: "..." } |
Lines are { type: "pizza", id, size, crust, half, extras, qty }, { type: "item", id, qty }, { type: "deal", id, picks, qty } or { type: "bundle", items: [{ id, qty }], qty }. |
crustclub-last-order |
The confirmation: number, time placed, mode, summary, lines and totals | Shown on /checkout for six hours while the cart is empty. |
Every read is validated by readOrder(), normalizeLine() and readCart() in app/utils/order.ts: unknown
items, sizes, crusts, toppings and codes are dropped, crusts and half-and-half are corrected for the size,
quantities are clamped to 1–MAX_QTY and duplicate lines are merged. Prices are always recalculated from
MENU. Components read the order with useOrderState(), which holds an empty cart until the component has mounted
(while the page is generated and hydrated), and other open tabs update through the storage event. To reset,
clear the site's storage in the browser's developer tools, or run localStorage.removeItem("crustclub-cart") in
the console. Change the key names if you run several sites on one domain.
SEO and sharing#
- Each page calls
usePageSeo({ title, description })(app/composables/usePageSeo.ts), which sets the title, description, canonical link, Open Graph tags (with the imageimg/og-image.png) and the Twitter card. The 404 page passesnoindex: true. - Canonical,
og:urlandog:imageare absolute:SITE_URLplus the base path. Build with your domain so social images get absolute URLs (the default ishttps://slice.example):
SITE_URL=https://www.your-pizzeria.example pnpm generateThe menu, bestsellers and deals are in the generated HTML, so every search engine sees them. Consider adding a
public/robots.txt, a sitemap and Restaurant structured data as JSON-LD with
each shop's address, phone, openingHours and hasMenu.
Accessibility#
- Skip link, landmarks (
header,nav,main,footer), oneh1per page and a logical heading order. - Visible focus outlines everywhere, including radio cards, chips and the Delivery / Pickup switch.
- The mobile menu button has
aria-expandedand closes on Esc. - The customizer and deal pickers use the native
<dialog>; the cart drawer isrole="dialog"witharia-modal, a focus trap and Esc to close. - Filter chips are toggle buttons (
aria-pressed); the result count, ZIP code result, bundle hint, toasts and tracker status are live regions. - Quantity steppers have labeled buttons ("One more Margherita"), and remove / edit buttons include the item name for screen readers.
- Diet tags have text, not just color; allergens are written out.
- Form errors are linked with
aria-describedby, fields getaria-invalidand the first error is focused. - Reduced motion turns off reveals, floating, spinning, card turns and tracker animations.
- Colors meet WCAG AA contrast. Keep text on
tomatoandbasilat full opacity when you edit.
When editing, keep exactly one h1 per page, give every new input a <label>, and keep alt text on
meaningful images.
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 menu.html, checkout.html and so
on; most static hosts serve /menu from menu.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=/slice pnpm generate): it
becomes app.baseURL in nuxt.config.ts. pnpm build:demo does this for the freeframe.dev demo and writes
demo/, which you can delete.
Need server features (an order API, server-side promo checks)? pnpm build makes a Node.js server build in
.output/ (pages are still prerendered); add routes in server/api/ and deploy to a Node-capable host.
pnpm lint runs ESLint 10 with @nuxt/eslint. 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#
- "Open now" is wrong. Hours are in
TIMEZONE(app/data/shop.ts). CheckSTORES[].hours(Sunday first) and setTIMEZONEto your shop's zone, for example"America/New_York". - My ZIP code isn’t recognized. Zones match 5-digit ZIP codes: add it to
ZONES[].districts. For other countries, changepostcodeDistrict()andcheckPostcode()inapp/utils/order.tsto read your format and list those codes inZONES[].districts. - The cart shows old items or totals after I changed the menu. Lines whose item, size or topping no longer
exists are removed on the next read. Clear
crustclub-cartto start fresh. - There are no time slots. The shop is closed for the next
SCHEDULE_DAYSdays, or the lead time runs past closing. Check the hours,pickupMinsanddeliveryMins. - A new class has no effect. Tailwind only finds full class names in the source; write
bg-basil, not"bg-" + color. - Images or links break under a sub-folder. Build with
DEMO_BASE_PATHand reference public files with the base path (`${$config.app.baseURL}img/hero-pizza.svg`);<NuxtLink>adds it automatically. - Social previews point to
slice.example. SetSITE_URLwhen building. - Forms "send" but nothing arrives. Connect them as described in Forms.
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. Rubik and
Shrikhand are licensed under the SIL Open Font License (app/assets/fonts/OFL-rubik.txt,
app/assets/fonts/OFL-shrikhand.txt). Crust Club, its team and its customers are fictional.