A mobile-first ordering page for small cafés and restaurants in Malaysia. Customers scan the QR code on their table, browse the menu in English, Chinese or Malay, pick their dishes and options, and the order arrives on the shop's WhatsApp, already tidied up with the table number and total. No app to install, no server and no customer data stored.
Live demo: https://jrl-qr-ordering-demo.netlify.app/?table=5 (a made-up shop, "Jade Noodle House"; no real orders are sent)
| Menu | Choose options | 中文 | WhatsApp message |
|---|---|---|---|
![]() |
![]() |
![]() |
![]() |
- Scan and order: each table has its own QR code (
?table=5); the table number goes into the order. - Three languages: English, 中文 and Bahasa Malaysia, switchable at any time and remembered on the phone.
- Dish options: pick-one choices such as hot or iced, sweetness or spice level, with price changes, plus a special note.
- WhatsApp hand-off: the customer checks the exact message, then sends it to the shop's WhatsApp. Totals include service tax when the shop uses it.
- Opening hours: outside opening hours the menu stays readable but ordering is paused, and the page says when the shop opens again.
- Printable QR cards: a staff page (
/qr) prints table cards on A4, 4 or 8 per page. - One folder per shop: a new shop is a copy of a sample folder (
shops/). A built-in checker stops the build with a plain-English list of mistakes (missing translation, colour too light to read, bad phone number…).
Next.js 16 (static export, no backend) · React 19 · TypeScript · Tailwind CSS 4 · Vitest (unit tests) · ESLint · qrcode · WhatsApp click-to-chat links · Netlify hosting
Built by Jarrell (MMU Diploma in IT student) with AI coding assistants: OpenAI Codex for the first version, then Claude Code. I set the requirements and made the design decisions, reviewed every change through pull requests, and tested on desktop and phone.
© 2026 Jarrell. All rights reserved. The code is public so it can be read as a portfolio piece; it is not open source. See LICENSE.
- Install Node.js 22 or newer.
- Run
npm ciin this folder. - Run
npm run dev. - Open
http://localhost:3000/?table=1(use the exact port printed by the terminal).
The ordering page reads the table number from ?table=1. If it is missing, the customer sees a clear warning and cannot continue to WhatsApp.
Each shop has its own folder in shops/. There are two made-up sample shops:
-
shops/jade-noodle-house/— the demo the live site is built from (noodle shop, English/Chinese/Malay, 6% service tax). -
shops/jrl-cafe/— a small café set up differently on purpose (English and Malay only, no service tax, no wait time, brown theme). It is a good folder to copy. -
shops/jade-noodle-house/menu.ts— dish names in English, Chinese and Malay; descriptions; prices; categories; availability; option groups (e.g. hot or iced, sweetness, spice level); and safe photo placeholders. -
shops/jade-noodle-house/config.ts— everything else about the shop: name, logo letter, page title, languages offered, menu tabs, hero text, service tax, currency, WhatsApp number, number of tables, brand and accent colours (used for the menu page and the QR codes; the link-preview picturepublic/og.pngis still the demo's), site address and demo mode.shops/types.tsexplains every setting. -
shops/theme.ts— works out the lighter shades of the brand colour.app/globals.csshas the neutral colours at the very top under:root.
app/data/shop.ts picks which shop folder the site is built from.
- Copy a sample folder. Copy
shops/jrl-cafe/and rename the copy, e.g.shops/my-cafe/(lowercase letters, digits and dashes). - Fill in
config.ts. Change the name, logo letter, page title, hero text, categories and colours.shops/types.tsexplains every setting. Give it its owncartStorageKey(e.g.'my-cafe-cart-v3'), otherwise a phone that visited another shop could load that shop's cart. - Choose the languages. List them in
languages(the first is the default). Every text needs an entry foren,zhandms, but a language you don't offer can stay''. - Write the menu in
menu.ts. One entry per dish. Eachcategorymust be one of the ids inconfig.ts. See "Add choices to a dish" below for options such as hot or iced. - Build it. In
app/data/shop.ts, change both lines fromjade-noodle-houseto your folder name. Runnpm run devand openhttp://localhost:3000/?table=1. If something is wrong, the terminal shows a plain-English list of problems (a missing translation, a colour too light to read, a dish in a missing category…). Fix them and save. - Check it. Add a few dishes to the cart, open the message preview, switch languages, and open
/qr. - Run the tests.
npm testchecks every folder inshops/with the same rules as the build, so a broken sample shop is caught even when it isn't the one being built. The other tests are written for the demo shop, so pointapp/data/shop.tsback atjade-noodle-housebefore running them (or before committing to this repo). - Before going live (
demoMode: false): set the shop's real WhatsApp number, check the tax rate with the shop, send a test order to that number, and replacepublic/og.png(the link-preview picture is still the demo's). Real shops' settings (phone number, prices) don't belong in a public repo.
demoMode: true (the demo shop) shows "demo only" notes, the ?table=1 hint and a link to the staff QR page in the footer. Set it to false for a real shop: those disappear and the wording is for real customers. The WhatsApp number must be set before turning demo mode off.
Outside opening hours, customers can still read the menu, but the add buttons and the cart are hidden, and the page shows when the shop opens again and the week's hours. In the shop's config.ts:
openingHours: {
mon: [], // closed all day
tue: [{ open: '08:00', close: '14:00' }, { open: '17:00', close: '21:00' }], // with a break
// ...one line for every day, wed to sun
},
timeZone: 'Asia/Kuala_Lumpur',
temporarilyClosed: false, // true = closed now, whatever the hours (a holiday, a busy day)
closedNotice: null, // or text in each language, e.g. "Closed for Hari Raya, back on 2 April"- Times are 24-hour
"HH:MM"at the shop."24:00"means midnight. For hours past midnight, split them over two days. openingHours: nullmeans no fixed hours: always open (the demo shop uses this).- The time comes from the customer's phone, so a phone with a wrong clock can see the wrong status. Orders still reach the shop by WhatsApp, so staff can always say no.
temporarilyClosedis part of the site: changing it needs a new build and release.
Open the shop's menu.ts, find the item and change price: 16.90. Use available: false to show it as sold out. Keep prices as numbers without RM.
A dish can have several pick-one questions, called option groups (for example "Hot or iced" and "Sweetness"). The customer picks one choice in each group; the first choice is picked at the start. In the shop's menu.ts:
optionGroups: [
{ id: 'temperature', label: { en: 'Hot or iced', zh: '冷熱', ms: 'Panas atau ais' }, choices: [
{ id: 'iced', label: { en: 'Iced', zh: '冰', ms: 'Ais' }, price: 0 },
{ id: 'hot', label: { en: 'Hot', zh: '熱', ms: 'Panas' }, price: 0 },
] },
spiceGroup(['none', 'mild', 'medium']),
],priceis added to the dish price (0for no change).spiceGroup([...])(fromshops/optionGroups.ts) adds a ready-made "Spice level" group with the levels you list.- Saved carts use the
ids, so keep them the same when you rename or reorder choices. If you add a group to a dish or remove a choice, carts saved before that drop the dish instead of guessing what the customer wanted.
Open the shop's config.ts. Set serviceTaxEnabled to true or false, and set serviceTaxRate as a decimal: 0.06 means 6%.
Orders go out through the shop's orderChannel; today the only choice is 'whatsapp' (app/lib/sendOrder.ts builds one order and sends it, so a server channel can be added later). Open the shop's config.ts and set whatsappNumber to the shop's number. Use international digits only (country code first), with no +, spaces or dashes. The demo leaves it empty, so WhatsApp asks the customer to choose a chat instead of messaging a made-up number. Always send a test order before showing the site to a restaurant.
Open /qr, choose how many tables (up to the shop's maxTables) and how many cards per A4 page, then press Print. The cards automatically use the current site address, so published cards point to the published site and local test cards point to localhost.
- 4 per page: A6 cards (105 × 148 mm) with a 60 mm code, sized for A6 table stands.
- 8 per page: 105 × 74 mm cards with a 46 mm code, for stickers.
The page shows each A4 sheet as it will print. In the print window choose A4, scale 100% (not "fit to page") and the default margins, then cut along the dashed lines. The words "Table" and "Scan to order" appear in every language the shop offers. A long shop name stops at two lines ("…") on the cards. The zero page margin only applies to /qr, so printing other pages keeps normal margins. Scan one printed card with a real phone before printing them all.
Open /?table=1, add an item, then refresh. The cart remains on that device using browser local storage. No cart data is sent to a server.
Run npm test, npm run lint and npm run build. The tests check prices, totals, the WhatsApp message and how it is sent, the cart, the menu tabs and search, the demo wording, and every shop folder in shops/ (files ending in .test.ts). The build makes a plain static website in the out/ folder. There is no server code, so any static host can serve it.
GitHub Actions runs the same checks (plus a TypeScript type check) on every pull request into develop and after each merge into it (.github/workflows/ci.yml); the badge at the top shows the latest result on develop.
netlify.toml already tells Netlify what to do (build with npm run build, publish the out/ folder).
- In Netlify, choose Add new project → Import an existing project and pick this GitHub repo.
- Keep the settings it reads from
netlify.tomland deploy. - Optional: put the final address in
siteUrlin the shop'sconfig.ts(used for link previews). If it is empty, the address Netlify gives the build is used. - Open
/qron the live site to print cards that point to the live address.



