A full-stack food-ordering and point-of-sale (POS) web application for WingZone Meru Branch.
Customers order online; kitchen staff receive real-time tickets and print ESC/POS receipts — no proprietary hardware required.
Live URL: https://wingzone-web-app.web.app
- Abstract
- Problem Statement
- Solution Overview
- Tech Stack
- System Architecture
- Firestore Database Schema
- Payment Flow (ToyyibPay FPX)
- User Flow — Customer
- User Flow — Admin / Staff
- Project Structure
- Environment Variables
- Getting Started
- Deployment
- QZ Tray Thermal Printing
- Event Menu System
- Firestore Security Rules
- Known Constraints & Limitations
- Completed Features
- Future Improvements
WingZone Web App is a Progressive Web App (PWA-ready) that replaces paper-based and verbal ordering at WingZone Meru Branch. It provides a customer-facing digital menu with real-time order placement and tracking, and an admin-facing operations dashboard for order management, receipt printing, menu administration, and sales reporting — all hosted on Firebase with zero ongoing server costs on the Spark plan and minimal cost on Blaze (Cloud Functions used only for signing QZ Tray print requests).
The system is purpose-built for a single branch but designed with multi-branch expansion in mind. It handles the complete ordering lifecycle: menu browsing → customisation → FPX payment → kitchen receipt print → order status pipeline → customer notification.
WingZone Meru Branch faced several operational pain points:
| Pain Point | Impact |
|---|---|
| No digital ordering — orders taken verbally or by paper slip | Error-prone, slow during peak hours |
| No real-time visibility of order queue for kitchen | Staff had to walk to counter repeatedly |
| Thermal receipt printing required expensive proprietary POS terminals | High capital cost |
| No data on daily/weekly sales without manual tallying | No business intelligence |
| Special events required a completely separate manual menu | Confusing for both staff and customers |
| No customer loyalty or voucher mechanism | Missed retention opportunity |
A hosted web app that:
- Customers access from any smartphone browser — no app install required. They browse a live menu, customise items (flavours, sides, beverages, bone type, dipping sauces), pay via FPX bank transfer (ToyyibPay), and track their order status in real time.
- Kitchen / Cashier receive instant Firestore push updates on the admin dashboard. A browser tab running on the cashier PC auto-plays an alert tone, auto-prints an ESC/POS kitchen receipt via QZ Tray, and advances the order to Preparing — no manual intervention needed for confirmed orders.
- Admin manages the full menu CRUD, store availability, event menus, discount vouchers, user accounts, and reviews from the same dashboard.
| Role | Route | Auth Required |
|---|---|---|
| Customer | / |
No (anonymous checkout supported) |
| Registered Customer | /profile, /order/[id] |
Firebase Auth (Google) |
| Admin / Staff | /admin/* |
Firebase Auth + Firestore admin record |
| Layer | Technology | Notes |
|---|---|---|
| Framework | Next.js 14 (App Router) | Server Components + Client Components |
| Language | TypeScript | Strict mode |
| Styling | Tailwind CSS | Custom brand tokens (primary, deep-red, wz-text) |
| Animations | Framer Motion | Page transitions, card stagger, sidebar |
| Database | Firebase Firestore | Real-time listeners on orders + menu |
| Auth | Firebase Authentication | Google OAuth; admin gate via Firestore record |
| File Storage | Firebase Storage | Menu item images; proxy-uploaded via /api/upload |
| Hosting | Firebase Hosting | CDN-backed static + SSR via Cloud Functions Gen 2 |
| Thermal Printing | QZ Tray 2.x + ESC/POS | Local desktop bridge; signed via RSA-SHA512 |
| Payments | ToyyibPay (FPX) | Malaysian FPX bank transfer; webhook confirmation |
| Admin SDK | firebase-admin | Singleton on webhook route; Cloud Run Node.js runtime |
| UI | Lucide React, React Hot Toast, SweetAlert2 | Icons, toasts, confirmation dialogs |
| Excel | SheetJS (xlsx) |
Menu bulk import + daily sales export |
| Image Crop | react-image-crop v11 | 4:3 crop modal; canvas output capped at 600 px WebP 60% |
| jsPDF | E-receipt PDF generation (pre-generated on mount, iOS Share Sheet safe) | |
| Charts | — | Planned |
Browser (Customer / Admin)
│
│ HTTPS
▼
┌─────────────────────────────────────┐
│ Firebase Hosting │
│ (Next.js 14 — App Router SSR) │
│ │
│ ┌─────────────────────────────┐ │
│ │ Next.js API Routes │ │
│ │ /api/checkout │ │ ──► ToyyibPay API (create bill)
│ │ /api/payment/callback │ │ ◄── ToyyibPay webhook (payment result)
│ │ /api/qz-sign │ │ ──► RSA-SHA512 signed response
│ │ /api/upload │ │ ──► Firebase Storage (REST)
│ └─────────────────────────────┘ │
└─────────────────────────────────────┘
│ │
│ Firestore SDK │ Firebase Admin SDK
▼ ▼
┌──────────────┐ ┌────────────────┐
│ Firestore │ │ Firebase Admin │
│ (real-time) │ │ (webhook only) │
└──────────────┘ └────────────────┘
│
│ WebSocket (localhost)
▼
┌──────────────┐
│ QZ Tray │ (running on cashier PC)
│ (2.x local) │
└──────────────┘
│ USB / Serial
▼
┌──────────────┐
│ Thermal POS │
│ Printer │
└──────────────┘
Key design decisions:
- Image uploads go through
/api/upload(server-side proxy) to avoid CORS issues with Firebase Storage fromlocalhostduring development. - QZ Tray signing is done server-side (
/api/qz-sign) so the RSA private key never reaches the browser. - ToyyibPay webhook uses
multipart/form-data— parsed with a regex field extractor since Next.jsrequest.formData()handles it natively.
{
name: string
type: 'combo' | 'alacarte' | 'promotion' | 'pack'
category: MenuCategory // 'Combo Meals' | 'Wings' | 'Tenders' | ...
basePrice: number // RM
description: string
imageUrl?: string // Firebase Storage URL
displayOrder?: number // Lower = shown first
isAvailable: boolean
kitchenIngredients: KitchenIngredient[]
customizationRequirements: CustomizationRequirements
availableFlavors: string[]
components: ComponentCategory[]
defaultSide?: string
basketUpcharge?: number // RM upcharge for basket add-on
voucherAmount?: number // Fixed RM deduction (promotion type only)
isTimeRestricted?: boolean
startTime?: string // 'HH:mm' MYT
endTime?: string // 'HH:mm' MYT
menuContext?: 'outlet' | 'event'
createdAt: Timestamp
updatedAt: Timestamp
}
{
orderId: string // Human-readable e.g. "WZ-1714123456"
recipientName: string
items: OrderItem[]
kitchenSummary: { // Pre-aggregated for thermal receipt
Mains: Record<string, number>
Sides: Record<string, number>
Drinks: Record<string, number>
Dippings: Record<string, number>
}
totalPrice: number // Final charged amount
subtotal?: number // Pre-discount total
discountPct?: number // App exclusive discount %
discountAmount?: number // RM saved from app discount
totalVoucher?: number // RM saved from promotion items
customDiscountAmt?: number // RM saved from voucher code
voucherCode?: string // e.g. "RAYA2026"
totalSavings?: number // Sum of all savings
orderStatus: OrderStatus // See status pipeline below
paymentMethod: 'cash' | 'online' | 'fpx'
pickupTime?: string
isGroupOrder: boolean
customerEmail?: string
phone?: string
hasPrinted?: boolean
hasRated?: boolean
rating?: number
ratingFeedback?: string
menuContext?: 'outlet' | 'event'
eventName?: string
createdAt: Timestamp
updatedAt: Timestamp
}
Order Status Pipeline:
awaitingPayment ──(ToyyibPay webhook)──► confirmed
│
(auto 30 s via useOrderNotifier)
│
preparing
│
(admin action)
│
ready
│
(admin action)
│
delivered
Cash orders enter at pending and are manually confirmed by admin.
{ discountPct: number } // App-exclusive % discount applied at checkout (public read)
{ GlobalAvailability } // Per-flavour, per-side, per-beverage availability toggles
{ isOpen: boolean, storeName: string, hours: StoreHours, ... }
{
isEventModeEnabled: boolean
eventName: string
startDate: string // ISO datetime
endDate: string // ISO datetime
}
{ name: string, phone: string, email: string, createdAt: Timestamp }
{ email: string } // Presence = admin access granted
{
code: string // Uppercase e.g. "LAUNCH5"
discountPercentage: number
isActive: boolean
singleUsePerAccount: boolean
usedBy: string[] // UIDs that have redeemed this code
createdAt: Timestamp
}
Auto-populated from order rating submissions.
Customer taps "Pay with FPX"
│
▼
POST /api/checkout
├── Validates env vars (API key, category code, app URL)
├── Converts amount to cents (RM → toyyibpay integer)
├── Creates bill via ToyyibPay REST API
│ billReturnUrl = /payment/success
│ billCallbackUrl = /api/payment/callback
└── Returns { checkoutUrl }
│
▼
Browser redirects to toyyibpay.com/{BillCode}
Customer completes FPX bank transfer
│
├── ToyyibPay POSTs to /api/payment/callback (multipart/form-data)
│ billcode, status_id, order_id, msg, transaction_id
│ status_id = 1 → Payment successful
│ ├── Firestore: order.orderStatus = 'confirmed'
│ └── useOrderNotifier picks up → ding + auto-print + 30s auto-advance
│
└── Browser redirects to /payment/success
Shows order confirmation + link to tracking page
Error resilience:
- If ToyyibPay returns HTML (maintenance mode) instead of JSON,
/api/checkoutparses raw text first and returns a 502 with a user-friendly message. - Webhook is idempotent — if called twice for the same order, the update is a no-op.
1. Open https://wingzone-web-app.web.app
│
├── [Event Mode active?] ──YES──► MenuSelectionModal
│ Customer picks Outlet or Event menu
│ Choice saved to sessionStorage
│
▼
2. Browse menu
├── Category pills (sticky, smooth-scroll, active pill auto-scrolls into view)
├── Item cards with image (blur placeholder → loaded → animate-pulse removed)
├── Sold Out overlay on unavailable items
└── Promotion badge ("Available" / time remaining) on time-restricted items
3. Tap item → CustomisationModal
├── Select bone type, flavour(s), side, dipping, beverage
├── Optional: basket add-on, extra dipping
├── Special instructions
└── Real-time price update → "Add to Order" button
4. Floating Order Widget (bottom of screen)
├── Item count badge
├── Expand to see full cart
└── "Checkout" button
5. Checkout sheet
├── Name / phone / pickup time
├── Voucher code input (requires login)
├── Pricing breakdown: Subtotal → App Exclusive → Promo → Voucher → Total
├── Payment method: FPX (online) or Cash
└── Place Order
6. FPX path → redirected to ToyyibPay → bank transfer
Cash path → order saved as 'pending' immediately
7. /payment/success → confirmation screen
8. /order/[id] — real-time tracking
├── 4-step animated pipeline: Pending → Preparing → Ready → Delivered
├── Save / Share E-Receipt button (jsPDF pre-generated on mount; iOS Share Sheet safe)
├── "I'm Here!" button when status = Ready (triggers Delivered)
└── Post-delivery star rating widget
1. /admin/login — Google sign-in
Firestore checks admins/{uid} → redirect to /admin on success
2. /admin — Dashboard
├── Today's order count, revenue, pending orders
├── Daily Sales Report table with Gateway Fee + Net Banked columns
└── Excel export
3. /admin/orders — Live Order Feed
├── Firestore onSnapshot listener — new orders appear instantly
├── "Advance" button: pending→preparing, preparing→ready, ready→delivered
├── Print Receipt button → QZ Tray ESC/POS (if QZ Tray running)
├── Cancel Order button
└── useOrderNotifier (background):
├── Listens for status=confirmed + hasPrinted=false
├── Plays ding.mp3 alert
├── Auto-prints kitchen receipt via QZ Tray
├── Marks hasPrinted=true
└── After 30 s → advances to 'preparing'
4. /admin/menu — Menu Management
├── Filter pills: All / Outlet / Event
├── Add / Edit / Delete items
├── Image upload (crop modal → 600px WebP 60% → /api/upload → Firebase Storage)
├── Toggle availability per item
└── Set menuContext (Outlet / Event)
5. /admin/availability — Global Availability
Toggles for bone types, flavours, beverages, sides, dipping sauces
6. /admin/event-settings — Event Menu Control
├── Enable / disable Event Mode
├── Set event name
└── Set optional start/end datetime window
7. /admin/vouchers — Voucher Codes
├── Create voucher (code, discount %, single-use toggle)
└── Activate / deactivate
8. /admin/users — Customer Accounts
9. /admin/reviews — Star Ratings + Feedback
10. /admin/import — Bulk Excel Menu Import
11. /admin/settings — Branch info, operating hours, notification preferences
WzWebApp/
├── app/
│ ├── page.tsx # Customer menu — main entry point
│ ├── layout.tsx # Root layout + CartContext + GlobalLoader
│ ├── loading.tsx # Slot-machine GlobalLoader
│ ├── not-found.tsx # Branded 404 with mascot
│ ├── globals.css # Tailwind base + custom form utilities
│ ├── about/page.tsx
│ ├── profile/page.tsx # Customer profile (name, phone, edit form)
│ ├── order/[id]/page.tsx # Real-time order tracking + e-receipt PDF
│ ├── payment/success/page.tsx # Post-FPX landing page
│ ├── admin/
│ │ ├── layout.tsx # Admin shell — sidebar, topbar, logout confirm
│ │ ├── page.tsx # Dashboard — sales summary + report
│ │ ├── login/page.tsx # Google sign-in gate
│ │ ├── orders/page.tsx # Live order feed + receipt printing
│ │ ├── menu/page.tsx # Menu CRUD + context filter
│ │ ├── availability/page.tsx # Global ingredient availability
│ │ ├── event-settings/page.tsx # Event Mode toggle + config
│ │ ├── settings/page.tsx # Branch settings + notification prefs
│ │ ├── users/page.tsx # Customer account list
│ │ ├── reviews/page.tsx # Rating + feedback viewer
│ │ ├── vouchers/page.tsx # Voucher code management
│ │ └── import/page.tsx # Bulk Excel menu import
│ └── api/
│ ├── checkout/route.ts # ToyyibPay bill creation
│ ├── payment/callback/ # ToyyibPay webhook (multipart parser)
│ ├── qz-sign/route.ts # RSA-SHA512 QZ Tray signing
│ └── upload/route.ts # Image proxy upload → Firebase Storage
│
├── components/
│ ├── CustomerHeader.tsx # Sticky header (logo + nav + sign-out)
│ ├── StoreStatusBanner.tsx # Open/closed banner (dismissible)
│ ├── FloatingOrderWidget.tsx # Persistent cart bottom widget
│ ├── ExclusivityToast.tsx # App-exclusive discount toast
│ ├── MenuSelectionModal.tsx # Outlet vs Event menu chooser
│ ├── admin/
│ │ ├── MenuItemModal.tsx # Add/edit item modal (crop + upload)
│ │ └── OrderListener.tsx # Firestore order listener component
│ └── ui/
│ ├── Loader.tsx # GlobalLoader + ButtonSpinner
│ ├── PageWrapper.tsx # Framer Motion page fade wrapper
│ └── reveal-loader.tsx # Slot-machine word animation
│
├── context/
│ └── CartContext.tsx # Global cart state (items, totals, helpers)
│
├── hooks/
│ ├── useScrollSpy.ts # Scroll-event category spy (V4, rAF debounced)
│ ├── useOrderNotifier.ts # Auto-ding + auto-print + 30s auto-advance
│ ├── useEventGatekeeper.ts # Firestore listener → MenuSelectionModal control
│ └── usePromoTime.ts # Time-restricted promotion active/inactive state
│
├── lib/
│ ├── firebase.ts # Firebase client SDK init
│ ├── firebase-admin.ts # Firebase Admin SDK singleton
│ ├── qzService.ts # QZ Tray connection + signing + ESC/POS helpers
│ └── qz-tray.ts # QZ Tray JS library loader
│
├── types/
│ └── index.ts # All shared TypeScript interfaces + enums
│
├── public/
│ ├── certs/digital-certificate.txt # QZ Tray self-signed cert (served publicly)
│ ├── WingzoneLogo.png
│ ├── wingmascot.png
│ ├── mascotComingSoon.png # Fallback menu image
│ ├── ding.mp3 # Order alert tone
│ └── flavors/ # Flavour artwork (PNG)
│
├── private/ # .gitignored
│ └── private-key.pem # RSA key for QZ Tray signing
│
├── firestore.rules
├── storage.rules
├── firebase.json
├── next.config.mjs
├── tailwind.config.ts
└── package.json
Copy .env.local.example → .env.local. Never commit .env.local.
# Firebase client SDK
NEXT_PUBLIC_FIREBASE_API_KEY=
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=
NEXT_PUBLIC_FIREBASE_PROJECT_ID=
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET=
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=
NEXT_PUBLIC_FIREBASE_APP_ID=
# App base URL (used for ToyyibPay return/callback URLs)
NEXT_PUBLIC_APP_URL=https://wingzone-web-app.web.app
# QZ Tray — RSA private key (single-line, \n-escaped)
QZ_PRIVATE_KEY=
# ToyyibPay
TOYYIBPAY_SECRET_KEY=
TOYYIBPAY_CATEGORY_CODE=For Firebase production, store QZ_PRIVATE_KEY as a secret:
firebase functions:secrets:set QZ_PRIVATE_KEY# Install dependencies
npm install
# Run development server
npm run dev- Customer menu: http://localhost:3000
- Admin panel: http://localhost:3000/admin (requires Firestore
admins/{uid}record)
# Build + deploy to Firebase Hosting (Next.js SSR via Cloud Functions Gen 2)
firebase deploy --only hostingRequires:
firebase login.firebaserclinked to your projectQZ_PRIVATE_KEYsecret set in Firebase (firebase functions:secrets:set QZ_PRIVATE_KEY)
- The cashier PC runs QZ Tray 2.x as a background system service.
- On a confirmed order (or manual print click), the web app:
- Fetches the signing cert from
/certs/digital-certificate.txt POST /api/qz-sign— server signs the request with the RSA-SHA512 private key- Sends signed ESC/POS commands to QZ Tray over
wss://localhost:8181
- Fetches the signing cert from
- QZ Tray forwards commands to the connected USB/Serial thermal printer.
- Install QZ Tray 2.x
- Open the web app and trigger a print
- In the QZ trust dialog: check "Remember this decision" → click Allow
- Done — cert is served from the live URL, no local file setup
- Subscribes to Firestore orders where
orderStatus === 'confirmed'andhasPrinted === false - Plays
ding.mp3alert - Prints kitchen receipt immediately
- Sets
hasPrinted = true - After 30 seconds, advances order to
preparingautomatically
Allows the store to activate a separate curated menu for special events (pop-ups, catering, limited-time drops) without replacing the regular Outlet Menu.
- Admin → Event Settings: toggle Event Mode on, set event name, optionally set start/end datetime.
- Firestore
app_config/event_settingsis updated. useEventGatekeeperon the customer page detects the change in real time.- If no session choice exists,
MenuSelectionModalappears — full-screen overlay asking the customer to choose Outlet or [Event Name] menu. - Choice is saved to
sessionStoragefor the tab's lifetime. - An event escape hatch banner appears below the header when viewing the Event Menu — one tap returns to the Outlet Menu (cart cleared after SweetAlert2 confirmation).
Each menu item has menuContext: 'outlet' | 'event' set via the Menu Context segmented control in the item editor. The admin Menu page has All / Outlet / Event filter pills for quick sorting.
menuItems — public read, auth-required write
settings/* — public read (menu + discount), auth-required write
orders — public create (anonymous checkout), auth-required read/update/delete
everything else — auth-required read + write
Orders allow unauthenticated create so guests can place orders. All sensitive operations (reading order lists, updating status, managing menu) require Firebase Authentication.
| Constraint | Detail |
|---|---|
| QZ Tray dependency | Must be running on the cashier PC for auto-print to work. It is a local desktop app — not cloud-based. |
| Admin auth | Auth is a Firestore admins/{uid} presence check, not Firebase Custom Claims. The admin record must be seeded manually. |
| ToyyibPay webhook format | ToyyibPay POSTs multipart/form-data. If they change the payload format, the field parser in the webhook route needs updating. |
| Canvas re-encoding | Image uploads go through a <canvas> crop step, which always decompresses → re-encodes. Output is capped at 600 px WebP 60% to keep sizes below ~80 KB. |
| jsPDF e-receipt | PDF is text-only (no image of the page). Complex layouts are approximated with manual y-coordinate tracking. |
| Single branch | The data model has no branch scoping — adding a second branch would require a significant schema change. |
| No offline support | Firestore persistence is enabled but the app has no Service Worker / offline fallback shell. |
| ToyyibPay HTML error responses | On gateway maintenance, ToyyibPay returns HTML instead of JSON. The checkout route handles this with a raw text → safe JSON.parse guard and returns a user-friendly 502. |
| iOS Share Sheet | navigator.share({ files }) only works from a user-initiated synchronous click. The e-receipt PDF is pre-generated on useEffect mount and stored in state so the button handler has zero await before calling navigator.share. |
- Full customer menu with live Firestore data, category navigation (scroll-spy V4), and customisation modal
- Cart context, floating order widget, and order submission
- Real-time order tracking page with animated 4-step pipeline
- User profile management with unsaved-changes detection
- Sign-out confirmation (SweetAlert2) on both customer profile and admin sidebar
- All admin pages: orders, menu CRUD, availability, settings, users, reviews, daily report, import, vouchers, event settings
- ESC/POS receipt printing via QZ Tray with server-side RSA-SHA512 signing
useOrderNotifier— ding + auto-print + 30 s auto-advance on confirmed orders- ToyyibPay FPX payment integration — bill creation, redirect, webhook confirmation
- Webhook correctly parses multipart/form-data; handles HTML error responses gracefully
- Image upload pipeline: browser crop (600 px WebP 60%) →
/api/uploadproxy → Firebase Storage - E-receipt PDF (jsPDF, pre-generated on mount, iOS Share Sheet + desktop download fallback)
- App-exclusive discount: Firestore
settings/discount, shown in checkout breakdown and e-receipt - Voucher codes: per-account single-use enforcement, displayed in checkout + e-receipt
- Event Menu System: dual-menu modal, per-item
menuContext, escape hatch banner ExclusivityToast— discount notification (only shown after discount data loads)- Framer Motion animations: page fade, menu card stagger, sidebar slide, banner dismiss
- GlobalLoader (slot-machine word animation) + ButtonSpinner
- Branded 404 page and
mascotComingSoon.pngfallback for items without images - Menu image blur placeholder (neutral gray 1×1 PNG) +
animate-pulseskeleton +priorityon first 6 cards - Category pill bar: sticky, auto-scrolls active pill into view on mobile, centered on desktop
- Daily Sales Report: Gateway Fee + Net Banked columns; full Excel export
- Admin menu context filter pills (All / Outlet / Event)
- Time-restricted promotions: "Available" badge during active window
- Phone input
min-w-0fix (prevents bleed on mobile edit profile) - Firebase production deployment (Hosting + Cloud Functions Gen 2)
| Priority | Feature | Notes |
|---|---|---|
| High | Push notifications (FCM) | Notify customers when order is Ready; notify staff on new orders without needing a browser tab open |
| High | ToyyibPay webhook polling fallback | In case webhook delivery fails, poll ToyyibPay bill status every 30 s as a safety net |
| Medium | Customer order history | Paginated list on /profile of all past orders with re-order shortcut |
| Medium | Multi-branch support | Scope Firestore documents by branchId; branch selector on admin login |
| Medium | PWA manifest + Service Worker | Full offline shell so the menu loads without network; install-to-home-screen on mobile |
| Medium | Firebase Custom Claims for admin | Replace Firestore admins collection check with proper Firebase Auth custom claims |
| Low | Automated CI | GitHub Actions: next build + tsc --noEmit + eslint on every PR |
| Low | Sales analytics charts | Week/month revenue graphs on the admin dashboard |
| Low | Event Menu date/time auto-activation | Cloud Function scheduled trigger to flip isEventModeEnabled at the configured datetime, instead of relying on the admin to toggle manually |
| Low | Loyalty / points system | Award points per order, redeemable as discount |
| Low | Table QR codes | Per-table QR that pre-fills table number at checkout |
| Role | Access |
|---|---|
| Customer | Menu browsing, ordering, order tracking, profile management |
| Admin / Staff | Orders dashboard, menu management, store settings, reporting |
- Live Menu with category navigation (smooth scroll + sticky category pill bar)
- Item Customisation Modal — flavour, side, and beverage selections with real-time price calculation
- Floating Order Widget — persistent cart summary visible across the menu
- Real-time Order Tracking — order status updates pushed via Firestore listeners
- User Profile — display name, phone, address management with unsaved-changes detection
- Store Status Banner — shows open/closed status, dismissible with animation
- Branded 404 Page — custom not-found page with mascot
- Fallback Item Image —
mascotComingSoon.pngshown for items without uploaded photos - Image Loading Overlay — fullscreen overlay held until all menu card images resolve (no flash of missing images)
- ToyyibPay FPX Online Payment — customers pay via FPX bank transfer at checkout
- Background Webhook (
/api/payment/callback) — ToyyibPay POSTs multipart/form-data after every payment attempt; webhook verifies status and advances order toconfirmed - Firebase Admin SDK on the webhook route — direct Firestore write without going through the client SDK
- Orders Dashboard — live order feed with Firestore real-time listener, advance status button with spinner feedback
- Payment Status Flow —
Awaiting Payment→ (webhook) →Confirmed→ (auto 30 s) →Preparing→ (admin) →Ready→ (admin) →Delivered - Auto-print + ding on Confirmed —
useOrderNotifierlistens for newconfirmed+hasPrinted=falseorders, plays alert tone, auto-prints ESC/POS receipt via QZ Tray, then advances toPreparingafter 30 s - Menu Management — add/edit/delete menu items, upload item images (Firebase Storage), toggle availability
- Store Availability — toggle store open/closed status
- Store Settings — update branch info, operating hours
- User Management — view all registered customer accounts
- Reviews — view customer ratings and feedback
- Daily Sales Report — aggregate order totals by day with Gateway Fee (RM) and Net Banked (RM) columns; Excel export includes all columns
- Menu Import — bulk import menu items via Excel (
.xlsx) - Event Settings — toggle Event Mode on/off, set event name and active date/time window; live status banner shows current state
- Vouchers — manage discount voucher codes
- Admin Login — dedicated
/admin/loginroute with Firebase Authentication
- ESC/POS Receipt Printing via QZ Tray 2.x
- Server-side SHA-512 RSA request signing (
/api/qz-sign) - Certificate served from
/certs/digital-certificate.txt— no per-device file setup needed
- Dual-menu selection modal (
MenuSelectionModal) — full-screen overlay lets customers choose between the regular Outlet Menu and a named Event Menu before browsing; choice is persisted insessionStorage useEventGatekeeperhook — subscribes toapp_config/event_settingsin Firestore; shows the selection modal only when Event Mode is active and the session has no stored choice- Per-item
menuContext— each menu item carriesmenuContext: 'outlet' | 'event'; the customer page filters items to the active context - Event escape hatch banner — sticky banner rendered between the header and category pills when viewing the Event Menu; one-tap switch back to Outlet Menu (with cart-clear confirmation)
- ExclusivityToast — bottom-anchored toast that slides up to confirm the active menu context
- Framer Motion animations: page fade-in, stagger on menu cards, hamburger sidebar slide, banner dismiss collapse, save button reveal
- GlobalLoader — full-screen slot-machine word animation on route loading
- ButtonSpinner — inline loading indicator on async action buttons
- Cart/Checkout totals — digital receipt–style breakdown with line-item subtotals, savings callout, and grand total row
| Layer | Technology |
|---|---|
| Framework | Next.js 14 (App Router) |
| Language | TypeScript |
| Styling | Tailwind CSS |
| Animations | Framer Motion |
| Backend / DB | Firebase Firestore |
| Auth | Firebase Authentication |
| File Storage | Firebase Storage |
| Hosting | Firebase Hosting + Cloud Functions Gen 2 (Blaze) |
| Printing | QZ Tray 2.2.4 + ESC/POS |
| Payments | ToyyibPay (FPX) |
| Admin SDK | firebase-admin (modular, Cloud Run Node.js runtime) |
| UI Utilities | Lucide React, React Hot Toast, SweetAlert2 |
| Excel Import | SheetJS (xlsx) |
| Image Cropping | react-image-crop |
app/
page.tsx # Customer menu (main page) — dual-menu gatekeeper, event escape hatch
layout.tsx # Root layout + CartContext provider
loading.tsx # GlobalLoader shown on route transitions
not-found.tsx # Branded 404 page
about/ # About page
profile/ # Customer profile management
order/[id]/ # Real-time order tracking
admin/
layout.tsx # Admin shell layout + sidebar navigation
page.tsx # Admin dashboard home
login/ # Admin login page (Firebase Auth)
orders/ # Orders management + receipt printing
menu/ # Menu item CRUD (with menuContext filter pills)
availability/ # Store open/closed toggle
event-settings/ # Event Mode toggle, event name, active date/time window
settings/ # Branch settings
users/ # Customer accounts list
reviews/ # Customer reviews
vouchers/ # Discount voucher management
import/ # Bulk Excel menu import
api/
qz-sign/route.ts # Server-side QZ Tray request signing
upload/route.ts # Image upload handler
checkout/route.ts # Creates ToyyibPay bill + order in Firestore
payment/callback/ # ToyyibPay background webhook (multipart/form-data parser)
payment/
success/ # Post-payment landing page
components/
CustomerHeader.tsx # Unified sticky header (logo + nav) — h-20, shrink-safe
StoreStatusBanner.tsx # Sticky store open/closed banner (dismissible)
FloatingOrderWidget.tsx # Persistent cart summary
MenuSelectionModal.tsx # Dual-menu selection overlay (Outlet vs Event); persists choice to sessionStorage
ExclusivityToast.tsx # Bottom-anchored context confirmation toast
admin/
MenuItemModal.tsx # Add/edit menu item modal (includes menuContext segmented control)
OrderListener.tsx # Firestore real-time order listener
ui/
Loader.tsx # GlobalLoader + ButtonSpinner
PageWrapper.tsx # Framer Motion page fade-in wrapper
context/
CartContext.tsx # Global cart state
hooks/
useOrderNotifier.ts # Listens for confirmed + unprinted orders → ding + print + auto-advance
useEventGatekeeper.ts # Subscribes to app_config/event_settings; controls MenuSelectionModal visibility
usePromoTime.ts # Promo/time-based display helpers
lib/
firebase.ts # Firebase client SDK initialisation
firebase-admin.ts # Firebase Admin SDK singleton (try/catch init pattern)
qzService.ts # QZ Tray connection, signing, and print helpers
qz-tray.ts # QZ Tray JS library loader
types/
index.ts # Shared TypeScript types (MenuItem with menuContext, EventConfig, etc.)
public/
certs/digital-certificate.txt # QZ Tray signing certificate (self-signed)
wingmascot.png # Brand mascot
404mascot.png # 404 page mascot
mascotComingSoon.png # Fallback image for menu items without photos
WingzoneLogo.png
ding.mp3 # Alert tone played when a confirmed order arrives
private/ # .gitignored — RSA private key for QZ signing
private-key.pem
Copy .env.local.example to .env.local and fill in values. Never commit .env.local.
# Firebase client config
NEXT_PUBLIC_FIREBASE_API_KEY=
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=
NEXT_PUBLIC_FIREBASE_PROJECT_ID=
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET=
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=
NEXT_PUBLIC_FIREBASE_APP_ID=
# QZ Tray signing — RSA private key (single-line, \n-escaped)
QZ_PRIVATE_KEY=
# ToyyibPay
TOYYIBPAY_API_KEY=
TOYYIBPAY_CATEGORY_CODE=
NEXT_PUBLIC_TOYYIBPAY_URL=https://toyyibpay.comFor production (Firebase), set QZ_PRIVATE_KEY as a Firebase secret:
firebase functions:secrets:set QZ_PRIVATE_KEY# Install dependencies
npm install
# Start development server
npm run devOpen http://localhost:3000.
Admin panel is at http://localhost:3000/admin — requires a Firebase account with admin custom claims set in Firestore.
# Build and deploy to Firebase Hosting + Cloud Functions
firebase deploy --only hostingRequires Firebase CLI logged in (firebase login) and project linked (.firebaserc).
The Event Menu System lets the store activate a separate curated menu for special events (e.g. pop-ups, catering, limited-time drops) without displacing the regular Outlet Menu.
- An admin navigates to Event Settings and toggles Event Mode on, sets an event name, and optionally configures a start/end date-time window.
- The settings are written to
app_config/event_settingsin Firestore. - On the customer page,
useEventGatekeeperlistens to that document in real time. When Event Mode is active and no session choice is stored,MenuSelectionModalis shown. - The customer picks Outlet Menu or the named Event Menu — the choice is saved to
sessionStorageand persists for the tab's lifetime. - The category pill bar, cart, and checkout all operate on the filtered item set for the chosen context.
- A sticky event escape hatch banner appears below the header when viewing the Event Menu, allowing one-tap return to the Outlet Menu (cart is cleared after confirmation).
- Master toggle — enables/disables Event Mode globally
- Event name — displayed in the selection modal and the escape hatch banner
- Date/time window — optional; event auto-activates and auto-deactivates based on the window
- Per-item context — each menu item has a
menuContextfield set via the Menu Context segmented control in the item editor (outlet|event) - Context filter pills — the admin Menu page has All / Outlet / Event filter pills to quickly view items by context
- The cashier PC runs QZ Tray 2.x as a local background service.
- When an admin clicks Print Receipt, the web app:
- Fetches the signing cert from
/certs/digital-certificate.txt - Calls
POST /api/qz-signwith the request string — the server signs it with the RSA private key - Sends signed ESC/POS commands to QZ Tray via WebSocket
- Fetches the signing cert from
- QZ Tray forwards the commands to the connected USB/Serial thermal printer.
- Install QZ Tray 2.x
- Open the web app and trigger any print
- When the trust dialog appears: check "Remember this decision" → click Allow
- Done — no manual cert file installation needed (the cert is served from the live URL)
The signing certificate at public/certs/digital-certificate.txt is a self-signed certificate issued for this deployment. QZ Tray allows permanent trust for user-generated certificates (unlike the QZ demo cert which intentionally blocks persistent trust).
- Full customer menu with live Firestore data, category navigation, and customisation modal
- Cart context, floating order widget, and order submission
- Real-time order tracking page
- User profile management with unsaved-changes detection
- All admin pages: orders, menu CRUD, availability, settings, users, reviews, daily report, import
- ESC/POS receipt printing via QZ Tray with server-side RSA signing
- Animated UI: page transitions, menu card stagger, sidebar, store banner, save button
- GlobalLoader (slot-machine animation) and ButtonSpinner
- Branded 404 page and fallback menu item image
- Firebase production deployment (Hosting + Cloud Functions Gen 2)
- QZ Tray trust resolved — permanent "Remember this decision" works with self-signed cert
- GitHub repository set up with secrets excluded from version control
- ToyyibPay FPX payment integration — checkout creates bill, webhook confirms payment
- Background webhook correctly parses ToyyibPay multipart/form-data payload via regex
- Firebase Admin SDK on webhook route —
serverComponentsExternalPackages+ try/catch singleton useOrderNotifier— ding + auto-print + 30 s auto-advance toPreparingonconfirmedorders- Unified
CustomerHeader— sticky header with correct height (h-20) and shrink-safe logo - Image loading overlay — held until all menu images resolve
- Cart/Checkout totals redesign — digital receipt–style breakdown with savings callout
- Daily Sales Report — Gateway Fee (RM) and Net Banked (RM) columns added to table and Excel export
- Event Menu System — dual-menu gatekeeper (
useEventGatekeeper),MenuSelectionModal, per-itemmenuContext, event escape hatch banner,ExclusivityToast - Admin: Event Settings page — master toggle, event name, start/end datetime window, live status banner
- Admin: Vouchers page — discount voucher management
- Admin: Login page — dedicated
/admin/loginroute - Category pill bleed fix —
shrink-0on all pills + trailing spacer prevents last pill clipping on mobile - Admin Menu context filter — All / Outlet / Event pills in the menu admin page
- Push notifications for order status updates (Firebase Cloud Messaging)
- Customer-facing order history page
- Voucher redemption on the customer checkout flow
- Multiple branch support
- PWA manifest + offline support
- Automated ESLint/type-check CI on GitHub Actions
- ToyyibPay payment status polling fallback (for webhook delivery failures)
- Event Menu date/time window auto-activation without page reload (scheduled Firestore rule or Cloud Function)
- QZ Tray must be running on the cashier PC for printing to work — it is a local desktop service, not cloud-based.
- Admin auth currently relies on a Firestore
adminscollection check — no Firebase custom claims. Ensure the admins document is correctly seeded. .env.localmust be manually configured on each development machine — it is intentionally excluded from version control.- ToyyibPay webhook is
multipart/form-data(notx-www-form-urlencoded) — the webhook uses a regex-based field extractor; if ToyyibPay changes their payload format this will need updating. - QZ Tray must be running on the cashier PC for auto-print on
confirmedorders to work.