Global Texture - Noise Background
Global Texture - Noise Background
    uxproof on two phones: the empty chat, and a chat that lists the uploaded reports and generates a deck
    uxproof on two phones: the empty chat, and a chat that lists the uploaded reports and generates a deck
Client
QRCodeFlip
Role
UX architecture, product design, design system and frontend engineering
Scope
Version one of the product, guest ordering and the staff application, built around the client’s existing backend and data model
Year
2026
Tools
FigmaReact 19ViteReact RouterZustandFramer MotionSwiperCSS ModulesPlaywrightaxe-core
Website
View demo

Context

One product for many hotels, with a guest side and a staff side.

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.

QRCodeFlip on a marble background: the staff dashboard, with today’s net sales, orders, average order value, menu views, the busiest QR point, recent orders and top sellers, beside a guest’s basket on a phone that flags one dish as no longer available
Diagram: one product, two sides, one shared domain. Guest ordering and staff operations on either side of one model of catalogue, option groups, outlets and outlet prices, availability, orders and QR context, with what each side reads and changes

Problem

The menu changes while the guest is ordering.

A QR ordering flow looks linear: scan, menu, basket, checkout, order. The system underneath it isn’t. While a guest is choosing, a dish can sell out, its price can change, an option can disappear, a required choice can become invalid, and a dish can be offered at one location but not at another.

So the menu a guest opened is only a snapshot. The real question was what should happen when the truth in an active basket stops matching the truth in the live catalogue, and that became the main UX problem and the main engineering problem at once.

The second problem sat at the integration boundary. The frontend reads JSON from the client’s existing system, shaped for that backend rather than for the way a guest or a member of staff thinks. The first audit showed one concrete consequence: prices were stored in minor units, and every dish was showing at a hundred times its price.

Admin menus on two phones, light and dark: dishes with their outlet prices, edit and options actions, and a sold-out switch on every row
The guest basket after a dish sold out, light and dark: the line is marked no longer available and checkout is disabled until it’s removed

System model

A scan is context. A basket line is an identity.

The key modelling decision was to separate context from selection. A QR code sets the context: the hotel, the service point, the menu and location prices that apply there, and the code that started the session. That context stays with the browser tab, so every menu view and order is recorded against it.

A basket line is a selection: the dish plus its chosen options. The same configuration twice raises the quantity, and a different configuration is a line of its own, so every line keeps a stable identity while the catalogue changes around it.

The rule for what happens next came out of the first audit, which found the basket keeping stale prices and still allowing dishes that staff had made unavailable or deleted. Now, whenever the live catalogue changes, every line is reconciled and resolves to one of six states: still valid, repriced, unavailable, removed, an option no longer available, or a choice required again.

These are product states with their own sentence for the guest, and none of them is a backend error passed through. A line that can’t safely be ordered is flagged, drops out of the total and holds checkout until the guest resolves it. That rule became the bridge between the product design and the engineering architecture.

Diagram: a basket line, the dish plus its sorted choices, is reconciled against the live menu whenever staff change it, with six outcomes and the sentence the guest reads for each; any flagged line stops the order
Three component boards, light and dark: the menu item row and category section, with options chosen inline; the cart line in each of its states, from a line with options to price changed, no longer available, a chosen option no longer available and options to choose again, with the editable option chip and its menu; and the order timeline, order line and details panel

Guest experience

Every step between the scan and the order is designed, not defaulted.

The guest interface is simple because the system underneath isn’t. The menu has categories, sections that open and close, and a search that ignores accents and reads descriptions as well as names. A dish with choices opens them where the guest is already deciding, with required groups marked, and the Add button states the dish and the total.

Checkout is four steps, basket, details, payment and summary, and the basket stays editable throughout, so changing a side doesn’t mean removing the dish and building it again. After the order the guest gets a confirmation, an estimated delivery time, the order’s status as staff move it, a WhatsApp line for help and a feedback form tied to a recent order.

Empty, loading, unavailable and error states were designed as part of the product, next to the main path. On a desktop the guest app stays a narrow column, keeping the mobile ordering model.

The guest menu and checkout at 1920, 1440 and 390px, in light and dark: one column, with the details step beside it on desktop and the basket bar on the phone
Four phones, light and dark: on the left, a dish’s required choice open in its row, with Add disabled until a choice is made; on the right, order tracking with an estimated delivery time, five stages from Received to Delivered and a WhatsApp help card

Staff application

The admin panel is organised around what needs doing next.

The staff application has five areas with role-based access. An administrator manages everything, and a manager has the operational areas without menu administration. The dashboard leads with today: net sales against the same point yesterday, orders in progress, menu views and how many became orders, and the busiest QR point. Analytics compare each period with the one before over the same stretch of time, and break menu views and delivery times down by location.

Orders move through explicit states, and 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 up the related order, the guest’s details, call and WhatsApp actions and an internal note.

The information architecture changes with the screen. The admin shell goes from a sidebar to a compact icon rail to a bottom navigation bar, filters become sheets, page actions collapse into a menu, and dense tables become readable rows on a phone.

The staff dashboard at 1920, 1440 and 390px, in light and dark: a sidebar on the desktops and a bottom navigation bar on the phone
Analytics at 1440px, light and dark: views and revenue by day, the order funnel with median time to delivery by location, views by location and payment methods
An order at 1440 and 390px, in light and dark: its status from Placed to Delivered, the item, the guest’s room and the payment method
A two-star review marked as needing follow-up, light and dark: ratings by aspect, call and WhatsApp buttons, the linked order and an internal note
The admin on phones, light and dark: on the left, menu filters in a bottom sheet with a Show 36 items button; on the right, order actions in a sheet with Refresh orders and Download report

Menu management

Adding a dish is a sequence, not a form.

The menu editor sits right on the boundary between staff and guests. A menu change is more than a data update: it can change what a guest sees, what they can order and whether an open basket is still valid.

So adding a dish is a sequence of steps, each with one job: details, category, price and availability, options, locations and photo, then a review. Availability shares the price step because a new dish is usually available, and an eighth step wouldn’t fit the stepper on a small phone.

Editing starts with a different question: what do you want to change? The editor then shows only those parts, and the review lists only what changed, with the old values struck through. Option groups carry their own rules, such as required choices, extra prices and stock, and the availability switch on the menu list is an operational control: flip it, and the guest menu changes.

The menus page at 1440 and 390px, in light and dark: outlet prices and a sold-out switch on every row, with search, filters and Add docked at the bottom on the phone
Adding a dish in the admin at 1440 and 390px, in light and dark: the options step with a required “Choose your glass” group and a Large option at +₦2,000, and the review on a phone listing everything before it’s added to the menu
Editing a dish in the admin at 1440 and 390px, in light and dark: “What would you like to update?” with Where it is sold chosen, then a review on a phone listing only that change, the lounge price going from ₦70,000 to ₦75,000 with the old prices struck through

Engineering system

Three decisions carry the architecture.

The first is a domain boundary. The client’s JSON enters through one adapter that turns it into the app’s own model: dishes, option groups, options, locations, location prices and availability. No component reads the raw structure. When the price representation turned out to be wrong, the fix stayed in the data and a migration: the data was corrected at the source, and a versioned migration rescaled the baskets and orders already saved on devices, leaving prices entered in the admin untouched.

The second is state ownership. The catalogue store holds the menu and is read by both sides, the guest store holds the basket, the guest’s details and the latest order, and the admin store holds orders, menu activity and feedback. The two interfaces never keep competing copies of the catalogue, so a change made in the admin travels through the same model the guest app reads.

Because the client’s developers own the final backend integration, these boundaries are also the integration contract. Replacing the simulated sources with API calls changes where the data comes from, and the model the interface understands stays the same.

The third is reconciliation. A catalogue change has somewhere to go: into the basket, where each line resolves to one of the six known states, so a changing menu is an expected event at checkout. The rest of the frontend follows the same idea, with route-level code splitting, role checks in the route loaders, sessions that expire, redirects that stay inside the admin, simulated features kept separate and labelled, and shared behaviour in reusable hooks.

Diagram: the QR link feeds the guest app and the admin panel sits beside it; below them a guest store, a catalogue read by both sides and an admin store that guest actions write into; the menu JSON from the client’s existing system enters only through an adapter; payment, sign-in and dashboard activity are simulated until the backend connects

Design system

Figma, tokens, components and accessibility share the same rules.

The design system works as an implementation system as well as a visual reference. Interfaces never use primitive values directly; components use semantic tokens. Semantic tokens carry a light and a dark value, the CSS custom property they ship as and a Figma scope, so a value in Figma can be traced to the code that draws it.

The same applies to typography, spacing, sizing, focus states and component states, in both themes. Where the code and the system don’t agree yet, the system says so: the spacing tokens exist, but some components still use raw values, so the system lists that as proposed work. A design system is only useful if it describes the system that actually exists.

Diagram: the focus ring from the amber primitives in Figma, through the focus/ring semantic variable with a light and a dark value, to the --focus-ring custom property and the components on both sides, with three rules that keep Figma and the code in step
Colour foundations: primitive scales and semantic colours, each with its light and dark value and CSS variable
Colour tokens: each Figma variable with its Light and Dark alias, CSS variable and scope
Typography: Montserrat and Nunito text styles, with size, weight, line height and where each is used, in light and dark
Spacing, sizing and elevations: the spacing scale, control sizes, radii and shadows in light and dark
Grid and layout: what changes at each breakpoint, and the guest and admin layout rules
Spacing, radius, typography and layout tokens, with Desktop and Compact values and CSS variables
Three component boards, light and dark: the button in five variants and two sizes with its default, hover, focus, disabled and loading states; the icon button in four variants and three sizes; and the icon library, one outline set on a 24px grid, grouped by use
Two component boards, light and dark: the admin bottom sheet with the filters and order-actions sheets, the more-actions row and how the sheets dock on a phone and a tablet; and the modal in its sizes beside the confirm dialog in its tones, on the scrim in light and dark

Accessibility

Accessibility lives in the components, so every screen inherits it.

Dialogs manage focus. Only the active one responds to the keyboard, and when it closes, focus goes back to the control that opened it, or to the page heading when that control no longer exists. Status changes and basket totals are announced, and accessible names start with the visible label. An order opened from the list on a phone is a dialog like any other: focus moves in, stays there, and returns to the row when it closes.

Charts don’t depend on colour alone. Contrast was checked against the semantic tokens the app actually uses, in the dark theme as well as the light one, so accessibility sits in the component and token architecture, where every screen inherits it. In the feedback form, each row of stars is a radio group named after what it rates, so a screen reader hears, for example, “Rate Food Quality 4 stars”.

The guest feedback form on two phones, light and dark: an overall rating, four aspect ratings in rows of stars, an optional comment and a submit button
An order opened as a dialog on two phones, light and dark: its four status steps, the item and total, the guest’s details and a Close button

QA loop

The agent finds. I decide. The system validates.

I used a read-only browser agent as part of the validation workflow. It drives the app in Chromium, Firefox and WebKit at widths from 320 to 1440px, reproduces findings, checks keyboard and focus behaviour, runs accessibility checks, computes contrast from the tokens actually shipping and collects screenshots as evidence. It never changes the application.

Every finding gets an ID and a severity. I decide whether it’s real, whether it’s a defect, where the fix belongs and whether the result is acceptable. Only approved findings are fixed, and the next validation pass checks that specific change.

There were five audit and validation passes, the later ones in all three engines. The first audit produced 48 findings, including the critical hundred-times prices. One validation pass ran 74 targeted checks in each engine, 222 in all, caught the rebuilt orders search losing keystrokes in Firefox alone, and was signed off only once all 222 passed. That’s why I don’t treat an AI-generated audit as a quality certificate. The point isn’t the agent. It’s the gate.

For this page I re-ran the key checks on the current build. In Chromium, Firefox and WebKit, a price edit, a sold-out dish, a deleted dish and each status change reached an open guest tab, nothing scrolled sideways from 320 to 1440px, and a guest’s first visit loaded none of the admin code.

The Playwright report for the checks re-run on the current build, on a screen against beige marble: 54 passed, none failed, flaky or skipped, in 47 seconds across Chromium, Firefox and WebKit, opening on the admin-to-guest tests: a price edit, a sold-out dish, a deleted dish and each status change reaching an open guest tab
Playwright’s trace viewer on a screen against beige marble, on the test “a dish switched to sold out holds the guest’s checkout”: its last check, that Proceed to Checkout is disabled, selected over a snapshot of the guest basket flagging the dish as not available right now, with a timeline row for each tab, the guest’s and the admin’s
Diagram of the QA loop: a read-only agent audits in three engines, I decide which findings to fix and where, fixes land in tokens, components or hooks, a validation pass re-runs the checks and the next audit starts from scratch, with three things one loop moved
The guest menu at 390px in Chromium 153, Firefox 155 and WebKit 26.6, side by side: the same layout and content in all three