Shops templates

Full design · Shops

Cafe ordering

An order ahead app for a neighbourhood cafe.

Live preview. Click around, it works.

About this template

An order ahead app for a neighbourhood cafe. Customers browse a menu with sizes, milks, extra shots and dietary tags, pick a pickup time in five minute slots that respect opening hours and kitchen capacity, check out, and watch their order go from received to ready. A stamp card gives every tenth coffee free, automatically. The owner gets a live order queue with bump buttons and ticket times, a menu editor with sold out switches, today's revenue and busiest half hour, and settings for hours, slot capacity and loyalty rules.

What the agents are asked

The first message sent when you use this template. You can edit the plan before anything is built.

Adapt this cafe ordering app to my business. Keep the order ahead flow (menu with options, cart with pickup time slots, checkout, live order tracking), the stamp card, and the owner side (live order queue, menu editor, today's numbers, settings), but change the brand, the menu, the prices, the opening hours and the loyalty rule to match my cafe. Keep it fast to use on a phone.

An order ahead app for a neighbourhood cafe. Customers browse the menu, choose a size, milk and extras, pick a pickup time in five minute slots, check out, and watch their order go from received to ready. A stamp card gives every tenth coffee free, automatically. The owner runs a live order queue, edits the menu, sees today's numbers and sets hours, slot capacity and the loyalty rule.

"Tinbird" is a made up cafe. Rename it, and change the menu, prices and hours to your own.

This is not the restaurant menu template. That one is a single page showing a fine dining menu. This one takes orders and money, and has a back of house.

Files

FileWhat it is
index.htmlShell: top bar (brand, navigation on desktop, the "Viewing as" switch, theme button), main, bottom tab bar on phones, one <dialog> for sheets, the toast region.
styles.cssTokens first: a light theme (Stone ground) and a dark theme, switched by prefers-color-scheme or the theme button, then components, screens and motion. Every colour is a token.
script.jsOne plain ES module, no dependencies. Sections: helpers, icons and photos, menu seed, sample orders, store, pricing and loyalty, pickup slots, demo timer, chrome, router and motion, customer views, owner views, sheets, events.
media.jsonThe photographs and the one video, with the prompts they were made from (see Art direction).
schema.sqlPostgres tables, row level security, grants, the place_order function, seed rows, and a VERIFY block.
template.jsonCatalogue entry.

Serve the folder with any static server. There is no build step. The photographs live outside the folder, in NovaBuild's public/template-media/cafe-ordering/, so to see them locally serve that folder at /template-media/ as well (see Art direction).

Art direction

A pickup window at seven in the morning. The page reads like the cafe's own signage: condensed capitals on a dark sign panel, one warm signal colour, and photographs from a single morning shoot doing most of the selling.

Palette. Three solid colours, no gradients.

TokenHexUsed for
Stone#eeede8The light ground. A cool off white, not cream.
Char#1b1917Text, the sign panels (hero, stamp card, story, owner bar, phone tab bar) and the dark ground.
Yolk#f2b230The signal: primary buttons, the next pickup time, stamps, the current tracking step, the busiest bar. Text on Yolk is always Char; on Stone, Yolk is a fill, never text (Yolk text on Stone fails contrast, so --accent-text is a deep #7a5300).

The dark theme swaps the grounds (#141311, surfaces #1d1b18) and keeps Yolk. Functional red is kept for errors, late tickets and sold out only.

Type. Big Shoulders (display, optical sizes 10 to 72, weights 600, 800, 900) for headings, prices, order numbers and section names, set in capitals with tight leading, like a menu board. Hanken Grotesk (400 to 700) for reading, at 16 px with a 1.6 line height and tabular figures for prices and times. Scale ratio 1.333. Quantities on dockets use Hanken, because a condensed 1 reads as an I.

Layout. Customer screens are about half photography, each laid out differently: the Menu hero runs the window photograph to the left edge of the screen with the sign panel beside it; each menu section leads with one photographed item that stays put beside the typeset list on wide screens; Checkout, Tracking and Orders have a full height photograph column that stays put while the page scrolls (Orders mirrors it to the right); the Cart opens with a banner that runs off the right edge. Images are square cornered. Owner screens stay efficient: the same type and colours, a Char top bar so back of house is never mistaken for the shop, and photo thumbnails in the menu editor.

Menu items. Five items have their own photograph (flat white, magic, iced latte, smashed avo, butter croissant). A section's first photographed item leads it; everything else is set as a menu row: name, dotted leader, price, description, tags. Where a small square is needed (cart lines, menu editor) an item without a photograph gets a typeset tile with its initials in its section's colour, and the item sheet gets a Char header with the section name. Which items have photographs is the ITEM_PHOTO map in script.js; in the real app it is an image_url on menu_items (see Plugging in the backend).

Photography. Seven photographs and one video from one shoot, all at 7 am with low warm sun from the left, crisp long shadows, warm highlights and cool charcoal shadows, a 50 mm look at f/2.8 and fine film grain. Real hands at work, natural skin, no text or logos. The prompts are in media.json. Every image sits on a painted fallback colour (--photo-bg) so the layout holds while it loads, has width and height set, and loads lazily except the hero.

FileSizeWhere
cafe-window.webp1600 × 1067Menu hero (loads first)
latte-pour.webp1000 × 1500Checkout column, the video's poster, Magic
flat-white.webp1000 × 1241Coffee section lead, stamp card, empty cart
cold-drink.webp1000 × 1241Cold drinks section lead (iced latte)
pastry.webp1000 × 1241Bakery section lead (butter croissant), Orders column
breakfast-plate.webp1000 × 1241Breakfast section lead (smashed avo)
pickup-window.webp1000 × 1321Cart banner, Tracking column
pour-loop.mp4720 × 1080, 5 s, about 264 KB, no audioThe Menu story, "Made when you are nearly here"

Paths are written exactly as /template-media/cafe-ordering/<name>.webp; project creation makes them absolute.

Video. One five second loop made from latte-pour.webp: the rosetta forms and a wisp of steam rises. It is decorative (aria-hidden="true"; its figure carries the description), muted, playsinline, loop, preload="metadata", with the photograph as its poster. It never autoplays under reduced motion (the poster stays), and it plays only while at least a tenth of it is on screen in a visible tab; otherwise it is paused.

Motion. Each piece has one job, and all of it is off under prefers-reduced-motion:

  • First load: the hero (or page head) rises in a short stagger, once.
  • Scroll: section heads and the story's beats reveal with animation-timeline: view() where the browser has it, and an IntersectionObserver class at threshold 0.2 where it does not.
  • Photographs: a clip-path: inset() reveal with a slight scale, once per arrival on a screen.
  • Sticky columns: the section lead beside the menu list, and the photograph column on Checkout, Tracking and Orders.
  • Screen changes: document.startViewTransition cross fades, where supported and the tab is visible.
  • Hovers: 150 to 200 ms, eased, no bounce.
  • The one orchestrated moment: when an order is placed, its docket prints. The paper feeds down out of the window (a clip-path reveal), the order number is stamped, then Received lights up in Yolk. It plays once, when the tracking page is arrived at from checkout, never on the live refreshes that follow.

Data refreshes (a ticket bumped, the demo timer) re-render the page, so they mark everything as already shown rather than animating it again.

Two roles

The "Viewing as: Customer | Owner" switch in the top bar changes role in demo mode. In the real app the role comes from sign in: customers are any signed in user, owners and staff are rows in cafe_staff.

Customer

RouteScreen
#/menuMenu. The window photograph beside a sign panel: open now or closed (from the opening hours), the next pickup time, a stamp card peek. Sections jump from a rail that stays at the top while you scroll. Each section leads with a photographed item where it has one; the rest is a typeset menu. Dietary filters: vegetarian, vegan, gluten free, dairy free, hide nuts. Each item shows price, dietary tags and a stamp icon if it earns one. Sold out items stay visible with a Sold out stamp and cannot be added. On desktop a live order summary sits on the right; on phones a "View order" bar floats above the tab bar. The page ends with "Made when you are nearly here", three short beats about how the window works beside the pour video.
item sheetItem. Opens over the menu. Option groups follow their rules: pick one and required (Size, Milk), optional pick one with a None choice (Sugar), up to N (Extras, once the maximum is reached the rest disable). Sold out options (Almond milk in the sample) are shown and disabled. The button shows the live price with options and quantity. Editing a line in the cart reuses this sheet.
#/cartYour order. Lines with options and extra charges, quantity steppers, edit and remove (with Undo). The stamp card is applied as you build the order and the free coffee is marked on its line. Pickup time: "As soon as possible", then up to three open days, hours, and 5 minute slots. Slots run from opening (or now plus the lead time) to 5 minutes before close. A slot with as many orders as the capacity shows Full and cannot be picked; one place left shows "1 left". If a chosen slot fills or passes, the page says so and asks for another. Notes for the counter, 200 characters.
#/checkoutCheckout. Name for the order (required), mobile (optional, validated), summary with pickup time, notes and totals, and a payment panel that says "Demo checkout. Connect Stripe in project settings." Paying places the order through the same checks the server runs (paused, sold out, slot still free) and shows a plain message if one fails.
#/order/:idOrder tracking. The order number large, pickup time and how long until it, four steps (Received, Making, Ready, Collected) with the time of each, and a line saying what is happening. In demo mode the order moves along on a timer (12 s, 25 s, 20 s) with Pause and "Next step now". The owner's bump buttons move it too. Receipt, stamps earned, Order this again.
#/ordersYour orders. On the way, then earlier.
#/loyaltyStamp card. A card with one stamp per coffee and the free slot at the end, how many to go, how it works (which items count), and the history of stamps earned and free coffees used.

Owner

RouteScreen
#/owner/queueOrder queue. Three columns: Received, Making, Ready, sorted by pickup time. Each ticket shows the order number, name, pickup time and how long until it (or how late), the ticket time since the order came in (yellow at 6 minutes, red at 10), the items as a docket with options, notes, and any free coffee. The bump button moves it on: Start making, Mark ready, Collected, with Undo. Orders for more than 30 minutes ahead are marked Later. "Taking online orders" pauses checkout for customers. "Simulate an order" adds a sample order so the queue can be tried. On phones the columns scroll sideways.
#/owner/menuMenu editor. Items by section with inline price and a Sold out switch; Edit opens a sheet for name, description, section, price, dietary tags, option groups, whether it earns a stamp, and sold out. Items are removed with a confirm in the sheet and Undo; they are archived, not deleted, because past orders point at them. The Options and extras tab edits each option's name, extra charge and sold out state, adds options and removes them (with Undo).
#/owner/todayToday (and Yesterday). Orders, revenue, average ticket and free coffees, each compared with the day before. Busiest half hour as an SVG bar chart across opening hours: the busiest bar in the accent colour with its count, the rest muted, a tooltip on hover and keyboard focus, and "Show as a table". Most ordered items.
#/owner/settingsSettings. Opening hours per day (open switch, from and to, validated). Pickup capacity per 5 minute slot, earliest pickup (lead time), taking online orders. Stamp card on or off and how many coffees before one is free. Cafe name and pickup address. Sample data: remove, or start over.

Phones get a bottom tab bar with four tabs per role; from 900 px a top navigation replaces it.

The rules, in one place

  • Price of a line = item price + the extra charge of each option chosen, times quantity. Prices are cents.
  • Pickup slots: 5 minute steps from opening to 5 minutes before close. Today starts at now plus the lead time (10 minutes by default), rounded up to the next slot. A slot is full when it holds slot_capacity orders that are not cancelled. The server takes an advisory lock on the slot while counting, so two customers cannot both take the last place.
  • Stamp card (loyalty_stamps_needed, default 9): each coffee in an order either earns one stamp or, if the card already has 9, is free and uses 9 stamps. Worked through in order, so with 8 stamps and two coffees the first earns the ninth and the second is free. The free ones go to the most expensive coffees, extras included. Items count when counts_for_loyalty is set (all of Coffee, plus iced latte and cold brew in the sample).
  • Order numbers count up from 1 each day.
  • Ticket time runs from when the order came in. Yellow at 6 minutes, red at 10, for Received and Making.

Data model

store in script.js holds one object with the same tables and column names as schema.sql:

cafe_settings             { cafe_name, address_line, opening_hours[{dow, open, close, closed}], slot_minutes,
                            slot_capacity, lead_minutes, accepting_orders, loyalty_enabled, loyalty_stamps_needed }
menu_categories[]         { id, name, blurb, sort_order }
menu_items[]              { id, category_id, name, description, price_cents, dietary_tags[], counts_for_loyalty,
                            sold_out, art, sort_order, is_sample, archived_at }
modifier_groups[]         { id, name, selection (single | multi), required, max_select, sort_order }
modifiers[]               { id, group_id, name, price_cents, is_default, sold_out, sort_order, archived_at }
menu_item_modifier_groups[] { item_id, group_id, sort_order }
orders[]                  { id, order_number, customer_id, customer_name, customer_phone, status, pickup_at,
                            placed_at, making_at, ready_at, collected_at, notes, subtotal_cents, discount_cents,
                            total_cents, payment_status, is_sample }
order_items[]             { id, order_id, item_id, item_name, qty, unit_price_cents, modifiers[], line_total_cents,
                            loyalty_free_qty }
loyalty_cards[]           { id, customer_id, created_at }
loyalty_stamps[]          { id, card_id, order_id, delta, reason (earned | redeemed | adjusted), note, created_at }

Demo only fields, not in the schema: orders.demo_auto, sim_due_at and sim_paused drive the tracking timer. The cart (tinbird.cart.v1) and preferences such as role and theme (tinbird.prefs.v1) stay in the browser.

schema.sql adds cafe_staff (who is an owner), orders.order_day (for the daily number), the loyalty_balances view, and place_order. Row level security:

TableAnyoneSigned in customerStaff
cafe_settings, menu tablesread (live rows)read (live rows)read and write; menu rows are archived, not deleted
orders, order_itemsnoneread their ownread all; update status and its timestamps only
loyalty_cards, loyalty_stampsnoneread their ownread all
cafe_staffnonenoneread their own row; start_as_owner and claim_invite are the only writes

Nobody writes an order, a price, a total or a stamp from the browser. place_order does it, and only the service role can call it. Stamps are append only (a trigger refuses update and delete for every role), so a balance is always a sum of rows and never a counter that can drift.

schema.sql was run in PGlite (Postgres 16 in WASM) with stubs for Supabase's auth schema and roles: 37 checks passed, covering the grants, each role's reads and refused writes, pricing, slot capacity, option rules, the free coffee and append only stamps. Run the VERIFY block against your own project after applying, before trusting it.

What is sample or simulated

  • Sample data: 26 menu items (Almond milk, orange juice and the almond croissant sold out), yesterday's orders across opening hours, today's orders up to now, a live queue of seven orders, and a customer, Sam, with two past orders and 8 stamps. Menu items, orders and stamps carry a Sample tag. The sample orders and stamps are versioned (SAMPLE_VERSION in script.js): on a new day, or whenever this browser's saved version does not match, they are made again fresh; your own orders, menu edits, cart and settings always stay. Settings, "Remove sample data" archives the sample items and removes sample orders and stamps, keeping sections, options, hours and settings; once removed, a version change will not bring them back.
  • Checkout is a demo: no card is charged. It says "Demo checkout. Connect Stripe in project settings."
  • Tracking moves on a timer for orders placed in the demo, and says so on screen.
  • Sign in is simulated by the role switch. The customer is always Sam.
  • Nothing is sent anywhere. Everything is kept in localStorage under tinbird.*.

Plugging in the backend

Today this template is demo only: script.js has no Supabase mode and no sign in screen, everything lives in localStorage, and the "Viewing as" switch stands in for a real session. schema.sql, including the functions below, is written and ready for whoever wires a real backend up: that is a separate, larger piece of work than this schema.

  1. Apply `schema.sql` to a new Supabase project (SQL editor) and run the VERIFY block. Turn Confirm email on under Authentication settings first: without it, anyone could sign up as an address someone else was meant to be invited on. Nobody starts as owner automatically: sign in as the person who should run the cafe and call start_as_owner('Their Name'). It only ever succeeds once, for whoever gets there first, and is refused once a real owner exists. To add a second staff member later, insert a cafe_staff row with just name, email and role; once that person signs in with the same, confirmed email, claim_invite() links their account automatically. Neither function takes a client supplied id: each acts only on the caller's own, confirmed email.
  2. Swap the store. Everything the screens read goes through store, and everything they write goes through its methods. Replace store.init() with loading the tables (menu tables and settings for everyone; the customer's orders, cards and stamps, or all live orders for staff) into store.data, and replace each write:

``js // before: store.updateItem(id, patch) changes the cache and saves to localStorage async updateItem(id, patch) { const { error } = await supabase.from('menu_items').update(patch).eq('id', id).select().single(); if (error) throw new UserError('That change did not save. Check your connection and try again.'); Object.assign(this.item(id), patch); this.commit(); } ``

setOrderStatus becomes an update of status and the matching *_at column. placeOrder becomes a call to the server function below. Subscribe to orders with Supabase Realtime and, on each change, update store.data.orders and call store.commit(): the queue and the tracking page already re-render on commit, and keep what someone is typing. Delete the demo timer (tick's first loop) and the "Simulate an order" button.

Item photos. schema.sql has no photo column yet: the template's photographs are fixed per item in ITEM_PHOTO. To let the owner set their own, add image_url text to menu_items in a new additive migration, store uploads in a Supabase Storage bucket, and make itemPhoto() return item.image_url when it is set. Items without one keep the typeset tile, so the menu never shows a broken image.

  1. The server function `place-order` (a Supabase Edge Function). It reads the customer from the request's JWT, calls place_order(customer_id, name, phone, pickup_at, notes, lines, 'pending') with the service role key, then creates a Stripe PaymentIntent for total_cents and stores its id in orders.stripe_payment_intent_id. The page confirms payment with Stripe's Payment Element. Keys live on the server only: the Stripe secret key and the service role key never reach the browser.
  2. A Stripe webhook (stripe-webhook function) marks the order paid on payment_intent.succeeded. On payment_intent.payment_failed or cancellation it sets the order cancelled (which frees the slot) and, if the order used or earned stamps, inserts one adjusted stamp row that reverses them. Show the queue only orders that are paid (or demo), so unpaid orders never reach the machine.
  3. Time zones. The browser shows local time. The server checks opening hours in cafe_settings.timezone (Australia/Sydney by default); set it to the cafe's zone.

Left out on purpose

Real accounts and sign in screens, refunds and cancelling from the queue, tips, table service and dine in, scheduled menus (breakfast until 11:30 is only text), category editing, uploading item photos (the sample photographs are fixed per item), printing dockets, SMS when an order is ready, and busy mode that stretches the lead time automatically. Each is a good next step and most need the backend.

Dietary tags are a guide, not a guarantee. The menu says the kitchen handles common allergens and asks customers to mention allergies in their notes. Keep that line, or one like it, in any real version.