QRCodeFlip — a multi-context ordering system
[ A QR ordering platform for many hotels, each with its own menus and locations: one product with a guest side and an admin side on one shared domain model — designed and built in React on one design system, on top of the client's existing backend and its data. ]

Many hotels. Any number of locations. One product.
QRCodeFlip is a QR ordering system used by many hotels, each with its own number of service points, and each service point can offer a different menu at different prices. A guest who scans a code lands in that hotel's menu, at the service point where the code sits, and staff run the menus, orders and feedback from an admin application. Both sides are one product, built on the same domain model and design system. The hotel this case follows has five service points: a restaurant, a bar, a lounge, a pool bar and room service.
The client already had a backend holding the menus, outlets, prices and images. It worked, but its interface was data-heavy and hard to navigate, and the hotels found their menu data difficult to manage. That set two constraints from the start: make ordering simple for guests without simplifying the business rules underneath, and make the operation manageable for staff without replacing the client's existing data system. What made it interesting was the relationship between context, catalogue state, basket identity and operational change.
I designed and built the UX architecture, the domain and state model, the design system and the React frontend for version one, around the JSON the client's system already produces. Version one is complete as a frontend prototype, with live basket reconciliation, role-based staff workflows, layouts from phone to desktop in light and dark, accessibility validation and cross-browser QA.
Payment, staff sign-in and the dashboard's activity stay simulated until the client's backend is connected. The client's team owns the existing backend and connecting it to this frontend, production sign-in, payment integration and testing with real guests and staff, which becomes the input for version two. I don't present adoption or revenue figures, because there aren't any yet; the numbers in the demo are generated data.
The live demo opens the menu the way a guest would, from the QR code on a lounge table. The admin panel is one tap away through the dashboard icon in its corner, and the sign-in page offers demo accounts.

The menu changes while someone is ordering.
A QR ordering flow looks linear — scan, menu, basket, checkout, order. The system underneath is not. During service a dish can sell out, a price can change, an option can disappear, a required choice can become invalid, and a dish can exist at one location but not another.
So the menu a guest saw when they scanned may not be the menu that exists when they reach checkout. A stale basket is not just a UX problem: it can mean a wrong price, an impossible order, or an operational problem for staff. The real question was what should happen when the truth in an active basket is no longer the truth in the live catalogue.


A scan is context. A basket line is an identity.
The central modelling decision was to separate context from selection. A QR code establishes the service context: which hotel and which of its locations the guest is ordering from, which menu they should see, which price applies, and which code started the session. That context stays with the browser tab.
A basket line establishes a different kind of identity: the dish plus its selected options. The same configuration twice raises the quantity; a different configuration stays a separate line. Every line keeps a stable identity even while the catalogue changes.
Whenever the catalogue changes, every line is reconciled against the current menu and resolves to one of six defined states: still valid, repriced, unavailable, removed, an option no longer available, or a choice to make again. These aren't backend errors exposed to the guest — they are product states, each with its own sentence. A line that can't safely be ordered drops out of the total and holds checkout until the guest resolves it.
That rule became the bridge between product design and engineering — the foundation for the UX, the state management and the frontend architecture.

Four decisions carry the product.
First, keep the client's backend model outside the UI. Their JSON passes through one adapter into the application's own model, so no component depends on the shape of an external system.
Second, give every piece of state one owner. The catalogue, the guest's basket and the staff's operational data live in separate stores with explicit responsibilities, so the guest side and the admin side never hold competing versions of the menu.
Third, let catalogue changes reach active baskets. A menu change is a domain event the basket has to answer, not an error the guest discovers at checkout.
Fourth, treat phone and desktop as different interaction modes, not different widths. The admin panel changes its navigation, filters and tables; the guest app stays a single column at every size.
Together they make the product predictable: a guest always knows what changed, why, and what to do next, and staff know that what they edit is exactly what guests read.

Some of the most important decisions were refusals.
I didn't give guests and staff separate copies of the menu. Both sides read one catalogue, so a change made in the admin panel — a new price, a sold-out dish, a renamed option — is exactly what the next guest sees after scanning the QR code.
And I didn't let the screens read the client's data as it arrives. Their backend describes a menu in its own format: an option's price sits in a field called topping_option_price and is left empty when the option is free; a dish's category and subcategory are packed into one string, such as “SUYA:GRILLS”; names arrive in lowercase. One adapter translates all of that into the app's own model — a price, an in-stock flag, a category and a subcategory, a readable name — before any screen sees it. The client's format is understood in that one file, so when their team connects the real backend, the adapter changes, not every screen.
I didn't let a stale basket update its prices quietly: a repriced line keeps the old price, struck through, so the guest sees what changed. Nor did I let an unavailable item stay orderable.
I didn't treat mobile as a scaled-down desktop.
And I didn't let the QA agent change the product on its own authority. It finds; I decide.



Every step between the scan and the order is designed, not defaulted.
The menu has category tabs, collapsible sections and a search that reads names and descriptions and ignores accents. A configurable dish opens its options in place — required choices marked — instead of sending the guest to another screen, and the Add button names the dish and states the total.
Checkout is four steps — basket, details, payment, summary. Choices stay editable from the basket, so changing a side doesn't mean deleting the dish and starting again, and the guest chooses how to pay: online, cash, card terminal or charge to room.
After ordering, the guest gets a confirmation, tracking through the status staff set, a delivery time labelled as the estimate it is, WhatsApp support, and feedback connected to a recent order. Loading, empty, error and unavailable states are designed as part of the product, not treated as exceptions to the happy path.


The admin panel is organised around what staff need to do next.
Five operational areas with role-based access: an administrator manages everything, a manager everything but the menus. The dashboard surfaces today's picture — net sales against the same point yesterday, orders in progress, menu views and how many became orders, and the busiest QR point.
Orders move through explicit states — placed, confirmed, in transit, delivered — rather than disappearing into a generic “processed”. Filters live in the URL, so opening an order and coming back keeps your place. Feedback is organised around follow-up: a low rating brings its order, the guest's contact options and an internal note with it.
On a phone the information architecture adapts instead of shrinking: the sidebar becomes an icon rail and then bottom navigation, filters become sheets, dense tables become readable rows, and page actions collapse into one contextual menu.





Adding a dish is a sequence, not a form.
Menus are what guests see when they scan the QR code, so a change in the admin panel reaches the guest side straight away: it decides what the next guest at that location sees, what they can order, and whether a basket already in progress is still valid. Menu data was also where the client's old system was hardest to use — data-heavy and difficult to navigate.
The Menus page is where it starts. Each dish is one row showing its category, its price at each location and its actions: edit, options, delete, and an availability switch. Turning the switch off marks the dish sold out at once — guests still see it on the menu, but can't order it.
Adding a dish is split into six short steps, each with one job: name and description; category and subcategory, picked from the list or created on the spot; price and availability; option groups; the locations that sell it, each with its own price if needed; and a photo, which can also be added later. A review of everything closes the flow. Each step is checked before the next one opens, and if something is missing, focus moves to the field that needs fixing.
Editing starts with a different question: what do you want to change? Every part of the dish appears as a card showing its current value, and the admin ticks only the parts they need — only those steps follow. The review then lists just what changed, with the old value struck through above the new one, and Save stays disabled until something has actually changed. The Options action on a row skips the question and opens the edit straight on the option groups.
Option groups, such as a spice level or a choice of protein, carry their own rules. A group is either required or optional, and every option has its own extra price and its own stock switch. The form won't accept two options with the same name in one group, or a required group with every option out of stock — guests couldn't order that dish at all.
Closing the dialog with unsaved changes asks first, so one press of Escape can't lose a long entry. Together, these turn menu administration from data entry into a short, guided task — and every change is one the guest side can safely show.





How the rules reach the code.
The adapter maps the client's JSON into dishes, option groups, options, locations, location prices and availability. When the prices proved wrong, the adapter didn't convert them: the data was corrected at the source, and a versioned migration rescaled baskets and orders already saved on devices, leaving anything priced in the admin panel untouched.
The three stores — catalogue, guest and admin — also form the integration contract. The client's developers connect their existing backend themselves, so replacing the simulated sources with API calls changes where the data comes from, not the model the interface understands.
Around them: routes code-split and guarded in their loaders, sessions that expire, redirects that can only point inside the admin panel, simulated functionality labelled as such in the code, and shared behaviour — dialog focus, scroll restoration, page titles — in reusable hooks. The frontend architecture follows the domain model, not the other way round.


Figma, tokens, components and accessibility share the same rules.
The guest app and the admin panel share one design language: semantic colour tokens, light and dark themes, typography, spacing and sizing, component states, responsive layout rules, icons and focus states. Primitive values never reach a screen; each semantic token carries a light value, a dark value, the CSS custom property it ships as and a Figma scope — a traceable line from design to code.
Follow-up on the design system is checked, not assumed. A design-system agent compares Figma with the code and the running app, and records where they don't match instead of drawing over it: spacing tokens exist, but some components still use raw values, so spacing is marked as proposed in Figma rather than shown as finished.
The QA agent checks accessibility in the same components: that dialogs hold focus and return it to the control that opened them, that status changes and basket totals are announced, that accessible names start with the visible label, and that charts don't rely on colour alone. Its findings go through the same gate as every other finding — I decide what is fixed.



The agent finds. I decide. The system validates.
A read-only browser agent navigates the app in Chromium, Firefox and WebKit at widths from 320px. It reproduces findings, inspects keyboard and focus behaviour, runs accessibility checks, computes contrast from the tokens actually shipping and collects evidence. It does not modify the application.
Every finding gets an ID and a severity. I decide whether it is real, whether it is a defect, where the correction belongs — a token, a component, a shared hook or the design itself — and whether the result is acceptable. Only approved findings are fixed, and the next validation pass checks the specific change.
Across five audit and validation passes, the first audit raised 48 findings, including the critical hundred-times prices. A later validation pass ran 74 targeted checks — each a scripted assertion that an approved fix holds — in each of three browser engines: 222 checks, all passing, after it had caught the rebuilt orders search losing keystrokes in Firefox alone.
AI accelerates the audit — browser coverage, accessibility checks, repetitive analysis. Product decisions, acceptance and risk stay with me. The point isn't the agent. It's the gate.


Version one: one product, two sides, one system.
Version one delivers one product — guest ordering and an admin panel — on one domain model and one design system; hotels with any number of locations, each with its own menus and prices — five in the demo hotel; live basket reconciliation; role-based staff workflows; phone to 1920px; light and dark themes; accessibility and cross-browser validation.
I implemented the UX architecture, product design, design system, React frontend, domain and state model, responsive behaviour, accessibility and the QA workflow.
Payment, staff sign-in and dashboard activity stay simulated until the client's backend is connected. No adoption or revenue figures are claimed because none exist yet; every figure in the demo is generated data.
The client's team owns the existing backend and its connection to the new frontend, production sign-in, payment integration, and validation with real guests and staff — the input for version two.

Accessibility is not a one-time certificate.
The build keeps a visible open register. Guest details are still kept on the device after an order. Long admin labels truncate when text spacing is enlarged on a phone. And a styling pass after the last audit softened the outline and focus colour of form fields below the 3:1 target.
The last one matters most: a product can pass an audit and regress a week later, so accessibility has to stay inside the development loop rather than be stamped once at the end.


Design the rules before the screens.
The screens were not the hardest part. The difficult decisions sat underneath them: what does a scan mean? What makes a basket line the same line? What happens when the menu changes under an active order? Which system owns the truth, and where should each rule live — an adapter, a store, a shared hook, a token or a component?
Once those rules were explicit, the interface became much easier to design — and QA changed too: instead of asking an agent to “check the application”, I could ask it to test specific behaviour against explicit rules.
AI can find evidence, accelerate the audit and expose inconsistencies. The product decisions still need an accountable human owner. That is the difference between using AI as a shortcut and using it inside a controlled product-engineering workflow.