diff --git a/JOURNAL.md b/JOURNAL.md index a72bfad..e4f4a22 100644 --- a/JOURNAL.md +++ b/JOURNAL.md @@ -1115,4 +1115,30 @@ order, every primitive, zero console errors. A whole plant, standing up from a c The point was never the cleaner. The point was that the cleaner was never special — and now there's a test that proves it. +--- + +## Day 2 — 2026-09-12 · The first real restaurant menu + +The restaurant engine already existed — stations, fan-out, a KDS, modifiers, 86 counts, tips, split checks. What +it didn't have yet was a *real restaurant's whole menu* proving it. Stage 2 is **Hamburger Barn** in Arkadelphia +(the same town as Ozark), and the point of a stage is that a real business in the vertical depends on the +software. So the `burgerbarn` builder template got built out from the shop's actual public menu: **58 items** +across 13 categories — the burgers (Bubba, Bacon Hickory, Chili, Mushroom Onion, Bacon Chipotle), the chicken +sandwiches, the Tex-Mex line, the salads and soups, the fried apps, shakes, desserts, kids — each routed to the +station that actually makes it (grill · fry · shake bar · a new **Salad & Prep** line), with the Toast-style +**"make it a combo — fries & drink"** upsell on every main. *(Prices in the template are sensible estimates; +the shop confirms its own in the builder — nobody knows a menu's prices like the people ringing it.)* + +Fifty-eight items is where a real lesson landed: a single flat wall of tiles is how you *lose* the rush. Toast +tabs its menu by category, so the engine now does too — a generic **category-tab bar** on the ordering screen, +driven straight off each item's `category` (labels from an optional `catLabels` map, else the category +title-cased), showing only when a menu is big enough to need it. The dry cleaner's short list stays a plain +grid; a fifty-item burger menu becomes Burgers / Chicken / Tex-Mex / Salads / Shakes / … at a tap. Search and +the category filter share one path so they never fight. It's a restaurant-shaped need that turned into an +engine primitive every vertical gets — the recurring shape of this whole project. + +`tests/burgerbarn.js` now pins all of it: the full 58-item, 13-category build; the routing (burger→grill, +rings→fry, shake→shakes, salad→prep, chimi→fry, fajita→grill); the combo upsell and category labels; and the +live preview actually rendering the category tabs and fanning one order out to the grill and the fry station. + *— to be continued —* diff --git a/builder.html b/builder.html index 07199ad..a385a36 100644 --- a/builder.html +++ b/builder.html @@ -340,54 +340,111 @@ welcome:{ message:"Make someone's day today ✂️", specials:["Ask about the color special"] }, catalog:[ {id:"s1",name:"Haircut",price:35,category:"service",path:["chair"],performer:true}, {id:"s2",name:"Color",price:85,category:"service",path:["chair"],performer:true}, {id:"s3",name:"Product",price:22,category:"retail",path:[]} ], stations:[ {id:"desk",type:"central",label:"Front Desk",view:{money:true}}, {id:"chair",type:"production",label:"Chair",view:{money:false}}, {id:"book",type:"booking",label:"Appointments",view:{}}, {id:"clock",type:"timeclock",label:"Time Clock",view:{}}, {id:"sched",type:"schedule",label:"Schedule",view:{}}, {id:"office",type:"report",label:"Back Office",view:{money:true}} ] }, - // A REAL restaurant template — the Hamburger Barn menu (Arkadelphia, AR). Public menu; prices are - // sensible estimates the owner edits. One order fans out to the grill, the fry station, and the shake bar. - burgerbarn: { flowId:"burgerbarn", label:"Hamburger Barn (burger joint)", topology:"hub-and-spoke", blurb:"A real burger-joint menu — grill · fry · shakes", + // A REAL restaurant template — the full Hamburger Barn menu (Arkadelphia, AR), laid out Toast-style: the Front + // Counter menu tabs by category, and one order fans out to the grill, fry, shake bar, and salad/prep line. + // The public menu (hamburgerbarn.com) gives the items and sections; ⚠️ PRICES ARE SENSIBLE ESTIMATES the owner + // confirms in the builder — the shop knows its own prices. "Make it a combo" is the Toast-style fries+drink upsell. + burgerbarn: { flowId:"burgerbarn", label:"Hamburger Barn (burger joint)", topology:"hub-and-spoke", blurb:"The real Hamburger Barn menu — grill · fry · shakes · salad line", branding:{ name:"Hamburger Barn", brandColor:"#a3312a" }, - endpoints:{ customer:{persist:false}, payment:{tenders:["cash","card"], closeGate:"balanceLE0", tip:{presets:[15,18,20]}, split:true} }, + endpoints:{ customer:{persist:false}, payment:{tenders:["cash","card"], closeGate:"balanceLE0", tip:{presets:[15,18,20]}, split:true}, orderTypes:["Dine-in","To-go","Drive-thru"] }, timer:{ mode:"aging", warnSec:360, badSec:720, label:"ticket" }, capacity:{ perOrderMins:6, max:15 }, kds:{warnSec:360,badSec:720}, + // Toast-style category tabs on the ordering screen (order = first appearance below) + catLabels:{ burger:"Burgers", chicken:"Chicken", sandwich:"Sandwiches", plate:"Plates", texmex:"Tex-Mex", app:"Appetizers", salad:"Salads", soup:"Soups & Potatoes", side:"Sides", shake:"Shakes", dessert:"Desserts", kids:"Kids", drink:"Drinks" }, catalog:[ - // ---- BURGERS → grill ---- - {id:"bubba",name:"Bubba Burger",price:13,category:"burger",path:["grill"], + // ---- BURGERS → grill (all can "make it a combo") ---- + {id:"bubba",name:"Bubba Burger",price:13,category:"burger",path:["grill"],suggest:["fries","shake","drink"], modifiers:[{group:"Patty",required:true,options:[{name:"Single",price:0},{name:"Double",price:3}]}], - addons:[{name:"Bacon",price:1.5},{name:"Extra cheese",price:1},{name:"Jalapeños",price:0.5},{name:"Grilled onions",price:0.5}], + addons:[{name:"Bacon",price:1.5},{name:"Extra cheese",price:1,reveal:{group:"Which cheese",required:true,options:[{name:"American",price:0},{name:"Swiss",price:0},{name:"Cheddar",price:0},{name:"Pepper Jack",price:0}]}},{name:"Jalapeños",price:0.5},{name:"Grilled onions",price:0.5},{name:"Make it a combo — fries & drink",price:3.5}], flags:["Allergy note"]}, - {id:"mushswiss",name:"Mushroom Swiss Burger",price:12.5,category:"burger",path:["grill"],addons:[{name:"Bacon",price:1.5}]}, - {id:"gouda",name:"Gouda Burger",price:12.5,category:"burger",path:["grill"]}, - {id:"chipotle",name:"Chipotle Burger",price:12.5,category:"burger",path:["grill"]}, + {id:"bachick",name:"Bacon Hickory Burger",price:12.5,category:"burger",path:["grill"],addons:[{name:"Extra bacon",price:1.5},{name:"Make it a combo — fries & drink",price:3.5}]}, + {id:"chiliburg",name:"Chili Burger",price:11.5,category:"burger",path:["grill"],addons:[{name:"Extra cheese",price:1},{name:"Make it a combo — fries & drink",price:3.5}]}, + {id:"mushonion",name:"Mushroom Onion Burger",price:12,category:"burger",path:["grill"],addons:[{name:"Bacon",price:1.5},{name:"Make it a combo — fries & drink",price:3.5}]}, + {id:"bacchip",name:"Bacon Chipotle Burger",price:12.5,category:"burger",path:["grill"],addons:[{name:"Extra cheese",price:1},{name:"Make it a combo — fries & drink",price:3.5}]}, {id:"cheeseburger",name:"Cheeseburger",price:9,category:"burger",path:["grill"], - modifiers:[{group:"Cheese",required:true,options:[{name:"American",price:0},{name:"Swiss",price:0},{name:"Cheddar",price:0},{name:"Pepper Jack",price:0}]}]}, + modifiers:[{group:"Cheese",required:true,options:[{name:"American",price:0},{name:"Swiss",price:0},{name:"Cheddar",price:0},{name:"Pepper Jack",price:0}]}], + addons:[{name:"Bacon",price:1.5},{name:"Make it a combo — fries & drink",price:3.5}]}, + // ---- CHICKEN SANDWICHES → grill (fried ones to fry); grilled/fried where it applies ---- + {id:"grillchick",name:"Grilled Chicken",price:10,category:"chicken",path:["grill"],addons:[{name:"Bacon",price:1.5},{name:"Make it a combo — fries & drink",price:3.5}]}, + {id:"cajunchick",name:"Cajun Chicken",price:10.5,category:"chicken",path:["grill"],addons:[{name:"Make it a combo — fries & drink",price:3.5}]}, + {id:"honeymust",name:"Honey Mustard Chicken",price:10.5,category:"chicken",path:["grill"],addons:[{name:"Make it a combo — fries & drink",price:3.5}]}, + {id:"chipjack",name:"Chipotle Jack Chicken",price:11,category:"chicken",path:["grill"],addons:[{name:"Make it a combo — fries & drink",price:3.5}]}, + {id:"petitjean",name:"Petit Jean Mountain Chicken",price:11.5,category:"chicken",path:["grill"],addons:[{name:"Make it a combo — fries & drink",price:3.5}]}, + {id:"cfc",name:"Chicken Fried Chicken",price:11,category:"chicken",path:["fry"],addons:[{name:"Make it a combo — fries & drink",price:3.5}]}, + // ---- OTHER SANDWICHES ---- + {id:"dagwood",name:"Dagwood",price:11,category:"sandwich",path:["prep"],addons:[{name:"Make it a combo — fries & drink",price:3.5}]}, + {id:"hamcheese",name:"Ham & Cheese",price:9,category:"sandwich",path:["prep"],addons:[{name:"Make it a combo — fries & drink",price:3.5}]}, + {id:"angus",name:"Angus Steak Sandwich",price:13,category:"sandwich",path:["grill"],addons:[{name:"Grilled onions",price:0.5},{name:"Make it a combo — fries & drink",price:3.5}]}, + {id:"turkey",name:"Turkey Bistro",price:10,category:"sandwich",path:["prep"],addons:[{name:"Make it a combo — fries & drink",price:3.5}]}, + // ---- PLATES ---- {id:"cfs",name:"Chicken Fried Steak",price:13,category:"plate",path:["grill"]}, - {id:"grillchick",name:"Grilled Chicken Sandwich",price:10,category:"sandwich",path:["grill"]}, - // ---- FRIED APPS & PLATES → fry ---- + {id:"catfish",name:"3-pc Catfish Plate",price:13,category:"plate",path:["fry"]}, + {id:"special",name:"Blue Plate Special",price:10,category:"plate",path:["grill"],par:10}, // daily special, 86 count + // ---- TEX-MEX ---- + {id:"fajitas",name:"Flaming Fajitas",price:15,category:"texmex",path:["grill"], + modifiers:[{group:"Protein",required:true,options:[{name:"Chicken",price:0},{name:"Steak",price:2},{name:"Combo",price:2}]}]}, + {id:"quesadilla",name:"Mesquite Chicken Quesadilla",price:11,category:"texmex",path:["grill"]}, + {id:"tenders",name:"Chicken Tenders",price:10,category:"texmex",path:["fry"]}, + {id:"chimi",name:"Chicken Chimichanga",price:11,category:"texmex",path:["fry"]}, + {id:"tamales",name:"Hot Tamales",price:8,category:"texmex",path:["grill"]}, + {id:"enchilada",name:"Chicken Enchilada",price:10,category:"texmex",path:["grill"]}, + {id:"nachos",name:"Macho Nachos",price:10,category:"texmex",path:["prep"]}, + {id:"fritopie",name:"Frito Chili Pie",price:8,category:"texmex",path:["prep"]}, + {id:"bigdog",name:"The Big Dog (chili cheese dog)",price:7,category:"texmex",path:["grill"]}, + {id:"chili",name:"Soon-To-Be-Famous Chili",price:6,category:"texmex",path:["prep"]}, + // ---- APPETIZERS → fry (fried) / prep (cold) ---- {id:"onionrings",name:"Onion Rings",price:6,category:"app",path:["fry"]}, {id:"friedpickles",name:"Fried Pickles",price:6,category:"app",path:["fry"]}, {id:"greenbeans",name:"Fried Green Beans",price:6,category:"app",path:["fry"]}, {id:"cheesesticks",name:"Big Cheese Sticks",price:7,category:"app",path:["fry"]}, {id:"friedmush",name:"Fried Mushrooms",price:6,category:"app",path:["fry"]}, {id:"trio",name:"Big Teaser Trio",price:9,category:"app",path:["fry"]}, - {id:"catfish",name:"3-pc Catfish Plate",price:13,category:"plate",path:["fry"]}, + {id:"appcombo",name:"Appetizer Combo",price:10,category:"app",path:["fry"]}, + {id:"queso",name:"Queso with Chips & Salsa",price:7,category:"app",path:["prep"]}, + {id:"guac",name:"Guac with Chips & Salsa",price:7,category:"app",path:["prep"]}, + {id:"chipsalsa",name:"Chips & Salsa",price:4,category:"app",path:["prep"]}, + // ---- SALADS → salad/prep line (dressing choice, add chicken) ---- + {id:"housesalad",name:"House Salad",price:6,category:"salad",path:["prep"], + modifiers:[{group:"Dressing",required:true,options:[{name:"Ranch",price:0},{name:"Petit Jean Ranch",price:0},{name:"Honey Mustard",price:0},{name:"Italian",price:0},{name:"Bleu Cheese",price:0}]}], + addons:[{name:"Add grilled chicken",price:3}]}, + {id:"clubsalad",name:"Club Salad",price:10,category:"salad",path:["prep"]}, + {id:"mesquitesalad",name:"Mesquite Chicken Salad",price:11,category:"salad",path:["prep"]}, + {id:"ranchsalad",name:"Petit Jean Ranch Salad",price:10,category:"salad",path:["prep"]}, + // ---- SOUPS & POTATOES → prep ---- + {id:"broccoli",name:"Broccoli Cheese Soup",price:5,category:"soup",path:["prep"]}, + {id:"bisque",name:"Tomato Bisque",price:5,category:"soup",path:["prep"]}, + {id:"soupsalad",name:"Soup & Salad Combo",price:9,category:"soup",path:["prep"]}, + {id:"bakedpotato",name:"Loaded Baked Potato",price:6,category:"soup",path:["prep"]}, + // ---- SIDES → fry ---- {id:"fries",name:"Fries",price:3,category:"side",path:["fry"]}, // ---- SHAKES → shake bar ---- {id:"shake",name:"Milkshake",price:5,category:"shake",path:["shakes"], - modifiers:[{group:"Flavor",required:true,options:[{name:"Vanilla",price:0},{name:"Chocolate",price:0},{name:"Strawberry",price:0}]}]}, - // ---- DRINKS → handed over at the counter (no path) ---- + modifiers:[{group:"Flavor",required:true,options:[{name:"Vanilla",price:0},{name:"Chocolate",price:0},{name:"Strawberry",price:0},{name:"Cookies & Cream",price:0.5},{name:"Caramel",price:0.5}]}]}, + // ---- DESSERTS → prep ---- + {id:"breadpudding",name:"Apple Bread Pudding",price:6,category:"dessert",path:["prep"]}, + {id:"moltencake",name:"Chocolate Turtle Molten Cake",price:7,category:"dessert",path:["prep"]}, + // ---- KIDS ---- + {id:"babybubba",name:"Baby Bubba",price:6,category:"kids",path:["grill"]}, + {id:"chickenbites",name:"Chicken Bites",price:6,category:"kids",path:["fry"]}, + {id:"cornpups",name:"Corn Pups",price:6,category:"kids",path:["fry"]}, + {id:"macncheese",name:"Mac & Cheese",price:5,category:"kids",path:["prep"]}, + {id:"grilledcheese",name:"Grilled Cheese",price:5,category:"kids",path:["grill"]}, + // ---- DRINKS → handed over at the counter (no prep) ---- {id:"drink",name:"Fountain Drink",price:2.5,category:"drink",path:[]}, - {id:"tea",name:"Sweet Tea",price:2.5,category:"drink",path:[]}, - // ---- daily special with an 86 count ---- - {id:"special",name:"Blue Plate Special",price:10,category:"plate",path:["grill"],par:10} + {id:"tea",name:"Sweet Tea",price:2.5,category:"drink",path:[]} ], stations:[ {id:"counter",type:"central",label:"Front Counter",view:{money:true}}, {id:"grill",type:"production",label:"Grill",view:{money:false}}, {id:"fry",type:"production",label:"Fry Station",view:{money:false}}, {id:"shakes",type:"production",label:"Shake Bar",view:{money:false}}, + {id:"prep",type:"production",label:"Salad & Prep",view:{money:false}}, {id:"kds",type:"kds",label:"Kitchen Display",view:{money:false}}, {id:"office",type:"report",label:"Office",view:{money:true}} ] }, bistro: { flowId:"bistro", label:"Full-service restaurant", topology:"hub-and-spoke", blurb:"Dine-in — tables, floor plan, kitchen & bar", branding:{ name:"My Bistro", brandColor:"#7a1f2b" }, - endpoints:{ customer:{persist:false}, payment:{tenders:["cash","card"], closeGate:"balanceLE0", tip:{presets:[18,20,22]}, split:true}, coursing:true }, + staff:[ {id:"own",name:"Owner",pin:"1111",role:"owner"}, {id:"mgr",name:"Manager",pin:"2222",role:"manager"}, {id:"srv1",name:"Riley",pin:"3333"}, {id:"srv2",name:"Sam",pin:"4444"} ], // servers own tables; owner/manager approve comps + endpoints:{ customer:{persist:false}, payment:{tenders:["cash","card"], closeGate:"balanceLE0", tip:{presets:[18,20,22], tipOut:[{role:"Kitchen",pct:5},{role:"Bussers",pct:10}]}, split:true}, approvals:{comp:true,refund:true}, happyHour:{from:"15:00", to:"18:00"}, orderTypes:["Dine-in","To-go"], coursing:true }, // happy hour 3–6pm courses:["Appetizers","Entrées","Desserts"], timer:{ mode:"aging", warnSec:600, badSec:1200, label:"ticket" }, kds:{warnSec:420,badSec:900}, floor:{ label:"Dining Room", warnSec:900, sections:["Main","Patio","Bar"], tables:[ @@ -401,8 +458,8 @@ {id:"steak",name:"Ribeye",price:34,category:"entree",path:["line"],course:2,modifiers:[{group:"Temp",required:true,options:[{name:"Rare",price:0},{name:"Med rare",price:0},{name:"Medium",price:0},{name:"Well",price:0}]}]}, {id:"salmon",name:"Salmon",price:26,category:"entree",path:["line"],course:2}, {id:"burger",name:"Bistro Burger",price:16,category:"entree",path:["line"],course:2}, - {id:"wine",name:"House Wine",price:11,category:"drink",path:["bar"],course:1}, - {id:"beer",name:"Draft Beer",price:7,category:"drink",path:["bar"],course:1}, + {id:"wine",name:"House Wine",price:11,category:"drink",path:["bar"],course:1,ageRestricted:21,happyPrice:6}, + {id:"beer",name:"Draft Beer",price:7,category:"drink",path:["bar"],course:1,ageRestricted:21,happyPrice:4}, {id:"cake",name:"Chocolate Cake",price:10,category:"dessert",path:["cold"],course:3} ], stations:[ diff --git a/docs/MARKET-GRIEVANCES.md b/docs/MARKET-GRIEVANCES.md new file mode 100644 index 0000000..7177e1b --- /dev/null +++ b/docs/MARKET-GRIEVANCES.md @@ -0,0 +1,78 @@ +# 🗣️ What restaurant operators want changed — market research + +*Grievances and feature-wants gathered from restaurant-POS reviews and forums (Toast, Square for Restaurants, +Aloha, Clover, Lightspeed, TouchBistro) — mapped to what customPOS already does, so the roadmap is driven by what +real operators complain about, not guesswork. Sources at the bottom. (This is captured **as data** — the point is +the pattern of complaints, not any one review.)* + +> Companion to [RESTAURANT-ROADMAP.md](RESTAURANT-ROADMAP.md) (our capability map) and +> [BURGER-BARN-STAGE.md](BURGER-BARN-STAGE.md) (grievance capture on-site). **Legend:** ✅ we already answer it · +> 🟡 partial · 🆕 a gap worth building. + +## The megatheme: lock-in & cost — and it's *our whole pitch* +This is the #1 thing operators say they'd change, on every platform. It is precisely what the sovereign model +exists to answer — so this isn't a to-build list, it's the sales story, already true: + +| What operators hate | customPOS | +|---|---| +| **Proprietary hardware** — must buy Toast's Android terminals ($399–$799+), can't use your own; replacements only from them | ✅ Runs in a browser on hardware you already own; buy replacements anywhere | +| **Locked processing rates** (2.49–3.5%+), can't bring your own processor | ✅ We *recommend* a processor; the merchant account is yours — PCI never lands on us | +| **Contract auto-renewal** (2–3 yr, 30–60 day escape window) + hardware financing you still owe after leaving | ✅ You own the code; leave anytime, take it with you | +| **Core features as $75/mo add-ons** (online ordering, loyalty, marketing, gift cards, team mgmt each billed) | ✅ One instance, features included; revenue is the *service*, not per-feature upsell | +| **Online-ordering commissions** (2.5–3.5% on top of the fee) | 🟡 Online ordering is the hub project — but never a per-order commission | +| **Poor / slow support**, tier-1 can't help, escalations take days | ✅ The relationship *is* the product — "Claude keeps your POS perfect" | + +## Operational grievances (the build signal) +| Grievance (who complains) | Our status | Note | +|---|---|---| +| **Cloud POS dies when the internet drops** — can't ring, can't take cards | ✅ | Offline-first single file keeps ringing; syncs on reconnect. *Honest caveat: live card **authorization** still needs a connection — store-and-forward is a processor feature.* | +| **Square can't route to the right kitchen station** on complex menus | ✅ | Per-item station fan-out is core | +| **Menu doesn't turn items off when 86'd / out of stock** | ✅ | 86/par + stock auto-disable; **day-part windows** now gate lunch-at-dinner etc. | +| **Complex/conditional modifier trees are hard to build** (Toast) | 🟡→🆕 | We have modifiers/add-ons; **nested/conditional modifiers** (e.g. "add cheese → which cheese") is a real gap | +| **KDS is rigid, little customization** (Toast) | ✅🟡 | We have a KDS with timers/bump/coursing; layout options could go further | +| **Reports are weak — need to pull several to get one answer; accounting/tips-in-payroll painful** | 🟡→🆕 | We have reports/P&L/paystub; a **one-screen daily "close" report** + cleaner tip export is worth building | +| **No delivery-driver report** — who took which delivery | 🆕 | Small, concrete: a driver/delivery summary | +| **No upsell suggestions** (Square/Toast weak) | 🆕 | We have combos; a **"suggested add-on" prompt** at ring/checkout is cheap and wanted | +| **Inventory is shallow — no wastage/spoilage tracking** (vs Lightspeed) | 🟡→🆕 | We track stock + reorder; a **wastage/waste-log** adjust is the gap | +| **Tips lost when a tab/table is transferred**; tip-out to support staff clumsy | 🟡 | Our transfer moves the check (tips ride the tenders); **tip-out by role** + keeping server attribution on transfer is worth pinning | +| **Loyalty should be built in, no extra app** | ✅ | Loyalty + gift cards are core | +| **Weak third-party integrations** (QuickBooks, delivery, etc.) | 🟡 | QBO exists on the Ozark instance; delivery ingestion is the hub project | +| **Hard to learn / long training** | ✅ | The builder + plain UI; training in hours not days | + +## Where we already win (say it out loud) +Sovereign ownership · no proprietary hardware · bring-your-own processor · **offline-first reliability** · per-item +kitchen routing · built-in loyalty/gift/house-accounts · the full server flow we just built (seat-by-seat, split +by seat, comps, server assignment, transfer/merge, day-parts/happy-hour, reservations/waitlist, order types) · +and the moat: **say a grievance in plain English → it's changed**, which is the exact opposite of "escalation +takes days." + +## New gaps worth building (prioritized from the research) — progress +1. ✅ **Nested / conditional modifiers** — shipped (`nested-modifiers.js`). +2. ✅ **Daily close visibility** — the report already had net/tax/tips/tenders/drawer/COGS/**waste**; added a + **Comps** line so what's given away shows at close (`report-comps.js`). +3. ✅ **Suggested add-on / upsell prompt** — shipped (`upsell.js`). +4. ✅ **Wastage** — already tracked (`adjustStock` "Waste" reason → `adjustLog`, shown in the report); no rebuild needed. +5. ⬜ **Delivery-driver report** — small; low priority for a dine-in burger joint (revisit if a delivery line matters). +6. ✅ **Tip-out by role** — shipped: the close spells out each support role's cut of tips + what servers keep (`tip-out.js`). + +**Beyond the forum list (ideas generated + shipped):** +- ✅ **Per-seat receipt** — a seated order's receipt groups items by seat with subtotals (`seat-receipt.js`). + +**Further ideas parked for owner steer (judgment calls better set by real Burger Barn shifts than by a forum):** +- Manager-PIN override for the day-part gate (rare off-hours exception, logged) — *note the honesty tradeoff; owner's call.* +- Void-with-reason on a sent-but-unpaid item (overlaps comp; loss-prevention angle). +- Cash-tip declaration at close (payroll). +- Delivery-driver report (only if a delivery line matters). + +These are recommendations, not commitments — the owner prioritizes (and real Burger Barn grievances outrank a +forum). Each ships through the safety pipeline with a test, like everything here. + +## Sources +- [Toast POS Problems & Complaints (2026) — Sleft](https://www.sleftpayments.com/learning-hub/toast-pos-problems-complaints-2026) +- [Square for Restaurants Review — NerdWallet](https://www.nerdwallet.com/business/software/reviews/square-for-restaurants) +- [What Restaurants Want from POS Systems in 2025 — Access Computech](https://accesscomputech.com/blog/what-restaurants-want-from-pos-systems-in-2025.html) +- [Top POS features for restaurants — Lavu](https://lavu.com/3-features-to-look-for-in-restaurant-pos-software/) +- [44 Best Restaurant POS Features — Quantic](https://getquantic.com/restaurant-pos-system-features/) +- [POS Offline Mode: Why Your Restaurant Needs It — SpotOn](https://www.spoton.com/blog/pos-offline-mode/) +- [Restaurant POS Offline Mode — Clopos](https://www.clopos.com/en/blog/restaurant-pos-offline-mode) +- [Shopify POS in a cafe — modifiers/disconnects (community thread)](https://community.shopify.com/t/experiencing-difficulties-with-shopify-pos-in-a-cafe-setting-any-advice/35154/18) diff --git a/docs/RESTAURANT-ROADMAP.md b/docs/RESTAURANT-ROADMAP.md new file mode 100644 index 0000000..a649b2a --- /dev/null +++ b/docs/RESTAURANT-ROADMAP.md @@ -0,0 +1,142 @@ +# 🍽️ Restaurant POS — capability map & roadmap + +*What a full restaurant POS actually needs (measured against Toast / Square for Restaurants / Aloha / TouchBistro +/ Revel), scored against what the customPOS engine already does, and a build order so the vertical is driven by a +plan — not spelled out feature by feature.* + +> Read with [RESTAURANT-VERTICAL.md](RESTAURANT-VERTICAL.md) (engine design) and +> [BURGER-BARN-STAGE.md](BURGER-BARN-STAGE.md) (the Stage-2 proving ground + Toast grievance capture). + +**Legend:** ✅ built · 🟡 partial / close · ❌ not yet. Status verified against `pos.html` and the `tests/` suite +(the test names in parentheses are the proof it exists). + +## 1. Front of house / the server flow +| Capability | Status | Notes | +|---|---|---| +| Take & send orders from a station | ✅ | central "server" station (`rules-and-fanout`) | +| Floor plan, sections, drag-drop designer | ✅ | `floor.js`, `floor-designer.js` | +| Table states (open→seated→ordered→…) + turn timing + covers | ✅ | `floor.js`, `turns.js` | +| Coursing — apps/drinks first, fire entrées, add dessert later | ✅ | `coursing.js` | +| **Order by SEAT number** (seat 2's ribeye) | ✅ | seat picker at intake; seat rides the line → KDS/station/ticket/check (`seat-order.js`) | +| Split the check | ✅ | even 2/3/4-way **and by seat** (`split.js`, `split-seat.js`) | +| **Per-seat checks** (each guest pays their own) | ✅ | "By seat" at payment; tenders tagged to the seat (`split-seat.js`) | +| Transfer / merge tables, move a guest | ✅ | move a check to another table (transfer/merge); per-line seat move (`table-transfer.js`) | +| Server assignment (sections → a server, "my tables") | ✅ | assign a server to a table; "my tables" floor filter (`server-assign.js`) | +| Reservations + waitlist | ✅ | host stand on the floor: book/seat/cancel + waitlist add/seat/clear (`reservations.js`) | + +## 2. Kitchen / expo +| Capability | Status | Notes | +|---|---|---| +| Per-item station routing / fan-out | ✅ | one order → grill + fry + bar (`rules-and-fanout`) | +| Kitchen Display (KDS) with aging timers + bump | ✅ | `kds.js` | +| Printed kitchen tickets routed by station | ✅ | `print.js` | +| 86 / par counts | ✅ | live, blocks the item everywhere | +| Course fire / hold to the line | ✅ | `coursing.js` | +| Allergen / prep flags on the ticket | ✅ | item `flags` | +| Seat # shown on the KDS ticket | 🟡 | rides on the seat build | + +## 3. Menu & items +| Capability | Status | Notes | +|---|---|---| +| Categories + Toast-style category tabs | ✅ | new (PR #5) | +| Modifiers (required/optional), add-ons, price deltas | ✅ | e.g. Patty single/double, cheese, bacon | +| Combos / upsell ("make it a combo") | ✅ | add-on combos | +| Open price / by-weight | ✅ | `custom-price.js`, `by-weight.js` | +| **Day-part menus** (breakfast till 11) | ✅ | item `avail` window hides it off-hours (`dayparts.js`) | +| **Time-based / happy-hour pricing** | ✅ | item `happyPrice` + shop `happyHour` window (`dayparts.js`) | + +## 4. Payments & checkout +| Capability | Status | Notes | +|---|---|---| +| Cash w/ change, card | ✅ | `cash-change.js`, `payments.js`, `idempotent-charge.js` | +| Tips + presets + **tip pooling** | ✅ | `tippool.js` | +| Discounts / coupons | ✅ | `coupon.js` | +| **Comps** (free item, reason-coded, manager-approved) | ✅ | per-line "comp"/"un-comp", reason + audit; approval opt-in (`comp.js`) | +| Voids / returns with audit + manager approval | ✅ | `line-return.js`, `approvals.js` | +| Gift cards + loyalty points | ✅ | `loyalty-giftcards.js` | +| House accounts / A/R, deposits, round-up | ✅ | `house-account.js`, `deposit-*.js`, `roundup.js` | +| Receipt — print & email | ✅ | `print.js`, `notify.js`, `tax-receipt.js` | +| **Pay-at-table / QR pay** | 🟡 | needs a hosted pay page (hub) the guest's phone loads — not a single-file engine feature | + +## 5. Order types +| Capability | Status | Notes | +|---|---|---| +| Dine-in | ✅ | | +| Takeout / to-go | ✅ | | +| Own delivery routes (driver, stops, confirm) | ✅ | `route.js` | +| **Drive-thru mode** | ✅ | order types (dine-in/to-go/drive-thru) on the ticket + KDS (`order-types.js`) | +| **3rd-party delivery (DoorDash/UberEats) intake** | 🟡 | engine has the `channel` hook + chip; ingestion needs the hub + a merchant delivery account | +| **Online ordering / QR menu** | 🟡 | needs a hosted customer-facing page (hub), not a single-file engine feature | + +## 6. Staff & labor +| Capability | Status | Notes | +|---|---|---| +| PIN login + roles | ✅ | | +| Time clock, breaks, schedule, time-off | ✅ | `timeclock.js`, `breaks.js`, `schedule.js`, `timeoff.js` | +| Labor cost vs sales, paystubs | ✅ | `labor-cost.js`, `paystub.js` | +| Manager approvals (voids/refunds/comps) | ✅ | `approvals.js` | + +## 7. Inventory, reporting, back office +| Capability | Status | Notes | +|---|---|---| +| Stock tracking, reorder, low-stock, margin | ✅ | `inventory.js`, `margin.js` | +| Sales / top items / P&L / expenses | ✅ | `report-*.js`, `top-items.js`, `expenses-pnl.js` | +| Busy hours + table-turn analytics | ✅ | `busy-hours.js`, `turns.js` | +| Activity / audit log + CSV | ✅ | `activity-log.js` | +| Multi-store | ✅ | `multistore.js` | +| Accounting export (QuickBooks) | 🟡 | exists on the Ozark instance; not in the shared engine | + +## 8. Compliance, platform & reliability (already strong) +| Capability | Status | Notes | +|---|---|---| +| Age gate by **typed birthdate** (computes age, refuses under-age) | ✅ | new (PR #5); `age-gate.js`, `age-check.js` | +| Tax: rate, zones, tax classes, tax-included | ✅ | `tax-*.js` | +| Offline-first single file / PWA | ✅ | `pwa.js`, `first-run.js` | +| Sync hub + delta + conflict merge, multi-register | ✅ | `sync.js`, `delta-*.js`, `hub-merge.js` | +| Encrypted, verified backups | ✅ | `backup*.js` | +| Crash/incident reporting + security guards | ✅ | `crash-reporter.js`, `injection.js`, `blob-guard.js` | + +--- + +## The gap list (what a full-service line still needs) +Everything not ✅ above, collected: + +1. **Order by seat number** (+ seat on the KDS ticket) +2. **Split & pay by seat / by item** (not just even N-way) +3. **Comps** — free item, reason-coded, manager-approved (separate from a discount) +4. **Server assignment** — sections/tables owned by a server; a "my tables" view +5. **Transfer / merge tables**, move a guest between seats/tables +6. **Day-part menus + happy-hour / time-based pricing** +7. **Reservations + waitlist** +8. **Pay-at-table / QR pay** +9. **Online ordering / QR menu**, and 3rd-party delivery intake +10. **Drive-thru mode** (only if a concept needs it) + +## Build order (proposed) +Sequenced by daily value to a sit-down burger place like Hamburger Barn, and by how much each unlocks the rest. + +- **P1 — the heart of the server flow** + 1. ✅ Seat-numbered ordering (assign each line to a seat; seats show on the ticket & KDS) — *shipped* + 2. ✅ Split & pay **by seat** — *shipped* (by-item split is a later refinement) + 3. ✅ Comps with reason (+ opt-in manager approval) — *shipped* +- **P2 — runs a real dining room** + 4. ✅ Server assignment + "my tables" — *shipped* + 5. ✅ Transfer / merge tables, move a guest — *shipped* + 6. ✅ Day-part menus + happy-hour pricing — *shipped* +- **P3 — reservations & the door** + 7. ✅ Reservations + waitlist — *shipped* +- **P4 — off-premise & self-serve** + 10. ✅ Drive-thru (order types: dine-in / to-go / drive-thru) — *shipped* + 8/9. Pay-at-table / QR pay · online ordering / QR menu · 3rd-party delivery intake — **need the hub + a + hosted customer-facing page + (for delivery) the merchant's own accounts.** These are honestly *not* + single-file engine features: the engine side is ready (an order `channel` hook + chip so an online or + delivery order displays and routes like any other), but the customer-facing web + processor + delivery + API are a hub/hosting/account project, sequenced after Burger Barn's on-premise flow proves out. + +Each item ships through the safety pipeline (draft → gates/tests → live preview → owner approval → deploy → rollback), +with a test pinning the behavior — same as every change in this repo. + +## How this changes the way we work +This doc is the plan of record: the build runs **down this list**, and the owner **steers** (reprioritize, veto, +add from real Burger Barn grievances) instead of spelling each feature out. When the owner works Toast at Burger +Barn, grievances captured in [BURGER-BARN-STAGE.md](BURGER-BARN-STAGE.md) get slotted into this order. diff --git a/pos.html b/pos.html index abfe93f..6dd9d14 100644 --- a/pos.html +++ b/pos.html @@ -523,6 +523,14 @@ function storeForOrder(cust){ return (cust && cust.storeId) || homeStore() || defaultStoreId(); } // an order's home store function storeChip(r){ if(!storesOn() || !r.storeId) return ""; const nm=storeName(r.storeId); return nm ? " 🏬 "+nm+"" : ""; } function tableChip(r){ return r.tableId ? " 🍽 "+tableLabel(r.tableId)+"" : ""; } +// 👤 which seat a line belongs to (dine-in seat-by-seat ordering). Blank when the line isn't tied to a seat. +function seatChip(l){ return (l&&l.seat) ? " 👤 S"+l.seat+"" : ""; } +// 🚗 order type (dine-in / to-go / drive-thru) + channel (in-house vs an online/delivery order the hub handed in). +function orderTypes(){ return (FLOW.endpoints && FLOW.endpoints.orderTypes) || null; } +function orderTypeChip(r){ if(!r||!r.orderType) return ""; const ic=/drive/i.test(r.orderType)?"🚗":(/go|take/i.test(r.orderType)?"🥡":"🍽"); return " "+ic+" "+r.orderType+""; } +function channelChip(r){ return (r&&r.channel&&r.channel!=="in-house") ? " 🌐 "+r.channel+"" : ""; } +// 🎁 a comped line (free, reason-coded, optionally manager-approved). Shows the reason on hover. +function compChip(l){ return (l&&l.comped) ? " 🎁 comp" : ""; } function assembledAt(r){ if(!storesOn() || !r.storeId) return ""; const p=plantStore(); return (p && r.storeId!==p.id) ? "assembled at "+p.name : ""; } // a drop-store order is done at the plant /* ===================================== MONEY MATH ===================================== */ @@ -560,8 +568,29 @@ // age-restricted items (tobacco/alcohol/lottery): an item's ageRestricted (true→21, or a number) drives an ID-check gate at checkout function lineAge(l){ const it=FLOW.catalog.find(i=>i.id===l.catId); const a=it&&it.ageRestricted; return a===true?21:(+a||0); } function orderAgeReq(r){ return Math.max(0, ...((r.lines||[]).filter(l=>!l.returned).map(lineAge))); } +// whole-year age from a TYPED birthdate — accepts YYYY-MM-DD or M/D/YYYY; null if it isn't a real date. +// We compute and gate on the age; we deliberately do NOT store the raw DOB (the defensible record is +// "age N was verified/refused, when, at which station" — not the customer's date of birth). +function ageFromDOB(s){ s=String(s||"").trim(); if(!s) return null; + var m1=s.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/), m2=s.match(/^(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{4})$/), y,m,d; + if(m1){ y=+m1[1]; m=+m1[2]; d=+m1[3]; } else if(m2){ m=+m2[1]; d=+m2[2]; y=+m2[3]; } else return null; + if(m<1||m>12||d<1||d>31) return null; + var dob=new Date(y,m-1,d); if(dob.getFullYear()!==y||dob.getMonth()!==m-1||dob.getDate()!==d) return null; // rejects 02/30 etc. + if(dob>new Date()) return null; + var now=new Date(), a=now.getFullYear()-y; if(now.getMonth() s + t.amount, 0); } function recordBalance(r){ return +(recordTotal(r) - recordPaid(r)).toFixed(2); } +// 👤 SPLIT BY SEAT — each guest pays their own. One bucket per seat (+ a "Shared" bucket for whole-table lines). +// A seat's amount = its share of the line subtotal, scaled to the record total so tax/tip/discount ride along. +function orderHasSeats(r){ return (r.lines||[]).some(l=>!l.returned && l.seat); } +function seatBuckets(r){ const map={}; + (r.lines||[]).forEach(l=>{ if(l.returned) return; const k=l.seat?("s"+l.seat):"shared"; + (map[k]=map[k]||{ key:k, seat:l.seat||null, label:l.seat?("Seat "+l.seat):"Shared", sub:0 }).sub += l.price*l.qty; }); + return Object.keys(map).sort((a,b)=>(map[a].seat||0)-(map[b].seat||0)).map(k=>map[k]); } +function seatAmount(r, bucket){ const subTot=(r.lines||[]).filter(l=>!l.returned).reduce((s,l)=>s+l.price*l.qty,0)||1; + return +(recordTotal(r) * bucket.sub / subTot).toFixed(2); } +function seatPaid(r, key){ return (r.tenders||[]).filter(t=>t.seatKey===key).reduce((s,t)=>s+t.amount,0); } +function seatDue(r, bucket){ return +Math.max(0, seatAmount(r,bucket)-seatPaid(r,bucket.key)).toFixed(2); } function firstPerformer(r){ const l=r.lines.find(x=>x.performer); return l?l.performer:null; } // refund reverses every original tender back to itself (card → card, cash → cash) and marks the order REFUNDED function refundRecord(r){ @@ -589,6 +618,19 @@ logActivity("refund", "line refund "+money(amount)+" on #"+r.number+" — "+names.join(", "), r.number, amount); saveDB(DB); render(); } +// 🎁 COMP a line — take it to $0 with a reason on the record. Distinct from a discount (a %-off promo): a comp +// is discretionary ("kitchen error", "long wait", "on the house"). Manager-approved when the instance opts in +// (FLOW.endpoints.approvals.comp + approver roles) — otherwise reason-only. Reversible via the same control. +const COMP_REASONS=["Manager comp","Kitchen error","Long wait","Customer service","Birthday","On the house"]; +function compLine(r, l){ + if(l.comped){ withApproval("comp","Un-comp "+l.name,(who)=>{ l.price=(l.comped&&l.comped.wasPrice)||l.price; delete l.comped; logActivity("comp","un-comped "+l.name+(who?" (by "+who.name+")":""), r.number); saveDB(DB); }); return; } + const reason=prompt("Comp "+l.name+" — reason?\n("+COMP_REASONS.join(" · ")+")", "Manager comp"); + if(reason==null) return; + const amt=+(l.price*l.qty).toFixed(2); + withApproval("comp","Comp "+l.name+" ("+money(amt)+") — "+(reason.trim()||"Manager comp"),(who)=>{ + l.comped={ reason:(reason.trim()||"Manager comp"), by:(who?who.name:((typeof actingStaff==="function"&&actingStaff())||"")), at:new Date().toISOString().slice(11,16), wasPrice:l.price, amount:amt }; + l.price=0; logActivity("comp","comped "+l.name+" — "+(reason.trim()||"Manager comp")+(who?" (by "+who.name+")":""), r.number, amt); saveDB(DB); }); +} /* Processor-agnostic PAYMENTS interface (matches docs/PAYMENTS-MODULE.md). By default — with no processor connected — the "record" adapter simply RECORDS a card sale on the order (so the books and the drawer are @@ -676,6 +718,16 @@ } function isLowStock(item){ return tracksStock(item) && stockOnHand(item) <= (item.reorderAt!=null?item.reorderAt:0); } function isOutOfStock(item){ return tracksStock(item) && stockOnHand(item) <= 0; } +// ⏰ TIME WINDOWS — day-part menus (breakfast till 11) + happy-hour pricing. A window is {days?:[0-6], from, to} +// ("HH:MM"); days omitted = every day; from/to omitted = all day; from>to spans midnight. All read the live clock. +function hmToMin(s){ const m=String(s||"").match(/^(\d{1,2}):(\d{2})$/); return m?(+m[1]*60 + +m[2]):null; } +function withinWindow(win, at){ if(!win) return false; const now=at||new Date(); const day=now.getDay(); + if(Array.isArray(win.days) && win.days.length && win.days.indexOf(day)<0) return false; + const f=hmToMin(win.from), t=hmToMin(win.to); if(f==null||t==null) return true; + const cur=now.getHours()*60+now.getMinutes(); return f<=t ? (cur>=f&&cur=f||curopenPunch(s.id)); return on.length===1?on[0].name:(on.length>1?"(multiple on shift)":""); }catch(e){ return ""; } } function logActivity(type, detail, orderNo, amt){ try{ DB=DB||loadDB(); DB.activity=DB.activity||[]; @@ -888,7 +940,7 @@ const done = (storesOn() && reportStore) ? dayAll.filter(r=>r.storeId===reportStore) : dayAll; const L=(lab,val,bold)=>"
"+(bold?"":"")+lab+(bold?"":"")+""+(bold?"":"")+val+(bold?"":"")+"
"; // ---- daily sales summary (refund-aware: a refunded order's sale nets out, its reversed tender nets its tenders) ---- - let net=0, tax=0, tips=0, disc=0, items=0, refunds=0, orders=0, cogs=0, roundUp=0; const byTender={}, byCat={}, byItem={}, byHour={}; + let net=0, tax=0, tips=0, disc=0, items=0, refunds=0, orders=0, cogs=0, roundUp=0, comps=0; const byTender={}, byCat={}, byItem={}, byHour={}; const itemCost=(l)=>{ const it=FLOW.catalog.find(i=>i.id===l.catId); return (it&&+it.cost)||0; }; done.forEach(r=>{ const rf=r.status==="REFUNDED"; if(rf){ refunds += recordTotal(r); } @@ -897,6 +949,7 @@ net += recordNet(r)*frac; tax += recordTax(r)*frac; tips += r.tip||0; disc += recordDiscount(r)*frac; roundUp += r.roundUp||0; if(r.ts){ const hr=new Date(r.ts).getHours(); byHour[hr]=(byHour[hr]||0)+recordNet(r)*frac; } r.lines.filter(l=>!l.returned).forEach(l=>{ items += l.qty; cogs += itemCost(l)*l.qty; byCat[l.category||"other"]=(byCat[l.category||"other"]||0)+l.price*l.qty; + if(l.comped) comps += (l.comped.amount||0); // 🎁 value given away as comps today const bi=byItem[l.name]=byItem[l.name]||{qty:0,rev:0,cost:0}; bi.qty+=l.qty; bi.rev+=l.price*l.qty; bi.cost+=itemCost(l)*l.qty; }); const ret = -((r.tenders||[]).filter(t=>t.refund).reduce((s,t)=>s+t.amount,0)); if(ret>0.0001) refunds += ret; // partial line returns } @@ -922,9 +975,18 @@ const sum=document.createElement("div"); sum.className="card"; sum.innerHTML="
Sales summary
" + L("Orders", String(orders)) + L("Items sold", String(items)) - + L("Net sales", money(net)) + (disc?L("Discounts","−"+money(disc)):"") + L("Tax collected", money(tax)) + (tips?L("Tips", money(tips)):"") + (refunds?L("Refunds","−"+money(refunds)):"") + + L("Net sales", money(net)) + (disc?L("Discounts","−"+money(disc)):"") + (comps?L("Comps (on the house)","−"+money(comps)):"") + L("Tax collected", money(tax)) + (tips?L("Tips", money(tips)):"") + (refunds?L("Refunds","−"+money(refunds)):"") + L("Total collected", money(collected), true) + (onAccount?L("On account (A/R)", money(onAccount)):""); app.appendChild(sum); + // 💸 tip-out by role — the payroll pain point. When configured, show each support role's cut of today's tips + // (a % of tips) and what's left for the servers, so tip-out is spelled out at close instead of done by hand. + const tipOut=(FLOW.endpoints.payment.tip&&FLOW.endpoints.payment.tip.tipOut)||null; + if(tipOut && tipOut.length && tips>0.0001){ + let out=0; const rows=tipOut.map(t=>{ const cut=+(tips*(+t.pct||0)/100).toFixed(2); out+=cut; return L("→ "+(t.role||"support")+" ("+(+t.pct||0)+"%)","−"+money(cut)); }).join(""); + const toCard=document.createElement("div"); toCard.className="card"; + toCard.innerHTML="
Tip-out from "+money(tips)+" in tips
"+rows+L("Kept by servers", money(+(tips-out).toFixed(2)), true); + app.appendChild(toCard); + } // ---- community: charity round-ups collected today (only when the round-up option is on) ---- if(FLOW.endpoints.payment.roundUp && roundUp>0){ const cause=(FLOW.endpoints.payment.roundUp.cause)||"a local cause"; const cc=document.createElement("div"); cc.className="card"; cc.innerHTML="
Community ♥
"+L("Rounded up for "+cause, money(roundUp), true)+"
Collected from customers who chose to round up — set it aside to donate.
"; @@ -1115,7 +1177,9 @@ // ---- ID checks (audit): defensible record of every age-restricted sale that was verified ---- { const idlog=(DB.ageLog||[]).filter(a=>a.day===todayStr()); if(idlog.length){ const ic=document.createElement("div"); ic.className="card"; ic.innerHTML="
ID checks (audit) "+idlog.length+" today
"; - idlog.slice(-8).reverse().forEach(a=>{ const row=document.createElement("div"); row.className="line"; row.innerHTML=""+a.at+" · #"+a.number+" — verified "+a.req+"+"+(a.station||"")+""; ic.appendChild(row); }); app.appendChild(ic); } } + idlog.slice(-8).reverse().forEach(a=>{ const row=document.createElement("div"); row.className="line"; + const res=a.result==="refused" ? ("REFUSED — under "+a.req+"") : ("verified "+a.req+"+"); + row.innerHTML=""+a.at+" · #"+a.number+" — "+res+(a.age!=null?" (age "+a.age+")":"")+""+(a.station||"")+""; ic.appendChild(row); }); app.appendChild(ic); } } // ---- commission (only when the trade pays commission) ---- const pct=(FLOW.commission&&FLOW.commission.pct)||0; if(FLOW.performers){ @@ -1404,9 +1468,9 @@ if(!live.length){ const e=document.createElement("div"); e.className="empty card"; e.textContent="All caught up — no tickets working."; app.appendChild(e); } live.forEach(r=>{ const c=document.createElement("div"); c.className="card"; const fc=r.firedCourse||1; - c.innerHTML="
#"+r.number+" "+kdsChip(r)+(coursingOn()?" "+courseLabel(fc)+"":"")+""+r.createdAt+"
"; + c.innerHTML="
#"+r.number+" "+kdsChip(r)+orderTypeChip(r)+channelChip(r)+(coursingOn()?" "+courseLabel(fc)+"":"")+""+r.createdAt+"
"; r.lines.filter(l=>courseReleased(r,l)).forEach(l=>{ const row=document.createElement("div"); row.className="line"; const where=lineComplete(l)?"ready":("at "+l.path[l.stage]); - row.innerHTML=""+l.qty+"× "+l.name+(l.modLabel?" "+l.modLabel+"":"")+""+where+""; c.appendChild(row); }); + row.innerHTML=""+l.qty+"× "+l.name+(l.modLabel?" "+l.modLabel+"":"")+seatChip(l)+""+where+""; c.appendChild(row); }); // held courses waiting to be fired if(coursingOn()){ const held=r.lines.filter(l=>!courseReleased(r,l)); if(held.length){ const h=document.createElement("div"); h.className="muted"; h.style.marginTop="4px"; h.textContent="held: "+held.map(l=>l.qty+"× "+l.name).join(", ")+" ("+courseLabel(fc+1)+")"; c.appendChild(h); } } @@ -1431,14 +1495,37 @@ ]; function floorStates(){ return (FLOW.floor && FLOW.floor.states) || FLOOR_STATES; } function floorStateById(id){ return floorStates().find(s=>s.id===id) || floorStates()[0]; } -function tableRec(t){ const r=(DB.tables||{})[t.id]; return { state:(r&&r.state)||floorStates()[0].id, since:(r&&r.since)||null, seatedTs:(r&&r.seatedTs)||null }; } +function tableRec(t){ const r=(DB.tables||{})[t.id]; return { state:(r&&r.state)||floorStates()[0].id, since:(r&&r.since)||null, seatedTs:(r&&r.seatedTs)||null, server:(r&&r.server)||null }; } +// 🧑‍🍳 server assignment: which server owns a table, and the "my tables" view. +function tableServer(id){ const r=(DB.tables||{})[id]; return (r&&r.server)||null; } +function serverStaff(){ return (FLOW.staff||[]); } // any staff member can own tables +function assignServer(id, name){ DB=loadDB(); DB.tables=DB.tables||{}; const prev=DB.tables[id]||{ state:floorStates()[0].id, since:Date.now() }; + DB.tables[id]=Object.assign({}, prev, { server:(name||null) }); saveDB(DB); + logActivity("access", (name?("table "+tableLabel(id)+" → "+name):("cleared server on table "+tableLabel(id)))); render(); } // state change also drives turn-time analytics: a "turn" runs from leaving Empty until returning to Empty function setTableState(id, state){ DB=loadDB(); DB.tables=DB.tables||{}; DB.turns=DB.turns||[]; const emptyId=floorStates()[0].id; const now=Date.now(); const prev=DB.tables[id]||{}; let seatedTs=prev.seatedTs||null; const wasEmpty=!prev.state || prev.state===emptyId; if(wasEmpty && state!==emptyId) seatedTs=now; // a table just got seated — turn begins if(state===emptyId){ if(seatedTs) DB.turns.push({ table:id, seatedTs, clearedTs:now, dur:now-seatedTs, day:todayStr() }); seatedTs=null; } // cleared — turn ends, logged - DB.tables[id]={ state, since:now, seatedTs }; saveDB(DB); render(); + DB.tables[id]=Object.assign({}, prev, { state, since:now, seatedTs }); // keep the assigned server across state changes + if(state===emptyId) delete DB.tables[id].server; // a cleared table is free again + saveDB(DB); render(); +} +// 🔀 TRANSFER / MERGE tables — move a table's open check(s) to another table. If the target is empty it's a +// transfer (party moved); if it already has a check it's a merge (two tables become one party). The source is +// then freed (its turn ends). Reuses setTableState so turn analytics and the server-clear stay consistent. +function moveTable(fromId, toId){ if(!toId || fromId===toId) return; + DB=loadDB(); const moved=(DB.records||[]).filter(r=>r.tableId===fromId && r.status!=="CLOSED" && r.status!=="REFUNDED"); + if(!moved.length){ flash("No open check on "+tableLabel(fromId)); return; } + moved.forEach(r=>{ r.tableId=toId; r._t=Date.now(); }); + DB.tables=DB.tables||{}; const src=DB.tables[fromId]||{}, tgt=DB.tables[toId]||{}; + const emptyId=floorStates()[0].id, occ=(floorStates()[1]||floorStates()[0]).id; + const wasOccupied=(tgt.state && tgt.state!==emptyId); + DB.tables[toId]=Object.assign({}, tgt, { state: wasOccupied?tgt.state:occ, since:Date.now(), seatedTs: tgt.seatedTs||src.seatedTs||Date.now(), server: tgt.server||src.server||null }); + saveDB(DB); + logActivity("access", (wasOccupied?"merged ":"moved ")+tableLabel(fromId)+" → "+tableLabel(toId)+" ("+moved.length+" check"+(moved.length>1?"s":"")+")"); + selTable=toId; setTableState(fromId, emptyId); // free the source (logs its turn, clears its server, saves + renders) } function tableSeats(id){ const t=((FLOW.floor&&FLOW.floor.tables)||[]).find(x=>x.id===id); return (t&&+t.seats)||0; } function tableLabel(id){ const t=((FLOW.floor&&FLOW.floor.tables)||[]).find(x=>x.id===id); return (t&&(t.label||t.id))||id; } @@ -1447,15 +1534,69 @@ function tableOpenTotal(id){ return tableOpenOrders(id).reduce((s,r)=>s+recordBalance(r),0); } function turnsToday(){ return (DB.turns||[]).filter(x=>x.day===todayStr()); } function turnStats(){ const ts=turnsToday(); const n=ts.length; return { n, avgMs:n?ts.reduce((s,x)=>s+x.dur,0)/n:0, covers:ts.reduce((s,x)=>s+tableSeats(x.table),0) }; } +/* 🕐 RESERVATIONS + WAITLIST — the host stand, run from the floor. Reservations are booked for a time today; + the waitlist holds walk-ins with a quoted wait. Both can be "seated" (optionally onto the selected table) or + cleared. Records are never hard-deleted — a reservation is cancelled, a waitlist party marked seated/left. */ +function todayReservations(){ return (DB.reservations||[]).filter(r=>r.day===todayStr() && r.status!=="cancelled").sort((a,b)=>String(a.time||"").localeCompare(String(b.time||""))); } +function addReservation(name, party, time, phone){ name=(name||"").trim(); if(!name){ flash("Name for the reservation?"); return; } + DB=loadDB(); DB.reservations=DB.reservations||[]; DB.reservations.push({ id:"RSV"+Date.now(), name, phone:(phone||"").trim(), party:Math.max(1,+party||1), time:(time||"").trim(), day:todayStr(), status:"booked" }); + logActivity("access","reservation · "+name+" ×"+(Math.max(1,+party||1))+((time||"").trim()?" @ "+time.trim():"")); saveDB(DB); render(); } +function seatReservation(id, tableId){ DB=loadDB(); const r=(DB.reservations||[]).find(x=>x.id===id); if(!r) return; r.status="seated"; if(tableId) r.tableId=tableId; logActivity("access","seated reservation · "+r.name); saveDB(DB); + if(tableId) setTableState(tableId, (floorStates()[1]||floorStates()[0]).id); else render(); } // save the seated status BEFORE setTableState (it reloads DB) +function cancelReservation(id){ DB=loadDB(); const r=(DB.reservations||[]).find(x=>x.id===id); if(!r) return; r.status="cancelled"; logActivity("access","cancelled reservation · "+r.name); saveDB(DB); render(); } +function activeWaitlist(){ return (DB.waitlist||[]).filter(w=>w.status==="waiting").sort((a,b)=>(a.addedTs||0)-(b.addedTs||0)); } +function waitlistWait(w){ return Math.round((Date.now()-(w.addedTs||Date.now()))/60000); } +function addWaitlist(name, party, quote, phone){ name=(name||"").trim(); if(!name){ flash("Name for the waitlist?"); return; } + DB=loadDB(); DB.waitlist=DB.waitlist||[]; DB.waitlist.push({ id:"WL"+Date.now(), name, phone:(phone||"").trim(), party:Math.max(1,+party||1), quote:Math.max(0,+quote||0), addedTs:Date.now(), status:"waiting" }); + logActivity("access","waitlist + "+name+" ×"+(Math.max(1,+party||1))+((+quote||0)?" ~"+(+quote)+"m":"")); saveDB(DB); render(); } +function seatWaitlist(id, tableId){ DB=loadDB(); const w=(DB.waitlist||[]).find(x=>x.id===id); if(!w) return; w.status="seated"; if(tableId) w.tableId=tableId; logActivity("access","seated waitlist · "+w.name); saveDB(DB); + if(tableId) setTableState(tableId, (floorStates()[1]||floorStates()[0]).id); else render(); } // save before setTableState (it reloads DB) +function leaveWaitlist(id){ DB=loadDB(); const w=(DB.waitlist||[]).find(x=>x.id===id); if(!w) return; w.status="left"; logActivity("access","waitlist left · "+w.name); saveDB(DB); render(); } +// the host stand UI — a Reservations card and a Waitlist card, each with a quick add row + "Seat"/"clear". +// "Seat" uses the currently-selected table when one is picked, so the host taps a table then seats the party. +function renderHostStand(app){ + const seatTarget = selTable ? (" → "+tableLabel(selTable)) : ""; + const mkAdd=(fields, onAdd)=>{ const row=document.createElement("div"); row.className="opts"; row.style.cssText="display:flex;gap:6px;flex-wrap:wrap;margin-top:8px"; + const els={}; fields.forEach(f=>{ const i=document.createElement("input"); i.placeholder=f.ph; i.id=f.id; if(f.w) i.style.width=f.w; if(f.type) i.type=f.type; row.appendChild(i); els[f.id]=i; }); + const add=document.createElement("button"); add.className="primary"; add.textContent="Add"; add.onclick=()=>{ onAdd(els); }; row.appendChild(add); return row; }; + // Reservations + const rc=document.createElement("div"); rc.className="card"; rc.style.marginBottom="10px"; + rc.innerHTML="
🕐 Reservations "+todayReservations().filter(r=>r.status==="booked").length+" booked
"; + todayReservations().forEach(r=>{ const row=document.createElement("div"); row.className="line"; + row.innerHTML=""+(r.status==="seated"?"✓ ":"")+""+(r.time||"—")+" · "+r.name+" ×"+r.party+(r.phone?" "+r.phone+"":"")+(r.status==="seated"?" seated":"")+""; + if(r.status!=="seated"){ const s=document.createElement("button"); s.className="good"; s.textContent="Seat"+seatTarget; s.onclick=()=>seatReservation(r.id, selTable||null); row.appendChild(s); + const x=document.createElement("button"); x.textContent="✕"; x.title="Cancel"; x.style.marginLeft="4px"; x.onclick=()=>cancelReservation(r.id); row.appendChild(x); } + rc.appendChild(row); }); + rc.appendChild(mkAdd([{id:"rsvName",ph:"name"},{id:"rsvParty",ph:"party",w:"64px",type:"number"},{id:"rsvTime",ph:"time e.g. 7:00",w:"110px"},{id:"rsvPhone",ph:"phone (optional)"}], + els=>{ addReservation(els.rsvName.value, els.rsvParty.value, els.rsvTime.value, els.rsvPhone.value); })); + app.appendChild(rc); + // Waitlist + const wc=document.createElement("div"); wc.className="card"; wc.style.marginBottom="10px"; + wc.innerHTML="
📝 Waitlist "+activeWaitlist().length+" waiting
"; + activeWaitlist().forEach(w=>{ const row=document.createElement("div"); row.className="line"; const waited=waitlistWait(w); + row.innerHTML=""+w.name+" ×"+w.party+(w.quote?" · quoted "+w.quote+"m":"")+" · waiting "+waited+"m"; + const s=document.createElement("button"); s.className="good"; s.textContent="Seat"+seatTarget; s.onclick=()=>seatWaitlist(w.id, selTable||null); row.appendChild(s); + const x=document.createElement("button"); x.textContent="✕"; x.title="Left"; x.style.marginLeft="4px"; x.onclick=()=>leaveWaitlist(w.id); row.appendChild(x); + wc.appendChild(row); }); + wc.appendChild(mkAdd([{id:"wlName",ph:"name"},{id:"wlParty",ph:"party",w:"64px",type:"number"},{id:"wlQuote",ph:"quote min",w:"90px",type:"number"},{id:"wlPhone",ph:"phone (optional)"}], + els=>{ addWaitlist(els.wlName.value, els.wlParty.value, els.wlQuote.value, els.wlPhone.value); })); + app.appendChild(wc); +} function renderFloor(app, station){ const cfg=FLOW.floor||{ tables:[] }; app.innerHTML="

"+station.label+"

"; - const tables=(cfg.tables||[]).filter(t=> !floorSection || (t.section||"")===floorSection); + renderHostStand(app); // 🕐 reservations + waitlist at the top of the host's floor screen + const tables=(cfg.tables||[]).filter(t=> (!floorSection || (t.section||"")===floorSection) && (!floorServer || tableServer(t.id)===floorServer)); const sections=cfg.sections || [...new Set((cfg.tables||[]).map(t=>t.section).filter(Boolean))]; // section filter (server sees a section; manager sees all) if(sections.length){ const bar=document.createElement("div"); bar.className="opts"; bar.style.marginBottom="10px"; const mk=(lab,val)=>{ const b=document.createElement("button"); if(floorSection===val) b.className="sel"; b.textContent=lab; b.onclick=()=>{ floorSection=val; selTable=null; render(); }; bar.appendChild(b); }; mk("All", null); sections.forEach(s=>mk(s, s)); app.appendChild(bar); } + // 🧑‍🍳 "my tables" — filter the floor to one server's tables + if(serverStaff().length){ const sbar=document.createElement("div"); sbar.className="opts"; sbar.style.marginBottom="10px"; + const lbl=document.createElement("span"); lbl.className="muted"; lbl.style.marginRight="6px"; lbl.textContent="Tables:"; sbar.appendChild(lbl); + const mk=(lab,val)=>{ const b=document.createElement("button"); if(floorServer===val) b.className="sel"; b.textContent=lab; b.onclick=()=>{ floorServer=val; selTable=null; render(); }; sbar.appendChild(b); }; + mk("All", null); serverStaff().forEach(s=>mk(s.name, s.name)); app.appendChild(sbar); } // at-a-glance: how many tables are occupied (not empty) const occupied=tables.filter(t=>tableRec(t).state!=="empty").length; const meta=document.createElement("p"); meta.className="muted"; meta.style.margin="0 0 10px"; meta.textContent=occupied+" of "+tables.length+" tables occupied · tap a table to advance it"; app.appendChild(meta); @@ -1478,6 +1619,7 @@ const chk=tableOpenTotal(t.id); b.innerHTML=""+ (t.label||t.id) +" "+st.label+"" +""+(t.seats?t.seats+" seats":"")+(t.section?" · "+t.section:"")+"" + +""+(tableServer(t.id)?"🧑‍🍳 "+tableServer(t.id):"")+"" +""+(chk>0.0001?"🧾 "+money(chk)+" open":(tr.since?fmtDur(el)+" in state":"—"))+""; b.onclick=()=>{ selTable=(selTable===t.id?null:t.id); render(); }; grid.appendChild(b); }); @@ -1496,7 +1638,19 @@ if(nx){ const adv=document.createElement("button"); adv.className="primary"; adv.textContent="→ "+nx.label; adv.style.margin="4px 8px 8px 0"; adv.onclick=()=>{ setTableState(t.id, nx.id); }; c.appendChild(adv); } const jump=document.createElement("div"); jump.className="opts"; floorStates().forEach(s=>{ const b=document.createElement("button"); if(s.id===st.id) b.className="sel"; b.textContent=s.label; b.onclick=()=>setTableState(t.id, s.id); jump.appendChild(b); }); - c.appendChild(jump); app.appendChild(c); + c.appendChild(jump); + // 🧑‍🍳 assign a server to this table (or clear) + if(serverStaff().length){ const sv=document.createElement("div"); sv.className="opts"; sv.style.marginTop="8px"; + const lbl=document.createElement("span"); lbl.className="muted"; lbl.style.marginRight="6px"; lbl.textContent="Server:"; sv.appendChild(lbl); + serverStaff().forEach(s=>{ const b=document.createElement("button"); if(tableServer(t.id)===s.name) b.className="sel"; b.textContent=s.name; b.onclick=()=>assignServer(t.id, tableServer(t.id)===s.name?null:s.name); sv.appendChild(b); }); + c.appendChild(sv); } + // 🔀 transfer / merge this table's open check to another table + if(tableOpenOrders(t.id).length){ const mv=document.createElement("div"); mv.className="opts"; mv.style.marginTop="8px"; + const lbl=document.createElement("span"); lbl.className="muted"; lbl.style.marginRight="6px"; lbl.textContent="Move check to:"; mv.appendChild(lbl); + (cfg.tables||[]).filter(x=>x.id!==t.id).forEach(x=>{ const occ=tableOpenOrders(x.id).length; const b=document.createElement("button"); + b.textContent=(x.label||x.id)+(occ?" (merge)":""); b.title=occ?("Merge onto "+(x.label||x.id)):("Move to "+(x.label||x.id)); b.onclick=()=>moveTable(t.id, x.id); mv.appendChild(b); }); + c.appendChild(mv); } + app.appendChild(c); } } } @@ -1827,6 +1981,7 @@ let reportStore = null; // multi-store report: which store the report is scoped to (null = all stores, with roll-up) let selTable = null; // floor plan: the table currently opened let floorSection = null; // floor plan: active section filter (null = all) +let floorServer = null; // floor plan: "my tables" — filter to one server (null = all) let selCell = null; // scheduling: the {staffId, day} cell being edited function attachCustomer(phone, name){ phone=(phone||"").trim(); if(!phone) return; @@ -1875,38 +2030,86 @@ function renderIntake(app, station){ if(!draft) draft = { lines:[] }; renderCustomerBar(app, station); + // 🚗 order type — dine-in / to-go / drive-thru (when the flow configures them) + if(orderTypes() && orderTypes().length){ + if(!draft.orderType) draft.orderType=orderTypes()[0]; + const ot=document.createElement("div"); ot.className="card"; ot.style.cssText="padding:8px 10px;margin-bottom:10px"; + const lbl=document.createElement("span"); lbl.className="muted"; lbl.style.marginRight="8px"; lbl.textContent="Order:"; ot.appendChild(lbl); + const opts=document.createElement("div"); opts.className="opts"; opts.style.display="inline-flex"; opts.style.flexWrap="wrap"; + orderTypes().forEach(tp=>{ const b=document.createElement("button"); if(draft.orderType===tp) b.className="sel"; b.textContent=tp; b.onclick=()=>{ draft.orderType=tp; render(); }; opts.appendChild(b); }); + ot.appendChild(opts); app.appendChild(ot); + } // table selector — when a floor is configured, ring this check to a table (restaurants) if(FLOW.floor && (FLOW.floor.tables||[]).length){ const tb=document.createElement("div"); tb.className="card"; tb.style.cssText="padding:8px 10px;margin-bottom:10px"; const lbl=document.createElement("span"); lbl.className="muted"; lbl.style.marginRight="8px"; lbl.textContent="🍽 Table:"; tb.appendChild(lbl); const opts=document.createElement("div"); opts.className="opts"; opts.style.display="inline-flex"; opts.style.flexWrap="wrap"; (FLOW.floor.tables||[]).forEach(t=>{ const b=document.createElement("button"); const open=tableOpenTotal(t.id); if(draft.tableId===t.id) b.className="sel"; - b.textContent=(t.label||t.id)+(open>0.0001?" · "+money(open):""); b.onclick=()=>{ draft.tableId=(draft.tableId===t.id?null:t.id); render(); }; opts.appendChild(b); }); - const none=document.createElement("button"); if(!draft.tableId) none.className="sel"; none.textContent="No table"; none.onclick=()=>{ draft.tableId=null; render(); }; opts.appendChild(none); + b.textContent=(t.label||t.id)+(open>0.0001?" · "+money(open):""); b.onclick=()=>{ draft.tableId=(draft.tableId===t.id?null:t.id); draft.seat=null; draft.__seatMax=0; render(); }; opts.appendChild(b); }); + const none=document.createElement("button"); if(!draft.tableId) none.className="sel"; none.textContent="No table"; none.onclick=()=>{ draft.tableId=null; draft.seat=null; draft.__seatMax=0; render(); }; opts.appendChild(none); tb.appendChild(opts); app.appendChild(tb); } + // 👤 seat picker — dine-in seat-by-seat ordering. Pick a seat and the items you tap ring to that seat (so the + // kitchen and the check know whose is whose, and the table can split by seat). "Whole table" = shared/no seat. + // Seats shown = the table's capacity, or higher if the ticket already used more, or the server added seats. + if(draft.tableId){ + const cap=tableSeats(draft.tableId)||0; + const usedMax=Math.max(0, ...(draft.lines||[]).map(l=>+l.seat||0)); + const shown=Math.max(cap, usedMax, draft.__seatMax||0, 4); // always offer at least a few + const sc=document.createElement("div"); sc.className="card"; sc.style.cssText="padding:8px 10px;margin-bottom:10px"; + const slbl=document.createElement("span"); slbl.className="muted"; slbl.style.marginRight="8px"; slbl.textContent="👤 Seat:"; sc.appendChild(slbl); + const sopts=document.createElement("div"); sopts.className="opts"; sopts.style.display="inline-flex"; sopts.style.flexWrap="wrap"; + const whole=document.createElement("button"); if(!draft.seat) whole.className="sel"; whole.textContent="Whole table"; whole.onclick=()=>{ draft.seat=null; render(); }; sopts.appendChild(whole); + for(let n=1;n<=shown;n++){ const cnt=(draft.lines||[]).filter(l=>(+l.seat||0)===n).length; const b=document.createElement("button"); if(draft.seat===n) b.className="sel"; + b.textContent="Seat "+n+(cnt?" ("+cnt+")":""); b.onclick=()=>{ draft.seat=n; render(); }; sopts.appendChild(b); } + const add=document.createElement("button"); add.textContent="+ seat"; add.title="Add another seat"; add.onclick=()=>{ draft.__seatMax=shown+1; draft.seat=shown+1; render(); }; sopts.appendChild(add); + sc.appendChild(sopts); app.appendChild(sc); + } const cat=document.createElement("div"); cat.innerHTML="

New order

"; // search / barcode-scan box — shown for big menus or when a scanner is configured (keyboard-wedge types code + Enter) const tiles=[]; const addItem=item=>{ const it=priceItem(item); if(!it) return; return needsConfig(it) ? (pending={item:it, choices:{}, addons:[], flags:[], performer:null}, render()) : addLine(it, {}, [], [], null); }; + // 🍔 Toast-style category tabs: a long menu (a real restaurant) is unusable as one flat wall of tiles, so we + // tab it by category. Generic to every vertical — categories come from the catalog in first-appearance order, + // labels from an optional FLOW.catLabels map (else the category title-cased). The dry cleaner's short menu + // stays a plain grid. Search + category filter combine through one applyFilter so they never fight. + let activeCat = draft.__cat || "__all"; + const catLabel = c => (FLOW.catLabels && FLOW.catLabels[c]) || (c ? c.charAt(0).toUpperCase()+c.slice(1) : "Other"); + const applyFilter=()=>{ const s=document.getElementById("catSearch"); const q=s?s.value.trim().toLowerCase():""; + tiles.forEach(({item,btn})=>{ const inCat=(activeCat==="__all")||((item.category||"other")===activeCat); + const hay=(item.name+" "+(item.category||"")+" "+(item.barcode||item.sku||"")).toLowerCase(); + btn.style.display=(inCat && (!q||hay.includes(q)))?"":"none"; }); }; if(FLOW.catalog.length>5 || FLOW.endpoints.scan){ const search=document.createElement("input"); search.id="catSearch"; search.placeholder="Search or scan…"; search.autocomplete="off"; search.style.cssText="width:100%;margin-bottom:10px"; - search.oninput=()=>{ const q=search.value.trim().toLowerCase(); tiles.forEach(({item,btn})=>{ const hay=(item.name+" "+(item.category||"")+" "+(item.barcode||item.sku||"")).toLowerCase(); btn.style.display=(!q||hay.includes(q))?"":"none"; }); }; + search.oninput=applyFilter; search.onkeydown=e=>{ if(e.key!=="Enter") return; const q=search.value.trim().toLowerCase(); if(!q) return; const exact=tiles.find(({item})=>String(item.barcode||item.sku||"").toLowerCase()===q && !item.__out); const vis=tiles.find(({btn,item})=>btn.style.display!=="none" && !item.__out); const hit=exact||vis; if(hit){ addItem(hit.item); } else { flash("No match for “"+search.value.trim()+"”"); } }; cat.appendChild(search); } + const catsInOrder=[]; FLOW.catalog.forEach(it=>{ const c=it.category||"other"; if(catsInOrder.indexOf(c)<0) catsInOrder.push(c); }); + if(FLOW.catalog.length>8 && catsInOrder.length>1){ + const tabs=document.createElement("div"); tabs.className="opts cattabs"; tabs.style.cssText="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:10px"; + const mk=(key,label)=>{ const t=document.createElement("button"); t.textContent=label; if(activeCat===key) t.className="sel"; + t.onclick=()=>{ activeCat=key; draft.__cat=key; tabs.querySelectorAll("button").forEach(x=>x.classList.remove("sel")); t.classList.add("sel"); applyFilter(); }; + tabs.appendChild(t); }; + mk("__all","All"); catsInOrder.forEach(c=>mk(c,catLabel(c))); + cat.appendChild(tabs); + } const grid=document.createElement("div"); grid.className="grid"; FLOW.catalog.forEach(item=>{ - const b=document.createElement("button"); b.className="tile"; const rem=remainingOf(item); const out=is86(item)||isOutOfStock(item); + const b=document.createElement("button"); b.className="tile"; const rem=remainingOf(item); const offHours=!itemAvailNow(item); const out=is86(item)||isOutOfStock(item)||offHours; item.__out=out; if(out) b.className="tile eightysix"; const via=item.path.length?item.path.join(" → "):"no prep"; - const priceLbl = item.customPrice ? "price at ring" : money(item.price)+(item.byWeight?" / "+(item.unit||"lb"):""); + const happy = item.happyPrice!=null && happyActive(); + const priceLbl = item.customPrice ? "price at ring" + : (happy ? (""+money(item.price)+" "+money(item.happyPrice)+" ⏰ happy hour") + : money(item.price)+(item.byWeight?" / "+(item.unit||"lb"):"")); let sub = station.view.money?priceLbl+" · "+via:via; + if(offHours) sub += " · "+(item.avail&&(item.avail.from||item.avail.to)?((item.avail.from||"")+"–"+(item.avail.to||"")):"off hours")+""; if(item.par!=null) sub += is86(item) ? " · 86" : " · "+rem+" left"; if(tracksStock(item)) sub += isOutOfStock(item) ? " · out of stock" : " · "+stockOnHand(item)+" in stock"+(isLowStock(item)?" low":""); b.innerHTML = item.name + ""+sub+""; @@ -1916,13 +2119,14 @@ grid.appendChild(b); }); cat.appendChild(grid); app.appendChild(cat); + applyFilter(); // honor the active category tab (and any typed search) on first paint if(pending) app.appendChild(configPanel(station)); const c=document.createElement("div"); c.className="card"; if(!draft.lines.length){ c.innerHTML="
Tap items to build the order.
"; } else { draft.lines.forEach((l,idx)=>{ const row=document.createElement("div"); row.className="line"; - row.innerHTML=""+l.qty+"× "+l.name+(l.modLabel?" "+l.modLabel+"":"")+(l.performer?" "+l.performer+"":"")+tagChip(l)+""+((l.path||[]).join(' → ')||'—')+""+(station.view.money?""+money(l.price*l.qty)+"":""); + row.innerHTML=""+l.qty+"× "+l.name+(l.modLabel?" "+l.modLabel+"":"")+seatChip(l)+(l.performer?" "+l.performer+"":"")+tagChip(l)+""+((l.path||[]).join(' → ')||'—')+""+(station.view.money?""+money(l.price*l.qty)+"":""); if(station.view.money){ // quantity steppers + per-line price edit (haggling, custom items, ring 6 in one tap) const dec=document.createElement("button"); dec.textContent="−"; dec.title="one fewer"; dec.style.cssText="padding:2px 9px;margin-left:6px"; dec.onclick=()=>{ if(l.qty>1) l.qty--; else { logActivity("void", "removed "+l.name+" ("+money(l.price)+") from an unsent ticket", null, l.price); draft.lines.splice(idx,1); } render(); }; row.appendChild(dec); const inc=document.createElement("button"); inc.textContent="+"; inc.title="one more"; inc.style.cssText="padding:2px 9px;margin-left:4px"; inc.onclick=()=>{ l.qty++; render(); }; row.appendChild(inc); @@ -1932,6 +2136,17 @@ c.appendChild(row); }); const tot=document.createElement("div"); tot.className="line"; tot.innerHTML="Total"+money(draft.lines.reduce((s,l)=>s+l.price*l.qty,0))+""; c.appendChild(tot); } app.appendChild(c); + // 💡 suggested add-ons (upsell): items referenced by draft items' `suggest`, not already on the ticket — one tap adds + if(draft.lines.length && station.view.money){ + const want={}; draft.lines.forEach(l=>{ const it=FLOW.catalog.find(x=>x.id===l.catId); (it&&it.suggest||[]).forEach(id=>{ want[id]=1; }); }); + const have={}; draft.lines.forEach(l=>{ have[l.catId]=1; }); + const sugg=Object.keys(want).map(id=>FLOW.catalog.find(x=>x.id===id)).filter(it=>it && !have[it.id] && !is86(it) && !isOutOfStock(it) && itemAvailNow(it)); + if(sugg.length){ const sc=document.createElement("div"); sc.className="card"; sc.style.cssText="padding:8px 10px"; + const lbl=document.createElement("div"); lbl.className="lbl"; lbl.textContent="💡 Want to add…"; sc.appendChild(lbl); + const opts=document.createElement("div"); opts.className="opts"; opts.style.cssText="display:flex;flex-wrap:wrap;gap:6px"; + sugg.forEach(it=>{ const b=document.createElement("button"); b.textContent="+ "+it.name+" "+money(itemPriceNow(it)); b.onclick=()=>addItem(it); opts.appendChild(b); }); + sc.appendChild(opts); app.appendChild(sc); } + } // deposit control (deposit-balance payment point) const dep = FLOW.endpoints.payment.deposit; if(dep && station.view.money && draft.lines.length){ @@ -1971,10 +2186,18 @@ } } +// 🔀 conditional (nested) modifiers: a modifier option or an add-on can carry a `reveal` group that only appears +// once it's chosen (e.g. "Add cheese" → "Which cheese?"). Groups expand across the list, so reveals can nest. +function revealedGroups(item){ + const out=[...(item.modifiers||[])]; + (item.addons||[]).forEach(a=>{ if(a.reveal && pending.addons.some(x=>x.name===a.name) && out.indexOf(a.reveal)<0) out.push(a.reveal); }); + for(let i=0;i"; - (item.modifiers||[]).forEach((g,gi)=>{ + revealedGroups(item).forEach((g,gi)=>{ const wrap=document.createElement("div"); wrap.className="mgroup"; wrap.innerHTML="
"+g.group+(g.required?" (required)":"")+"
"; const opts=document.createElement("div"); opts.className="opts"; @@ -2002,9 +2225,11 @@ (FLOW.performers||[]).forEach(name=>{ const b=document.createElement("button"); if(pending.performer===name) b.className="sel"; b.textContent=name; b.onclick=()=>{ pending.performer=name; render(); }; opts.appendChild(b); }); wrap.appendChild(opts); c.appendChild(wrap); } - const reqMet=(item.modifiers||[]).every(g=>!g.required||pending.choices[g.group]) && (!item.performer || pending.performer); + const active=revealedGroups(item); + const reqMet=active.every(g=>!g.required||pending.choices[g.group]) && (!item.performer || pending.performer); const add=document.createElement("button"); add.className="primary"; add.textContent="Add to order"; add.disabled=!reqMet; add.style.marginRight="8px"; - add.onclick=()=>{ addLine(item, pending.choices, pending.addons, pending.flags, pending.performer); pending=null; render(); }; + add.onclick=()=>{ const choices={}; active.forEach(g=>{ if(pending.choices[g.group]) choices[g.group]=pending.choices[g.group]; }); // only choices for groups still shown (a deselected reveal drops its choice) + addLine(item, choices, pending.addons, pending.flags, pending.performer); pending=null; render(); }; const cancel=document.createElement("button"); cancel.textContent="Cancel"; cancel.onclick=()=>{ pending=null; render(); }; c.appendChild(add); c.appendChild(cancel); return c; @@ -2024,6 +2249,7 @@ const modLabel=mods.map(m=>m.name).filter(Boolean).join(", "); const cart = editingId ? ((DB.records||[]).find(r=>r.id===editingId)||draft) : draft; // detailing edits the order; intake edits the draft cart.lines.push({ id:"L"+Math.round(performance.now())+Math.floor(Math.random()*1e4), catId:item.id, name:item.name, modLabel, flags:(flags||[]).slice(), performer:performer||null, price, qty:1, category:item.category, path:item.path.slice(), stage:0, course:item.course||1, + seat: (editingId ? null : (draft && draft.seat) || null), // 👤 dine-in: which seat this line rings to (null = whole table) serialized: !!item.serialized, tag: item.serialized ? genUnitTag() : undefined, tagLabel: item.serialized ? (item.tagLabel||"Tag") : undefined }); if(editingId) saveDB(DB); render(); @@ -2048,11 +2274,15 @@ if(isNaN(n)||n<0){ flash("Enter a price"); return null; } return {...item, price:+n.toFixed(2)}; } + // ⏰ happy-hour: ring the happy price when the window is live (a fixed-price item) + const now=itemPriceNow(item); if(now!==item.price) return {...item, price:now}; return item; } function sendDraft(){ DB=loadDB(); DB.seq++; const rec={ id:"R"+DB.seq, number:DB.seq, lines:draft.lines.map(l=>({...l, stage:0})), tenders:[], createdAt:new Date().toISOString().slice(11,16), ts:Date.now() }; + if(draft.orderType) rec.orderType=draft.orderType; // 🚗 dine-in / to-go / drive-thru + if(draft.channel) rec.channel=draft.channel; // 🌐 in-house vs an online/delivery order handed in by the hub if(storesOn()) rec.storeId = storeForOrder(draft.customer); // order belongs to a store (customer's, else this device's, else plant) if(draft.tableId){ rec.tableId = draft.tableId; // ring the check to its table; nudge an empty table to "occupied" DB.tables=DB.tables||{}; const cur=DB.tables[draft.tableId]; if(!cur || cur.state==="empty" || !cur.state){ DB.tables[draft.tableId]={ state:"ordered", since:Date.now(), seatedTs:(cur&&cur.seatedTs)||Date.now() }; } } @@ -2116,13 +2346,13 @@ const recs=DB.records.filter(r=> r.status==="INPROGRESS" && linesAtStation(r, station.id).length); if(!recs.length){ const e=document.createElement("div"); e.className="empty card"; e.textContent="Nothing waiting."; app.appendChild(e); } recs.forEach(r=>{ const mine=linesAtStation(r, station.id); const c=document.createElement("div"); c.className="card"; - c.innerHTML="
#"+r.number+" "+r.status+""+clockChip(r)+storeChip(r)+tableChip(r)+""+r.createdAt+"
"; + c.innerHTML="
#"+r.number+" "+r.status+""+clockChip(r)+storeChip(r)+tableChip(r)+orderTypeChip(r)+channelChip(r)+""+r.createdAt+"
"; let hasFlags=false, reconOk=true; mine.forEach(l=>{ const row=document.createElement("div"); row.className="line"; const next=l.path[l.stage+1]; const flagHtml=(l.flags||[]).map(f=>{ hasFlags=true; return " ⚠ "+f+""; }).join(""); const perfHtml=l.performer?" "+l.performer+"":""; if(station.reconcile){ const ck=reconChecked(r,station,l); if(!ck) reconOk=false; const cb=document.createElement("button"); cb.textContent=ck?"✓":"○"; cb.title="Count this piece out"; cb.style.cssText="padding:2px 9px;margin-right:4px"; if(ck) cb.className="sel"; cb.onclick=()=>reconToggle(r,station,l); row.appendChild(cb); } - row.insertAdjacentHTML("beforeend",""+l.qty+"× "+l.name+(l.modLabel?" "+l.modLabel+"":"")+perfHtml+flagHtml+tagChip(l)+bagChip(l)+""+(next?"→ "+next:"→ ready")+""); c.appendChild(row); }); + row.insertAdjacentHTML("beforeend",""+l.qty+"× "+l.name+(l.modLabel?" "+l.modLabel+"":"")+seatChip(l)+perfHtml+flagHtml+tagChip(l)+bagChip(l)+""+(next?"→ "+next:"→ ready")+""); c.appendChild(row); }); if(station.bag){ const bagged=mine.some(l=>l.bag); const bw=document.createElement("div"); bw.className="mgroup"; bw.innerHTML="
Assembly bags
"; const bb=document.createElement("button"); bb.textContent=bagged?("🧺 Re-bag ("+((r.bagsAt&&r.bagsAt[station.id])||0)+" bag(s))"):("🧺 Bag these "+pieceCount(mine)+" piece(s)"); bb.onclick=()=>bagOrder(r,station); bw.appendChild(bb); c.appendChild(bw); } if(printOn()){ const pw=document.createElement("div"); pw.className="mgroup"; pw.innerHTML="
Print
"; @@ -2172,7 +2402,7 @@ const c=document.createElement("div"); c.className="card"; if(!rec.lines.length){ c.innerHTML="
No pieces yet — tap items above.
"; } else rec.lines.forEach(l=>{ const row=document.createElement("div"); row.className="line"; - row.insertAdjacentHTML("beforeend",""+l.qty+"× "+l.name+(l.modLabel?" "+l.modLabel+"":"")+tagChip(l)+""+(station.view.money?""+money(l.price*l.qty)+"":"")); + row.insertAdjacentHTML("beforeend",""+l.qty+"× "+l.name+(l.modLabel?" "+l.modLabel+"":"")+seatChip(l)+tagChip(l)+""+(station.view.money?""+money(l.price*l.qty)+"":"")); if(l.serialized){ const tb=document.createElement("button"); tb.textContent="🏷 "+(l.tag||"tag"); tb.title="Scan / set the "+(l.tagLabel||"tag"); tb.style.cssText="padding:2px 8px;margin-left:6px"; tb.onclick=()=>{ const v=prompt("Scan or type the "+(l.tagLabel||"tag")+" for this "+l.name+":", l.tag||""); if(v!=null&&v.trim()){ l.tag=v.trim(); saveDB(DB); render(); } }; row.appendChild(tb); } const rm=document.createElement("button"); rm.textContent="✕"; rm.title="Remove"; rm.style.cssText="padding:2px 8px;margin-left:6px"; rm.onclick=()=>{ rec.lines=rec.lines.filter(x=>x.id!==l.id); saveDB(DB); render(); }; row.appendChild(rm); c.appendChild(row); }); @@ -2203,11 +2433,12 @@ return job; } function dueDateText(r){ const t=FLOW.timer; if(!t||t.mode!=="due"||!r.ts) return ""; return new Date(r.ts + t.promiseSec*1000).toISOString().slice(0,16).replace("T"," "); } -function docLineText(l){ return " "+(l.qty||1)+"x "+l.name+(l.modLabel?" ("+l.modLabel+")":"")+(l.serialized&&l.tag?" ["+(l.tagLabel||"Tag")+" "+l.tag+"]":"")+(l.bag?" bag "+l.bag:""); } +function docLineText(l){ return " "+(l.qty||1)+"x "+l.name+(l.modLabel?" ("+l.modLabel+")":"")+(l.seat?" [Seat "+l.seat+"]":"")+(l.serialized&&l.tag?" ["+(l.tagLabel||"Tag")+" "+l.tag+"]":"")+(l.bag?" bag "+l.bag:""); } function docTicket(r){ const L=[]; L.push(((FLOW.branding&&FLOW.branding.name)||"Order").toUpperCase()); if(storesOn()&&r.storeId){ L.push(storeName(r.storeId)); const aa=assembledAt(r); if(aa) L.push(aa); } L.push("Order #"+r.number+" "+(r.createdAt||"")); if(r.customer) L.push((r.customer.name||"")+" "+(r.customer.phone||"")); + if(r.orderType||r.tableId||(r.channel&&r.channel!=="in-house")) L.push([r.orderType, r.tableId?("Table "+tableLabel(r.tableId)):"", (r.channel&&r.channel!=="in-house")?r.channel:""].filter(Boolean).join(" ")); L.push("------------------------------"); (r.lines||[]).forEach(l=>L.push(docLineText(l))); L.push("------------------------------"); const dd=dueDateText(r); if(dd) L.push("Promised: "+dd); return L.join("\n"); @@ -2226,7 +2457,13 @@ function receiptPanel(r){ const sub=lineSubtotal(r), disc=recordDiscount(r), tax=recordTax(r), tip=r.tip||0, tot=recordTotal(r); const line=(l,v,b)=>"
"+(b?"":"")+l+(b?"":"")+""+(b?"":"")+v+(b?"":"")+"
"; - const rows=r.lines.map(l=>"
"+l.qty+"× "+l.name+(l.modLabel?" ("+l.modLabel+")":"")+(l.serialized&&l.tag?" ["+(l.tagLabel||"Tag")+" "+l.tag+"]":"")+""+money(l.price*l.qty)+"
").join(""); + const lineRow=l=>"
"+l.qty+"× "+l.name+(l.modLabel?" ("+l.modLabel+")":"")+(l.serialized&&l.tag?" ["+(l.tagLabel||"Tag")+" "+l.tag+"]":"")+""+money(l.price*l.qty)+"
"; + // 👤 per-seat receipt: when the order was rung by seat, group the itemized lines under each seat with its subtotal + let rows; + if(orderHasSeats(r)){ + rows=seatBuckets(r).map(bkt=>{ const its=r.lines.filter(l=>((l.seat?("s"+l.seat):"shared"))===bkt.key); + return "
"+bkt.label+""+money(bkt.sub)+"
"+its.map(lineRow).join(""); }).join(""); + } else { rows=r.lines.map(lineRow).join(""); } const hr="
"; const totals=(disc||tax?line("Subtotal",money(sub)):"")+(disc?line("Discount","−"+money(disc)):"")+(tax?line(taxIncluded()?"Tax (incl.)":"Tax",money(tax)):"")+(tip?line("Tip",money(tip)):"")+line("Total",money(tot),true); const tenders=(r.tenders||[]).map(t=>line(t.type==="card"?cardLabel(t):t.type[0].toUpperCase()+t.type.slice(1), money(t.amount))).join(""); @@ -2241,8 +2478,14 @@ const recs=DB.records.filter(r=> r.status==="READY"||r.status==="PAID"); if(!recs.length){ const e=document.createElement("div"); e.className="empty card"; e.textContent="No orders ready to check out."; app.appendChild(e); } recs.forEach(r=>{ const c=document.createElement("div"); c.className="card"; const bal=recordBalance(r); - c.innerHTML="
#"+r.number+" "+r.status+""+clockChip(r)+storeChip(r)+tableChip(r)+""+r.createdAt+"
"; - r.lines.forEach(l=>{ const row=document.createElement("div"); row.className="line"; row.innerHTML=""+l.qty+"× "+l.name+(l.modLabel?" "+l.modLabel+"":"")+""+money(l.price*l.qty)+""; c.appendChild(row); }); + c.innerHTML="
#"+r.number+" "+r.status+""+clockChip(r)+storeChip(r)+tableChip(r)+orderTypeChip(r)+channelChip(r)+""+r.createdAt+"
"; + r.lines.forEach(l=>{ const row=document.createElement("div"); row.className="line"; + const priceHtml=l.comped ? (""+money((l.comped.wasPrice||0)*l.qty)+" "+money(0)) : money(l.price*l.qty); + row.innerHTML=""+l.qty+"× "+l.name+(l.modLabel?" "+l.modLabel+"":"")+seatChip(l)+compChip(l)+""+priceHtml+""; + if(r.status==="READY" && station.view.money && !l.returned){ const cbtn=document.createElement("button"); cbtn.textContent=l.comped?"un-comp":"comp"; cbtn.title=l.comped?"Undo the comp":"Comp this item (on the house)"; cbtn.style.cssText="padding:2px 8px;margin-left:6px"; cbtn.onclick=()=>compLine(r,l); row.appendChild(cbtn); + if(r.tableId || orderHasSeats(r)){ const sbtn=document.createElement("button"); sbtn.textContent="seat"; sbtn.title="Move this item to another seat"; sbtn.style.cssText="padding:2px 8px;margin-left:4px"; + sbtn.onclick=()=>{ const v=prompt("Move "+l.name+" to which seat? (number, or blank for whole table)", l.seat||""); if(v===null) return; const n=parseInt(v,10); l.seat=(v.trim()===""?null:(n>0?n:l.seat)); saveDB(DB); logActivity("access","moved "+l.name+" → "+(l.seat?("seat "+l.seat):"whole table")+" on #"+r.number, r.number); render(); }; row.appendChild(sbtn); } } + c.appendChild(row); }); // tax area (destination sales tax): for delivery, tax at the delivery address's rate, not the shop's. // Shown only when tax zones are configured; "Shop" = the base (origin) rate. if(FLOW.endpoints.tax && Array.isArray(FLOW.endpoints.tax.zones) && FLOW.endpoints.tax.zones.length && r.status==="READY"){ @@ -2319,40 +2562,63 @@ const ageReq=orderAgeReq(r); if(ageReq && !r.ageVerified){ const gate=document.createElement("div"); gate.className="mgroup"; gate.style.cssText="border:1px solid var(--bad);border-radius:8px;padding:10px;margin-top:10px"; - gate.innerHTML="
⚠ Age-restricted order
This order has items that require the customer to be "+ageReq+"+. Check a photo ID before selling.
"; - const ok=document.createElement("button"); ok.className="primary"; ok.style.marginTop="8px"; ok.textContent="✓ ID checked — customer is "+ageReq+"+"; - ok.onclick=()=>{ const st=boundStation(); const at=new Date().toISOString().slice(11,16); - r.ageVerified={ req:ageReq, at, day:todayStr(), station:(st&&st.label)||"" }; // defensible record: what was required, when, at which station - DB.ageLog=DB.ageLog||[]; DB.ageLog.push({ number:r.number, req:ageReq, at, day:todayStr(), station:(st&&st.label)||"" }); - logActivity("idcheck", "ID checked — customer verified "+ageReq+"+", r.number); + gate.innerHTML="
⚠ Age-restricted order
This order has items that require the customer to be "+ageReq+"+. Check a photo ID and type the date of birth from it.
"; + const row=document.createElement("div"); row.className="opts"; row.style.marginTop="8px"; + const dob=document.createElement("input"); dob.id="dobInput"; dob.placeholder="Birthdate MM/DD/YYYY"; dob.autocomplete="off"; dob.style.minWidth="180px"; + const ok=document.createElement("button"); ok.className="primary"; ok.textContent="Verify age"; + // type the birthdate, we compute the age and REFUSE if it's under the requirement — no override tap + const verify=()=>{ const raw=(document.getElementById("dobInput")||{}).value; const age=ageFromDOB(raw); + const st=boundStation(); const at=new Date().toISOString().slice(11,16); + if(age==null){ flash("Type the date of birth as MM/DD/YYYY"); return; } + DB.ageLog=DB.ageLog||[]; + if(age{ if(e.key==="Enter") verify(); }; + row.appendChild(dob); row.appendChild(ok); gate.appendChild(row); + c.appendChild(gate); app.appendChild(c); return; // no tenders until a valid, of-age birthdate is entered } - // split the check evenly N ways (opt-in) — each tap takes one equal share until the balance clears + // split the check — evenly N ways, or BY SEAT (each guest pays their own) when the order was rung by seat if(FLOW.endpoints.payment.split){ - const wrap=document.createElement("div"); wrap.className="mgroup"; wrap.innerHTML="
Split evenly
"; + const wrap=document.createElement("div"); wrap.className="mgroup"; wrap.innerHTML="
Split the check
"; const opts=document.createElement("div"); opts.className="opts"; - [2,3,4].forEach(n=>{ const bb=document.createElement("button"); if(r.splitWays===n) bb.className="sel"; bb.textContent=n+" ways"; bb.onclick=()=>{ r.splitWays=n; r.splitShare=+(recordTotal(r)/n).toFixed(2); saveDB(DB); render(); }; opts.appendChild(bb); }); - const no=document.createElement("button"); if(!r.splitWays) no.className="sel"; no.textContent="No split"; no.onclick=()=>{ delete r.splitWays; delete r.splitShare; saveDB(DB); render(); }; opts.appendChild(no); + if(orderHasSeats(r)){ const sb=document.createElement("button"); if(r.splitMode==="seat") sb.className="sel"; sb.textContent="👤 By seat"; + sb.onclick=()=>{ r.splitMode="seat"; delete r.splitWays; delete r.splitShare; const first=seatBuckets(r).find(x=>seatDue(r,x)>0.0001); r.paySeat=first?first.key:null; saveDB(DB); render(); }; opts.appendChild(sb); } + [2,3,4].forEach(n=>{ const bb=document.createElement("button"); if(r.splitMode!=="seat"&&r.splitWays===n) bb.className="sel"; bb.textContent=n+" ways"; bb.onclick=()=>{ r.splitMode="even"; r.splitWays=n; r.splitShare=+(recordTotal(r)/n).toFixed(2); delete r.paySeat; saveDB(DB); render(); }; opts.appendChild(bb); }); + const no=document.createElement("button"); if(!r.splitWays&&r.splitMode!=="seat") no.className="sel"; no.textContent="No split"; no.onclick=()=>{ delete r.splitWays; delete r.splitShare; delete r.splitMode; delete r.paySeat; saveDB(DB); render(); }; opts.appendChild(no); wrap.appendChild(opts); c.appendChild(wrap); - if(r.splitWays){ const paidShares=Math.round((recordTotal(r)-bal)/r.splitShare); const info=document.createElement("div"); info.className="muted"; info.style.margin="4px 0"; info.textContent=r.splitWays+" shares · "+money(r.splitShare)+" each · "+paidShares+" of "+r.splitWays+" paid"; c.appendChild(info); } + if(r.splitMode==="seat"){ // one row per seat: its amount, paid ✓ or a "pay this seat" selector + seatBuckets(r).forEach(bkt=>{ const due=seatDue(r,bkt), amt=seatAmount(r,bkt), done=due<=0.0001; + const row=document.createElement("div"); row.className="line"; + row.innerHTML=""+(done?"✓ ":"")+bkt.label+" — "+money(amt)+(done?"":" · "+money(due)+" due")+""; + if(!done){ const pick=document.createElement("button"); if(r.paySeat===bkt.key) pick.className="sel"; pick.textContent=r.paySeat===bkt.key?"paying":"pay this"; pick.onclick=()=>{ r.paySeat=bkt.key; saveDB(DB); render(); }; row.appendChild(pick); } + else { const t=document.createElement("span"); t.className="pill clock good"; t.textContent="paid"; row.appendChild(t); } + c.appendChild(row); }); + } else if(r.splitWays){ const paidShares=Math.round((recordTotal(r)-bal)/r.splitShare); const info=document.createElement("div"); info.className="muted"; info.style.margin="4px 0"; info.textContent=r.splitWays+" shares · "+money(r.splitShare)+" each · "+paidShares+" of "+r.splitWays+" paid"; c.appendChild(info); } } - const payAmt = r.splitWays ? Math.min(r.splitShare, bal) : bal; - const label = r.splitWays ? "Take share " : (paid>0?"Take balance ":"Take payment "); - const b=document.createElement("button"); b.className="primary"; b.textContent=label+money(payAmt)+" cash"; b.style.marginTop="10px"; b.onclick=()=>{ r.tenders.push({type:"cash",amount:payAmt}); logActivity("money", "cash "+money(payAmt), r.number); if(recordBalance(r)<=0.0001){ r.status="PAID"; onPaid(r); } cashPad=null; saveDB(DB); render(); }; c.appendChild(b); + const paySeatBkt = (r.splitMode==="seat"&&r.paySeat) ? seatBuckets(r).find(x=>x.key===r.paySeat) : null; + const payAmt = paySeatBkt ? Math.min(seatDue(r,paySeatBkt), bal) : (r.splitWays ? Math.min(r.splitShare, bal) : bal); + const label = paySeatBkt ? (paySeatBkt.label+" ") : (r.splitWays ? "Take share " : (paid>0?"Take balance ":"Take payment ")); + const seatTag = () => (r.splitMode==="seat" && r.paySeat) ? r.paySeat : undefined; // tag a tender to its seat + const advanceSeat = () => { if(r.splitMode==="seat"){ const nx=seatBuckets(r).find(x=>seatDue(r,x)>0.0001); r.paySeat=nx?nx.key:null; } }; + const b=document.createElement("button"); b.className="primary"; b.textContent=label+money(payAmt)+" cash"; b.style.marginTop="10px"; b.onclick=()=>{ r.tenders.push({type:"cash",amount:payAmt,seatKey:seatTag()}); logActivity("money", "cash "+money(payAmt), r.number); advanceSeat(); if(recordBalance(r)<=0.0001){ r.status="PAID"; onPaid(r); } cashPad=null; saveDB(DB); render(); }; c.appendChild(b); // make change: enter what the customer handed over and see the change due (the cash-drawer moment) const mc=document.createElement("button"); mc.textContent="$ Make change"; mc.style.margin="10px 0 0 8px"; mc.onclick=()=>{ cashPad={ recId:r.id, received:payAmt }; render(); }; c.appendChild(mc); if((FLOW.endpoints.payment.tenders||[]).includes("card")){ const cb=document.createElement("button"); cb.textContent=(r.splitWays?"Share by card ":"Pay by card ")+money(payAmt); cb.style.margin="10px 0 0 8px"; const idemKey=r.id+":card:"+Math.round(payAmt*100)+":"+cardTenders(r).length; // stable across a double-tap of THIS button - cb.onclick=()=>{ const res=chargeCardOnce(r, payAmt, idemKey); if(res.status==="approved"){ if(!res.dedup){ const t=res.tender; logActivity("money", "card "+money(payAmt)+(t&&t.brand?" ("+t.brand+" ••"+t.last4+")":""), r.number); } if(recordBalance(r)<=0.0001){ r.status="PAID"; onPaid(r); } saveDB(DB); render(); } else { logActivity("money", "card declined "+money(payAmt), r.number); flash("Card "+(res.message||"declined")); } }; + cb.onclick=()=>{ const res=chargeCardOnce(r, payAmt, idemKey); if(res.status==="approved"){ if(!res.dedup){ const t=res.tender; if(t) t.seatKey=seatTag(); logActivity("money", "card "+money(payAmt)+(t&&t.brand?" ("+t.brand+" ••"+t.last4+")":""), r.number); advanceSeat(); } if(recordBalance(r)<=0.0001){ r.status="PAID"; onPaid(r); } saveDB(DB); render(); } else { logActivity("money", "card declined "+money(payAmt), r.number); flash("Card "+(res.message||"declined")); } }; c.appendChild(cb); } // house account: put it on the customer's tab (A/R) — settles the order now, they pay it later if((FLOW.endpoints.payment.tenders||[]).includes("account") && r.customer && r.customer.phone){ const ab=document.createElement("button"); ab.textContent="Charge to account "+money(payAmt); ab.style.margin="10px 0 0 8px"; - ab.onclick=()=>{ r.tenders.push({type:"account",amount:payAmt}); const cust=DB.customers.find(x=>x.phone===r.customer.phone); if(cust){ cust.balance=+((cust.balance||0)+payAmt).toFixed(2); } logActivity("money", "charged to account "+money(payAmt)+(r.customer?" · "+r.customer.name:""), r.number); if(recordBalance(r)<=0.0001){ r.status="PAID"; onPaid(r); } saveDB(DB); render(); }; c.appendChild(ab); + ab.onclick=()=>{ r.tenders.push({type:"account",amount:payAmt,seatKey:seatTag()}); const cust=DB.customers.find(x=>x.phone===r.customer.phone); if(cust){ cust.balance=+((cust.balance||0)+payAmt).toFixed(2); } logActivity("money", "charged to account "+money(payAmt)+(r.customer?" · "+r.customer.name:""), r.number); advanceSeat(); if(recordBalance(r)<=0.0001){ r.status="PAID"; onPaid(r); } saveDB(DB); render(); }; c.appendChild(ab); } // cash change-making: enter amount tendered, see change due, then take the cash if(cashPad && cashPad.recId===r.id){ @@ -2365,7 +2631,7 @@ pad.appendChild(row); const cd=document.createElement("div"); cd.id="changeDue"; cd.style.cssText="font-size:18px;font-weight:700;margin:8px 0"; cd.textContent="Change due "+money(Math.max(0,(cashPad.received||0)-payAmt)); pad.appendChild(cd); const conf=document.createElement("button"); conf.id="cashConfirm"; conf.className="primary"; conf.textContent="✓ Take "+money(payAmt); conf.disabled=(cashPad.received||0){ const recv=+(cashPad.received||payAmt).toFixed(2); const chg=+(recv-payAmt).toFixed(2); r.tenders.push({ type:"cash", amount:payAmt, tendered:recv, change:chg }); logActivity("money", "cash "+money(payAmt)+" (tendered "+money(recv)+", change "+money(chg)+")", r.number); if(recordBalance(r)<=0.0001){ r.status="PAID"; onPaid(r); } cashPad=null; saveDB(DB); render(); }; pad.appendChild(conf); + conf.onclick=()=>{ const recv=+(cashPad.received||payAmt).toFixed(2); const chg=+(recv-payAmt).toFixed(2); r.tenders.push({ type:"cash", amount:payAmt, tendered:recv, change:chg, seatKey:seatTag() }); logActivity("money", "cash "+money(payAmt)+" (tendered "+money(recv)+", change "+money(chg)+")", r.number); advanceSeat(); if(recordBalance(r)<=0.0001){ r.status="PAID"; onPaid(r); } cashPad=null; saveDB(DB); render(); }; pad.appendChild(conf); const cx=document.createElement("button"); cx.textContent="Cancel"; cx.style.marginLeft="8px"; cx.onclick=()=>{ cashPad=null; render(); }; pad.appendChild(cx); c.appendChild(pad); } diff --git a/tests/age-check.js b/tests/age-check.js index 8872cfd..5a18a3a 100644 --- a/tests/age-check.js +++ b/tests/age-check.js @@ -31,8 +31,9 @@ const FLOW = { const gated = await T(); const blockedOk = /Age-restricted order/.test(gated) && /21\+/.test(gated) && !/Take payment/.test(gated); - // check the ID -> payment unlocks - await p.getByRole('button',{name:/ID checked/}).click(); + // type an of-age birthdate -> payment unlocks + await p.locator('#dobInput').fill('06/20/1990'); + await p.getByRole('button',{name:/Verify age/}).click(); const unlocked = await T(); const unlockedOk = /Take payment/.test(unlocked) && !/Age-restricted order/.test(unlocked); await p.getByRole('button',{name:/Take payment/}).first().click(); diff --git a/tests/age-gate.js b/tests/age-gate.js new file mode 100644 index 0000000..8bc587b --- /dev/null +++ b/tests/age-gate.js @@ -0,0 +1,81 @@ +const { chromium } = require('playwright-core'); +const http = require('http'), fs = require('fs'), path = require('path'); +const EXE = process.env.CHROMIUM_EXE || '/opt/pw-browsers/chromium-1194/chrome-linux/chrome'; +const ROOT = path.resolve(__dirname, '..'); +const TYPES = { '.html':'text/html', '.js':'text/javascript' }; +function serve(){ return new Promise(r=>{ const s=http.createServer((rq,rs)=>{ if(rq.url==='/favicon.ico'){rs.statusCode=204;return rs.end();} const f=path.join(ROOT,rq.url.split('?')[0]); fs.readFile(f,(e,b)=>{ if(e){rs.statusCode=404;return rs.end('nf');} rs.setHeader('Content-Type',TYPES[path.extname(f)]||'text/plain'); rs.end(b); }); }); s.listen(0,'127.0.0.1',()=>r(s)); }); } + +// 🔞 A server selling alcohol must card by TYPED BIRTHDATE — the POS computes the age and refuses an under-age +// sale, no override tap. This drives the real full-service flow: ring a beer, fire it, then at payment the age +// gate demands a birthdate; an under-21 DOB is refused and logged, an of-age DOB clears the gate and opens tenders. +(async () => { + const errors = []; + const server = await serve(); const port = server.address().port; + const b = await chromium.launch({ executablePath: EXE, args:['--no-sandbox'] }); + const ctx = await b.newContext(); const p = await ctx.newPage(); + p.on('console', m => { if (m.type()==='error') errors.push(m.text()); }); + p.on('pageerror', e => errors.push('pageerror: '+e.message)); + await p.goto(`http://127.0.0.1:${port}/builder.html`); + + // build the full-service (server) template — it has a bar with age-restricted drinks + await p.locator('button.trade').filter({ hasText: 'Full-service' }).click(); + await p.getByRole('button',{name:/Build it for me/}).click(); + await p.waitForFunction(() => window.__build && window.__build.html); + + // unit-check the age math + the restricted flag, straight out of the generated engine (same-origin iframe) + const unit = await p.evaluate(async () => { + const w = document.querySelector('#preview').contentWindow; + for (let i=0;i<60 && !(w.ageFromDOB && w.CUSTOMPOS_FLOW);i++) await new Promise(r=>setTimeout(r,100)); + const f = w.ageFromDOB, beer = w.CUSTOMPOS_FLOW.catalog.find(x=>x.name==='Draft Beer'); + return { minorUnder21: f('01/15/2010') < 21, adultOver21: f('06/20/1990') >= 21, + garbageNull: f('hello')===null, badDateNull: f('02/30/2000')===null, futureNull: f('01/01/2050')===null, + isoWorks: typeof f('1995-03-02')==='number', beerRestricted: beer && beer.ageRestricted===21 }; + }); + + // drive the full server flow to a READY alcohol order, then exercise the gate + const gate = await p.evaluate(async () => { + const doc=()=>document.querySelector('#preview').contentDocument; + const w=document.querySelector('#preview').contentWindow; + const wait=ms=>new Promise(r=>setTimeout(r,ms)); + const clickBtn=re=>{const el=[...doc().querySelectorAll('button')].find(x=>re.test(x.textContent.trim()));if(el){el.click();return true;}return false;}; + const clickLink=re=>{const el=[...doc().querySelectorAll('a')].find(x=>re.test(x.textContent.trim()));if(el){el.click();return true;}return false;}; + clickBtn(/^Got it/); clickBtn(/^Server Station/); await wait(150); + const beer=[...doc().querySelectorAll('.tile')].find(t=>/Draft Beer/.test(t.textContent)); if(beer)beer.click(); await wait(100); + clickBtn(/Send order/); await wait(150); + clickLink(/change station/); await wait(120); clickBtn(/^Bar/); await wait(150); + clickBtn(/Mark done here/); await wait(200); + clickLink(/change station/); await wait(120); clickBtn(/^Server Station/); await wait(200); + const readyStatus=(w.loadDB().records||[]).map(r=>r.status); + const openIt=[...doc().querySelectorAll('button, .card, .line, a')].find(x=>/Draft Beer/.test(x.textContent||'')); if(openIt)openIt.click(); await wait(200); + const gateShown=!!doc().querySelector('#dobInput'); + // under-age → refused, still gated, logged + doc().querySelector('#dobInput').value='01/15/2010'; + (function(){const v=[...doc().querySelectorAll('button')].find(x=>/Verify age/.test(x.textContent));if(v)v.click();})(); await wait(200); + const recA=(w.loadDB().records||[])[0]; + const minorBlocked = !recA.ageVerified && !!doc().querySelector('#dobInput'); + const refusedLogged = (w.loadDB().ageLog||[]).some(a=>a.result==='refused' && a.age<21); + // of-age → verified, gate clears, tenders open + doc().querySelector('#dobInput').value='06/20/1990'; + (function(){const v=[...doc().querySelectorAll('button')].find(x=>/Verify age/.test(x.textContent));if(v)v.click();})(); await wait(200); + const recB=(w.loadDB().records||[])[0]; + const adultVerified = !!(recB.ageVerified && recB.ageVerified.age>=21 && recB.ageVerified.method==='dob'); + const gateGone = !doc().querySelector('#dobInput'); + const tenderShown = [...doc().querySelectorAll('button')].some(x=>/Cash|Card|Pay|Charge|Tender/.test(x.textContent)); + const noRawDob = !('dob' in recB.ageVerified) && !('birthdate' in recB.ageVerified); // we store the computed age, never the raw DOB + return { readyStatus, gateShown, minorBlocked, refusedLogged, adultVerified, gateGone, tenderShown, noRawDob }; + }); + + await b.close(); server.close(); + console.log('\n=== RESULTS ==='); + console.log('age math (minor<21, adult>=21, iso ok, garbage/badDate/future null):', unit.minorUnder21 && unit.adultOver21 && unit.isoWorks && unit.garbageNull && unit.badDateNull && unit.futureNull); + console.log('bar drink is age-restricted (21):', unit.beerRestricted); + console.log('order fires to READY and the age gate demands a birthdate:', JSON.stringify(gate.readyStatus)==='["READY"]' && gate.gateShown); + console.log('under-age birthdate is REFUSED, still gated, and logged:', gate.minorBlocked && gate.refusedLogged); + console.log('of-age birthdate verifies, clears the gate, opens tenders:', gate.adultVerified && gate.gateGone && gate.tenderShown); + console.log('the raw DOB is NOT stored (only the computed age):', gate.noRawDob); + console.log('console errors:', errors.length?errors:'NONE'); + const ok = unit.minorUnder21 && unit.adultOver21 && unit.isoWorks && unit.garbageNull && unit.badDateNull && unit.futureNull && unit.beerRestricted + && JSON.stringify(gate.readyStatus)==='["READY"]' && gate.gateShown && gate.minorBlocked && gate.refusedLogged + && gate.adultVerified && gate.gateGone && gate.tenderShown && gate.noRawDob && !errors.length; + process.exit(ok?0:1); +})().catch(e => { console.error('FATAL', e); process.exit(2); }); diff --git a/tests/burgerbarn.js b/tests/burgerbarn.js index 768fcea..4cd50ed 100644 --- a/tests/burgerbarn.js +++ b/tests/burgerbarn.js @@ -14,20 +14,28 @@ function serve(){ return new Promise(r=>{ const s=http.createServer((rq,rs)=>{ i p.on('pageerror', e => errors.push('pageerror: '+e.message)); await p.goto(`http://127.0.0.1:${port}/builder.html`); - // build the real Hamburger Barn template - await p.locator('button.trade').filter({ hasText: 'burger-joint' }).click(); + // build the real Hamburger Barn template (its trade button is the only one labeled "Hamburger Barn") + await p.locator('button.trade').filter({ hasText: 'Hamburger Barn' }).click(); await p.getByRole('button',{name:/Build it for me/}).click(); await p.waitForFunction(() => window.__build && window.__build.html); const cfg = await p.evaluate(() => { const f = window.__build.flow, byName = n => f.catalog.find(i=>i.name===n); + const cats = {}; f.catalog.forEach(i=>{ cats[i.category]=1; }); + const combo = (byName('Bubba Burger').addons||[]).some(a=>/combo/i.test(a.name)); return { name: f.branding.name, bubbaToGrill: (byName('Bubba Burger').path||[]).join()==='grill', onionToFry: (byName('Onion Rings').path||[]).join()==='fry', shakeToShakes: (byName('Milkshake').path||[]).join()==='shakes', + saladToPrep: (byName('House Salad').path||[]).join()==='prep', + chimiToFry: (byName('Chicken Chimichanga').path||[]).join()==='fry', + fajitaToGrill: (byName('Flaming Fajitas').path||[]).join()==='grill', specialPar: byName('Blue Plate Special').par===10, hasTip: !!f.endpoints.payment.tip, + hasCombo: combo, + catCount: Object.keys(cats).length, + hasCatLabels: !!(f.catLabels && f.catLabels.burger), itemCount: f.catalog.length }; }); @@ -35,6 +43,9 @@ function serve(){ return new Promise(r=>{ const s=http.createServer((rq,rs)=>{ i // drive the ACTUAL generated POS in the live preview: one order fans out to grill + fry const fr = p.frameLocator('#preview'); await fr.getByText('Front Counter', { exact:false }).first().click(); + // 🍔 Toast-style category tabs render for a big menu (wait for the ordering screen to paint before counting) + await fr.locator('.cattabs button').first().waitFor(); + const tabCount = await fr.locator('.cattabs button').count(); await fr.getByText('Bubba Burger', { exact:false }).first().click(); await fr.getByRole('button',{ name:/^Single/ }).first().click(); // required Patty choice await fr.getByRole('button',{ name:/Add to order/ }).click(); @@ -54,12 +65,16 @@ function serve(){ return new Promise(r=>{ const s=http.createServer((rq,rs)=>{ i console.log('grill:', grill.replace(/\n+/g,' | ')); console.log('fry:', fry.replace(/\n+/g,' | ')); console.log('\n=== RESULTS ==='); - console.log('real menu built ('+cfg.itemCount+' items), Hamburger Barn:', cfg.name==='Hamburger Barn' && cfg.itemCount>=18); - console.log('items routed right (burger→grill, rings→fry, shake→shakes):', cfg.bubbaToGrill && cfg.onionToFry && cfg.shakeToShakes); + console.log('full menu built ('+cfg.itemCount+' items across '+cfg.catCount+' categories), Hamburger Barn:', cfg.name==='Hamburger Barn' && cfg.itemCount>=40 && cfg.catCount>=10); + console.log('items routed right (burger→grill, rings→fry, shake→shakes, salad→prep, chimi→fry, fajita→grill):', cfg.bubbaToGrill && cfg.onionToFry && cfg.shakeToShakes && cfg.saladToPrep && cfg.chimiToFry && cfg.fajitaToGrill); + console.log('Toast-style: "make it a combo" upsell + category labels:', cfg.hasCombo && cfg.hasCatLabels); console.log('daily special has an 86 count; tips on:', cfg.specialPar && cfg.hasTip); + console.log('Toast-style category tabs render on the ordering screen ('+tabCount+' tabs):', tabCount>=5); console.log('live POS fan-out: Grill sees the burger only:', grillOk); console.log('live POS fan-out: Fry sees the onion rings only:', fryOk); console.log('console errors:', errors.length?errors:'NONE'); - const ok = cfg.name==='Hamburger Barn' && cfg.bubbaToGrill && cfg.onionToFry && cfg.shakeToShakes && cfg.specialPar && cfg.hasTip && grillOk && fryOk && !errors.length; + const ok = cfg.name==='Hamburger Barn' && cfg.itemCount>=40 && cfg.catCount>=10 && + cfg.bubbaToGrill && cfg.onionToFry && cfg.shakeToShakes && cfg.saladToPrep && cfg.chimiToFry && cfg.fajitaToGrill && + cfg.hasCombo && cfg.hasCatLabels && cfg.specialPar && cfg.hasTip && tabCount>=5 && grillOk && fryOk && !errors.length; process.exit(ok?0:1); })().catch(e => { console.error('FATAL', e); process.exit(2); }); diff --git a/tests/comp.js b/tests/comp.js new file mode 100644 index 0000000..cc2dbac --- /dev/null +++ b/tests/comp.js @@ -0,0 +1,49 @@ +const { chromium } = require('playwright-core'); +const http = require('http'), fs = require('fs'), path = require('path'); +const EXE = process.env.CHROMIUM_EXE || '/opt/pw-browsers/chromium-1194/chrome-linux/chrome'; +const ROOT = path.resolve(__dirname, '..'); +const TYPES = { '.html':'text/html', '.js':'text/javascript' }; +function serve(){ return new Promise(r=>{ const s=http.createServer((rq,rs)=>{ if(rq.url==='/favicon.ico'){rs.statusCode=204;return rs.end();} const f=path.join(ROOT,rq.url.split('?')[0]); fs.readFile(f,(e,b)=>{ if(e){rs.statusCode=404;return rs.end('nf');} rs.setHeader('Content-Type',TYPES[path.extname(f)]||'text/plain'); rs.end(b); }); }); s.listen(0,'127.0.0.1',()=>r(s)); }); } + +// 🎁 COMP a line — take an item to $0 with a reason on the record (distinct from a %-off discount). Reversible. +// Manager approval is opt-in per instance (FLOW.endpoints.approvals.comp); here we pin the core comp/un-comp +// behavior + that it lands on the activity log as its own 'comp' action. +(async () => { + const errors = []; + const server = await serve(); const port = server.address().port; + const b = await chromium.launch({ executablePath: EXE, args:['--no-sandbox'] }); + const ctx = await b.newContext(); const p = await ctx.newPage(); + p.on('console', m => { if (m.type()==='error') errors.push(m.text()); }); + p.on('pageerror', e => errors.push('pageerror: '+e.message)); + await p.goto(`http://127.0.0.1:${port}/builder.html`); + + await p.locator('button.trade').filter({ hasText: 'Full-service' }).click(); + await p.getByRole('button',{name:/Build it for me/}).click(); + await p.waitForFunction(() => window.__build && window.__build.html); + + const out = await p.evaluate(() => { + const w=document.querySelector('#preview').contentWindow; + w.prompt=(m)=>/PIN/i.test(m)?'2222':'Kitchen error'; // reason, then a manager PIN (bistro gates comps on approval) + const r={ id:'RC', number:77, status:'READY', tenders:[], lines:[ {id:'x',name:'Ribeye',price:34,qty:1,seat:1}, {id:'y',name:'Wine',price:11,qty:1,seat:2} ] }; + w.DB=w.loadDB(); w.DB.records=[r]; w.DB.activity=w.DB.activity||[]; w.saveDB(w.DB); + const before=w.recordTotal(r); + w.compLine(r, r.lines[0]); + const l=r.lines[0]; + const compedOk = l.price===0 && !!l.comped && l.comped.reason==='Kitchen error' && l.comped.wasPrice===34 && w.recordTotal(r)===11; + const chipOk = /comp/.test(w.compChip(l)); + const logged = (w.loadDB().activity||[]).some(a=>a.type==='comp' && /Ribeye/.test(a.detail||'')); + w.compLine(r, r.lines[0]); // un-comp + const restoredOk = r.lines[0].price===34 && !r.lines[0].comped && w.recordTotal(r)===45; + return { before, compedOk, chipOk, logged, restoredOk }; + }); + + await b.close(); server.close(); + console.log('\n=== RESULTS ==='); + console.log('comping a line takes it to $0 with a reason; order total drops (45→11):', out.before===45 && out.compedOk); + console.log('the comped line shows a comp badge:', out.chipOk); + console.log('the comp is logged as its own audit action:', out.logged); + console.log('un-comp restores the price and total (→45):', out.restoredOk); + console.log('console errors:', errors.length?errors:'NONE'); + const ok = out.before===45 && out.compedOk && out.chipOk && out.logged && out.restoredOk && !errors.length; + process.exit(ok?0:1); +})().catch(e => { console.error('FATAL', e); process.exit(2); }); diff --git a/tests/dayparts.js b/tests/dayparts.js new file mode 100644 index 0000000..985fa23 --- /dev/null +++ b/tests/dayparts.js @@ -0,0 +1,47 @@ +const { chromium } = require('playwright-core'); +const http = require('http'), fs = require('fs'), path = require('path'); +const EXE = process.env.CHROMIUM_EXE || '/opt/pw-browsers/chromium-1194/chrome-linux/chrome'; +const ROOT = path.resolve(__dirname, '..'); +const TYPES = { '.html':'text/html', '.js':'text/javascript' }; +function serve(){ return new Promise(r=>{ const s=http.createServer((rq,rs)=>{ if(rq.url==='/favicon.ico'){rs.statusCode=204;return rs.end();} const f=path.join(ROOT,rq.url.split('?')[0]); fs.readFile(f,(e,b)=>{ if(e){rs.statusCode=404;return rs.end('nf');} rs.setHeader('Content-Type',TYPES[path.extname(f)]||'text/plain'); rs.end(b); }); }); s.listen(0,'127.0.0.1',()=>r(s)); }); } + +// ⏰ DAY-PART MENUS + HAPPY-HOUR PRICING — items can carry an availability window (breakfast till 11) and a +// happy-hour price that applies inside a shop window. Time is injected so the test is deterministic. +(async () => { + const errors = []; + const server = await serve(); const port = server.address().port; + const b = await chromium.launch({ executablePath: EXE, args:['--no-sandbox'] }); + const ctx = await b.newContext(); const p = await ctx.newPage(); + p.on('console', m => { if (m.type()==='error') errors.push(m.text()); }); + p.on('pageerror', e => errors.push('pageerror: '+e.message)); + await p.goto(`http://127.0.0.1:${port}/builder.html`); + await p.locator('button.trade').filter({ hasText: 'Full-service' }).click(); + await p.getByRole('button',{name:/Build it for me/}).click(); + await p.waitForFunction(() => window.__build && window.__build.html); + + const out = await p.evaluate(() => { + const w=document.querySelector('#preview').contentWindow; + const wine=w.CUSTOMPOS_FLOW.catalog.find(x=>x.name==='House Wine'); + const at4=new Date(2026,0,1,16,0), at12=new Date(2026,0,1,12,0), at10=new Date(2026,0,1,10,0); + const brunch={from:"09:00",to:"11:00"}; + return { + wineHasHappy: wine && wine.happyPrice===6, + happyOn4: w.happyActive(at4), happyOff12: w.happyActive(at12), + wine4: w.itemPriceNow(wine,at4), wine12: w.itemPriceNow(wine,at12), + brunchOpen: w.itemAvailNow({avail:brunch}, at10), brunchClosed: w.itemAvailNow({avail:brunch}, at4), + allDay: w.itemAvailNow({}, at4), + overnight: w.withinWindow({from:"22:00",to:"02:00"}, new Date(2026,0,1,1,0)) + }; + }); + + await b.close(); server.close(); + console.log('\n=== RESULTS ==='); + console.log('bistro bar drink carries a happy-hour price:', out.wineHasHappy); + console.log('happy hour is on at 4pm, off at noon:', out.happyOn4 && !out.happyOff12); + console.log('wine rings $6 in happy hour, $11 otherwise:', out.wine4===6 && out.wine12===11); + console.log('day-part item available in-window (10am), hidden out (4pm):', out.brunchOpen && !out.brunchClosed); + console.log('an item with no window is always available; overnight window wraps midnight:', out.allDay && out.overnight); + console.log('console errors:', errors.length?errors:'NONE'); + const ok = out.wineHasHappy && out.happyOn4 && !out.happyOff12 && out.wine4===6 && out.wine12===11 && out.brunchOpen && !out.brunchClosed && out.allDay && out.overnight && !errors.length; + process.exit(ok?0:1); +})().catch(e => { console.error('FATAL', e); process.exit(2); }); diff --git a/tests/nested-modifiers.js b/tests/nested-modifiers.js new file mode 100644 index 0000000..937b5af --- /dev/null +++ b/tests/nested-modifiers.js @@ -0,0 +1,49 @@ +const { chromium } = require('playwright-core'); +const http = require('http'), fs = require('fs'), path = require('path'); +const EXE = process.env.CHROMIUM_EXE || '/opt/pw-browsers/chromium-1194/chrome-linux/chrome'; +const ROOT = path.resolve(__dirname, '..'); +const TYPES = { '.html':'text/html', '.js':'text/javascript' }; +function serve(){ return new Promise(r=>{ const s=http.createServer((rq,rs)=>{ if(rq.url==='/favicon.ico'){rs.statusCode=204;return rs.end();} const f=path.join(ROOT,rq.url.split('?')[0]); fs.readFile(f,(e,b)=>{ if(e){rs.statusCode=404;return rs.end('nf');} rs.setHeader('Content-Type',TYPES[path.extname(f)]||'text/plain'); rs.end(b); }); }); s.listen(0,'127.0.0.1',()=>r(s)); }); } + +// 🔀 CONDITIONAL / NESTED MODIFIERS: a modifier option or add-on can `reveal` a follow-up group that only shows +// once it's chosen (Bubba "Extra cheese" → required "Which cheese"), gates Add until answered, and rides onto the line. +(async () => { + const errors = []; + const server = await serve(); const port = server.address().port; + const b = await chromium.launch({ executablePath: EXE, args:['--no-sandbox'] }); + const ctx = await b.newContext(); const p = await ctx.newPage(); + p.on('console', m => { if (m.type()==='error') errors.push(m.text()); }); + p.on('pageerror', e => errors.push('pageerror: '+e.message)); + await p.goto(`http://127.0.0.1:${port}/builder.html`); + await p.locator('button.trade').filter({ hasText: 'Hamburger Barn' }).click(); + await p.getByRole('button',{name:/Build it for me/}).click(); + await p.waitForFunction(() => window.__build && window.__build.html); + + const out = await p.evaluate(async () => { + const doc=()=>document.querySelector('#preview').contentDocument; + const wait=ms=>new Promise(r=>setTimeout(r,ms)); + const cB=re=>{const b=[...doc().querySelectorAll('button')].find(x=>re.test(x.textContent.trim()));if(b){b.click();return true;}return false;}; + cB(/^Got it/); cB(/^Front Counter/); await wait(180); + const bubba=[...doc().querySelectorAll('.tile')].find(x=>/Bubba Burger/.test(x.textContent)); if(bubba)bubba.click(); await wait(120); + const hiddenBefore=!/Which cheese/.test(doc().body.innerText); + cB(/^Single/); cB(/Extra cheese/); await wait(120); + const shownAfter=/Which cheese/.test(doc().body.innerText); + const addBtn=[...doc().querySelectorAll('button')].find(x=>/Add to order/.test(x.textContent)); + const gatedUntilAnswered=!!(addBtn && addBtn.disabled); + cB(/^Swiss/); await wait(100); + const addBtn2=[...doc().querySelectorAll('button')].find(x=>/Add to order/.test(x.textContent)); + const enabledAfter=!!(addBtn2 && !addBtn2.disabled); + cB(/Add to order/); await wait(100); + const line=[...doc().querySelectorAll('.line')].map(l=>l.textContent).find(x=>/Bubba/.test(x))||""; + return { hiddenBefore, shownAfter, gatedUntilAnswered, enabledAfter, lineHasCheese:/Swiss/.test(line) }; + }); + + await b.close(); server.close(); + console.log('\n=== RESULTS ==='); + console.log('the revealed group is hidden until its trigger is chosen:', out.hiddenBefore && out.shownAfter); + console.log('a required reveal gates "Add" until answered:', out.gatedUntilAnswered && out.enabledAfter); + console.log('the nested choice rides onto the line:', out.lineHasCheese); + console.log('console errors:', errors.length?errors:'NONE'); + const ok = out.hiddenBefore && out.shownAfter && out.gatedUntilAnswered && out.enabledAfter && out.lineHasCheese && !errors.length; + process.exit(ok?0:1); +})().catch(e => { console.error('FATAL', e); process.exit(2); }); diff --git a/tests/order-types.js b/tests/order-types.js new file mode 100644 index 0000000..9e44750 --- /dev/null +++ b/tests/order-types.js @@ -0,0 +1,45 @@ +const { chromium } = require('playwright-core'); +const http = require('http'), fs = require('fs'), path = require('path'); +const EXE = process.env.CHROMIUM_EXE || '/opt/pw-browsers/chromium-1194/chrome-linux/chrome'; +const ROOT = path.resolve(__dirname, '..'); +const TYPES = { '.html':'text/html', '.js':'text/javascript' }; +function serve(){ return new Promise(r=>{ const s=http.createServer((rq,rs)=>{ if(rq.url==='/favicon.ico'){rs.statusCode=204;return rs.end();} const f=path.join(ROOT,rq.url.split('?')[0]); fs.readFile(f,(e,b)=>{ if(e){rs.statusCode=404;return rs.end('nf');} rs.setHeader('Content-Type',TYPES[path.extname(f)]||'text/plain'); rs.end(b); }); }); s.listen(0,'127.0.0.1',()=>r(s)); }); } + +// 🚗 ORDER TYPES (dine-in / to-go / drive-thru) + the online/delivery CHANNEL hook. The intake picker sets the +// type; it rides onto the record, the ticket and the KDS chip. Channel is the display hook for an order handed +// in by the hub from online ordering / a delivery app (that ingestion is hub + account work, not the engine). +(async () => { + const errors = []; + const server = await serve(); const port = server.address().port; + const b = await chromium.launch({ executablePath: EXE, args:['--no-sandbox'] }); + const ctx = await b.newContext(); const p = await ctx.newPage(); + p.on('console', m => { if (m.type()==='error') errors.push(m.text()); }); + p.on('pageerror', e => errors.push('pageerror: '+e.message)); + await p.goto(`http://127.0.0.1:${port}/builder.html`); + await p.locator('button.trade').filter({ hasText: 'Hamburger Barn' }).click(); + await p.getByRole('button',{name:/Build it for me/}).click(); + await p.waitForFunction(() => window.__build && window.__build.html); + + const out = await p.evaluate(async () => { + const doc=()=>document.querySelector('#preview').contentDocument; + const w=document.querySelector('#preview').contentWindow; + const wait=ms=>new Promise(r=>setTimeout(r,ms)); + const cB=re=>{const b=[...doc().querySelectorAll('button')].find(x=>re.test(x.textContent.trim()));if(b){b.click();return true;}return false;}; + cB(/^Got it/); cB(/^Front Counter/); await wait(200); + const picker=[...doc().querySelectorAll('button')].map(b=>b.textContent.trim()).filter(x=>/^Dine-in$|^To-go$|^Drive-thru$/.test(x)); + const typeChip=w.orderTypeChip({orderType:"Drive-thru"}); + const chanChip=w.channelChip({channel:"DoorDash"}); + const cfgTypes=(w.CUSTOMPOS_FLOW.endpoints.orderTypes||[]).join(","); + return { picker, typeChip, chanChip, cfgTypes }; + }); + + await b.close(); server.close(); + console.log('\n=== RESULTS ==='); + console.log('intake shows the order-type picker (Dine-in/To-go/Drive-thru):', out.picker.length===3); + console.log('config carries the order types:', out.cfgTypes==="Dine-in,To-go,Drive-thru"); + console.log('order-type chip renders the type:', /Drive-thru/.test(out.typeChip)); + console.log('channel chip renders an online/delivery source:', /DoorDash/.test(out.chanChip)); + console.log('console errors:', errors.length?errors:'NONE'); + const ok = out.picker.length===3 && out.cfgTypes==="Dine-in,To-go,Drive-thru" && /Drive-thru/.test(out.typeChip) && /DoorDash/.test(out.chanChip) && !errors.length; + process.exit(ok?0:1); +})().catch(e => { console.error('FATAL', e); process.exit(2); }); diff --git a/tests/report-comps.js b/tests/report-comps.js new file mode 100644 index 0000000..a2b1d83 --- /dev/null +++ b/tests/report-comps.js @@ -0,0 +1,44 @@ +const { chromium } = require('playwright-core'); +const http = require('http'), fs = require('fs'), path = require('path'); +const EXE = process.env.CHROMIUM_EXE || '/opt/pw-browsers/chromium-1194/chrome-linux/chrome'; +const ROOT = path.resolve(__dirname, '..'); +const TYPES = { '.html':'text/html', '.js':'text/javascript' }; +function serve(){ return new Promise(r=>{ const s=http.createServer((rq,rs)=>{ if(rq.url==='/favicon.ico'){rs.statusCode=204;return rs.end();} const f=path.join(ROOT,rq.url.split('?')[0]); fs.readFile(f,(e,b)=>{ if(e){rs.statusCode=404;return rs.end('nf');} rs.setHeader('Content-Type',TYPES[path.extname(f)]||'text/plain'); rs.end(b); }); }); s.listen(0,'127.0.0.1',()=>r(s)); }); } + +// 🧾 DAILY CLOSE surfaces COMPS. The report already shows net/tax/tips/tenders/drawer/COGS/waste; this pins that +// the value given away as comps shows as its own line so the owner sees it at close. +(async () => { + const errors = []; + const server = await serve(); const port = server.address().port; + const b = await chromium.launch({ executablePath: EXE, args:['--no-sandbox'] }); + const ctx = await b.newContext(); const p = await ctx.newPage(); + p.on('console', m => { if (m.type()==='error') errors.push(m.text()); }); + p.on('pageerror', e => errors.push('pageerror: '+e.message)); + await p.goto(`http://127.0.0.1:${port}/builder.html`); + await p.locator('button.trade').filter({ hasText: 'Full-service' }).click(); + await p.getByRole('button',{name:/Build it for me/}).click(); + await p.waitForFunction(() => window.__build && window.__build.html); + + const out = await p.evaluate(async () => { + const doc=()=>document.querySelector('#preview').contentDocument; + const w=document.querySelector('#preview').contentWindow; + const wait=ms=>new Promise(r=>setTimeout(r,ms)); + // a PAID order today: $30 sold + a $20 comped line + w.DB=w.loadDB(); + w.DB.records=[{id:'RP',number:1,status:'PAID',ts:Date.now(),tenders:[{type:'cash',amount:30}], + lines:[{id:'a',name:'Steak',price:30,qty:1,category:'entree'},{id:'b',name:'Wine',price:0,qty:1,category:'drink',comped:{reason:'Kitchen error',amount:20,wasPrice:20}}]}]; + w.saveDB(w.DB); + const cB=re=>{const b=[...doc().querySelectorAll('button')].find(x=>re.test(x.textContent.trim()));if(b){b.click();return true;}return false;}; + cB(/^Got it/); cB(/^Office/); await wait(250); + const txt=doc().body.innerText; + return { onReport:/Sales summary/.test(txt), net:/Net sales/.test(txt), comps:/Comps \(on the house\)/.test(txt), amt:/20\.00/.test(txt) }; + }); + + await b.close(); server.close(); + console.log('\n=== RESULTS ==='); + console.log('the daily close renders a sales summary:', out.onReport && out.net); + console.log('comps show as their own line with the amount given away:', out.comps && out.amt); + console.log('console errors:', errors.length?errors:'NONE'); + const ok = out.onReport && out.net && out.comps && out.amt && !errors.length; + process.exit(ok?0:1); +})().catch(e => { console.error('FATAL', e); process.exit(2); }); diff --git a/tests/reservations.js b/tests/reservations.js new file mode 100644 index 0000000..5959060 --- /dev/null +++ b/tests/reservations.js @@ -0,0 +1,57 @@ +const { chromium } = require('playwright-core'); +const http = require('http'), fs = require('fs'), path = require('path'); +const EXE = process.env.CHROMIUM_EXE || '/opt/pw-browsers/chromium-1194/chrome-linux/chrome'; +const ROOT = path.resolve(__dirname, '..'); +const TYPES = { '.html':'text/html', '.js':'text/javascript' }; +function serve(){ return new Promise(r=>{ const s=http.createServer((rq,rs)=>{ if(rq.url==='/favicon.ico'){rs.statusCode=204;return rs.end();} const f=path.join(ROOT,rq.url.split('?')[0]); fs.readFile(f,(e,b)=>{ if(e){rs.statusCode=404;return rs.end('nf');} rs.setHeader('Content-Type',TYPES[path.extname(f)]||'text/plain'); rs.end(b); }); }); s.listen(0,'127.0.0.1',()=>r(s)); }); } + +// 🕐 RESERVATIONS + WAITLIST (the host stand on the floor): book/seat/cancel reservations; add/seat/clear the +// waitlist. Seating onto a table occupies it. Nothing is hard-deleted (cancelled / left / seated). +(async () => { + const errors = []; + const server = await serve(); const port = server.address().port; + const b = await chromium.launch({ executablePath: EXE, args:['--no-sandbox'] }); + const ctx = await b.newContext(); const p = await ctx.newPage(); + p.on('console', m => { if (m.type()==='error') errors.push(m.text()); }); + p.on('pageerror', e => errors.push('pageerror: '+e.message)); + await p.goto(`http://127.0.0.1:${port}/builder.html`); + await p.locator('button.trade').filter({ hasText: 'Full-service' }).click(); + await p.getByRole('button',{name:/Build it for me/}).click(); + await p.waitForFunction(() => window.__build && window.__build.html); + + const out = await p.evaluate(async () => { + const doc=()=>document.querySelector('#preview').contentDocument; + const w=document.querySelector('#preview').contentWindow; + const wait=ms=>new Promise(r=>setTimeout(r,ms)); + const cB=re=>{const b=[...doc().querySelectorAll('button')].find(x=>re.test(x.textContent.trim()));if(b){b.click();return true;}return false;}; + cB(/^Got it/); cB(/^Floor/); await wait(200); + const hostCards=/Reservations/.test(doc().body.innerText) && /Waitlist/.test(doc().body.innerText); + w.addReservation('Test Party','4','7:00',''); w.addWaitlist('Joe','2','15',''); + let db=w.loadDB(); + const booked=(db.reservations||[]).some(r=>r.name==='Test Party' && r.status==='booked' && r.party===4); + const waiting=(db.waitlist||[]).some(x=>x.name==='Joe' && x.status==='waiting'); + const rid=(db.reservations||[]).slice(-1)[0].id, wid=(db.waitlist||[]).slice(-1)[0].id; + const empty=w.floorStates()[0].id; + w.selTable='t2'; w.seatReservation(rid,'t2'); + w.selTable='t3'; w.seatWaitlist(wid,'t3'); + db=w.loadDB(); + const seatedResv=(db.reservations||[]).find(r=>r.id===rid); const seatedWl=(db.waitlist||[]).find(x=>x.id===wid); + const seatedOk = seatedResv.status==='seated' && seatedResv.tableId==='t2' && (db.tables.t2||{}).state!==empty + && seatedWl.status==='seated' && (db.tables.t3||{}).state!==empty; + // cancel a fresh reservation, and it leaves the active list (soft, not deleted) + w.addReservation('Cancel Me','2','8:00',''); db=w.loadDB(); const cid=(db.reservations||[]).slice(-1)[0].id; + w.cancelReservation(cid); db=w.loadDB(); + const cancelledSoft = (db.reservations||[]).some(r=>r.id===cid && r.status==='cancelled') && !w.todayReservations().some(r=>r.id===cid); + return { hostCards, booked, waiting, seatedOk, cancelledSoft }; + }); + + await b.close(); server.close(); + console.log('\n=== RESULTS ==='); + console.log('host stand shows Reservations + Waitlist cards:', out.hostCards); + console.log('a reservation books and a walk-in joins the waitlist:', out.booked && out.waiting); + console.log('seating a reservation/walk-in onto a table occupies it:', out.seatedOk); + console.log('cancelling is soft (kept, dropped from the active list):', out.cancelledSoft); + console.log('console errors:', errors.length?errors:'NONE'); + const ok = out.hostCards && out.booked && out.waiting && out.seatedOk && out.cancelledSoft && !errors.length; + process.exit(ok?0:1); +})().catch(e => { console.error('FATAL', e); process.exit(2); }); diff --git a/tests/seat-order.js b/tests/seat-order.js new file mode 100644 index 0000000..54d5436 --- /dev/null +++ b/tests/seat-order.js @@ -0,0 +1,49 @@ +const { chromium } = require('playwright-core'); +const http = require('http'), fs = require('fs'), path = require('path'); +const EXE = process.env.CHROMIUM_EXE || '/opt/pw-browsers/chromium-1194/chrome-linux/chrome'; +const ROOT = path.resolve(__dirname, '..'); +const TYPES = { '.html':'text/html', '.js':'text/javascript' }; +function serve(){ return new Promise(r=>{ const s=http.createServer((rq,rs)=>{ if(rq.url==='/favicon.ico'){rs.statusCode=204;return rs.end();} const f=path.join(ROOT,rq.url.split('?')[0]); fs.readFile(f,(e,b)=>{ if(e){rs.statusCode=404;return rs.end('nf');} rs.setHeader('Content-Type',TYPES[path.extname(f)]||'text/plain'); rs.end(b); }); }); s.listen(0,'127.0.0.1',()=>r(s)); }); } + +// 👤 SEAT-BY-SEAT ordering: pick a table, pick a seat, and each item rings to that seat. Pins the seat picker, +// the per-line seat stamp (draft shows 👤 S1 / S2), and the seatChip helper. (The seat then rides onto the +// record + KDS via the same l.seat field — verified live.) +(async () => { + const errors = []; + const server = await serve(); const port = server.address().port; + const b = await chromium.launch({ executablePath: EXE, args:['--no-sandbox'] }); + const ctx = await b.newContext(); const p = await ctx.newPage(); + p.on('console', m => { if (m.type()==='error') errors.push(m.text()); }); + p.on('pageerror', e => errors.push('pageerror: '+e.message)); + await p.goto(`http://127.0.0.1:${port}/builder.html`); + await p.locator('button.trade').filter({ hasText: 'Full-service' }).click(); + await p.getByRole('button',{name:/Build it for me/}).click(); + await p.waitForFunction(() => window.__build && window.__build.html); + + const out = await p.evaluate(async () => { + const doc=()=>document.querySelector('#preview').contentDocument; + const w=document.querySelector('#preview').contentWindow; + const wait=ms=>new Promise(r=>setTimeout(r,ms)); + const cB=re=>{const el=[...doc().querySelectorAll('button')].find(x=>re.test(x.textContent.trim()));if(el){el.click();return true;}return false;}; + const cT=re=>{const el=[...doc().querySelectorAll('.tile')].find(x=>re.test(x.textContent));if(el){el.click();return true;}return false;}; + cB(/^Got it/); cB(/^Server Station/); await wait(150); + cB(/^1$/); await wait(120); + const seatPicker = [...doc().querySelectorAll('button')].some(x=>/^Seat 1/.test(x.textContent.trim())) && + [...doc().querySelectorAll('button')].some(x=>/Whole table/.test(x.textContent.trim())); + cB(/^Seat 1/); await wait(60); cT(/Calamari/); await wait(120); + cB(/^Seat 2/); await wait(60); cT(/Ribeye/); await wait(80); cB(/^Rare|^Med rare|^Medium/); cB(/Add to order/); await wait(120); + const chipS1=[...doc().querySelectorAll('.line')].some(l=>/Calamari/.test(l.textContent)&&/S1/.test(l.textContent)); + const chipS2=[...doc().querySelectorAll('.line')].some(l=>/Ribeye/.test(l.textContent)&&/S2/.test(l.textContent)); + const chipHelper=/S7/.test(w.seatChip({seat:7})) && w.seatChip({})===""; + return { seatPicker, chipS1, chipS2, chipHelper }; + }); + + await b.close(); server.close(); + console.log('\n=== RESULTS ==='); + console.log('picking a table shows the seat picker (Whole table + Seat N):', out.seatPicker); + console.log('items ring to the chosen seat (draft shows S1 / S2):', out.chipS1 && out.chipS2); + console.log('seatChip renders a seat and is blank when unseated:', out.chipHelper); + console.log('console errors:', errors.length?errors:'NONE'); + const ok = out.seatPicker && out.chipS1 && out.chipS2 && out.chipHelper && !errors.length; + process.exit(ok?0:1); +})().catch(e => { console.error('FATAL', e); process.exit(2); }); diff --git a/tests/seat-receipt.js b/tests/seat-receipt.js new file mode 100644 index 0000000..6cee29f --- /dev/null +++ b/tests/seat-receipt.js @@ -0,0 +1,43 @@ +const { chromium } = require('playwright-core'); +const http = require('http'), fs = require('fs'), path = require('path'); +const EXE = process.env.CHROMIUM_EXE || '/opt/pw-browsers/chromium-1194/chrome-linux/chrome'; +const ROOT = path.resolve(__dirname, '..'); +const TYPES = { '.html':'text/html', '.js':'text/javascript' }; +function serve(){ return new Promise(r=>{ const s=http.createServer((rq,rs)=>{ if(rq.url==='/favicon.ico'){rs.statusCode=204;return rs.end();} const f=path.join(ROOT,rq.url.split('?')[0]); fs.readFile(f,(e,b)=>{ if(e){rs.statusCode=404;return rs.end('nf');} rs.setHeader('Content-Type',TYPES[path.extname(f)]||'text/plain'); rs.end(b); }); }); s.listen(0,'127.0.0.1',()=>r(s)); }); } + +// 👤🧾 PER-SEAT RECEIPT: when an order was rung by seat, the receipt groups its items under each seat (+ Shared) +// with per-seat subtotals — the itemized companion to split-by-seat. +(async () => { + const errors = []; + const server = await serve(); const port = server.address().port; + const b = await chromium.launch({ executablePath: EXE, args:['--no-sandbox'] }); + const ctx = await b.newContext(); const p = await ctx.newPage(); + p.on('console', m => { if (m.type()==='error') errors.push(m.text()); }); + p.on('pageerror', e => errors.push('pageerror: '+e.message)); + await p.goto(`http://127.0.0.1:${port}/builder.html`); + await p.locator('button.trade').filter({ hasText: 'Full-service' }).click(); + await p.getByRole('button',{name:/Build it for me/}).click(); + await p.waitForFunction(() => window.__build && window.__build.html); + + const out = await p.evaluate(() => { + const w=document.querySelector('#preview').contentWindow; + const seated={ id:'RR', number:9, status:'READY', tenders:[], lines:[ {id:'a',name:'Calamari',price:12,qty:1,seat:1}, {id:'b',name:'Ribeye',price:34,qty:1,seat:2}, {id:'c',name:'Bread',price:6,qty:1,seat:null} ] }; + const seatedHtml=w.receiptPanel(seated).innerHTML; + const flat={ id:'RF', number:10, status:'READY', tenders:[], lines:[ {id:'x',name:'Burger',price:9,qty:1} ] }; + const flatHtml=w.receiptPanel(flat).innerHTML; + return { + grouped: /Seat 1/.test(seatedHtml) && /Seat 2/.test(seatedHtml) && /Shared/.test(seatedHtml) && /Calamari/.test(seatedHtml) && /Ribeye/.test(seatedHtml), + total: /Total/.test(seatedHtml), + flatUngrouped: !/Seat 1/.test(flatHtml) && /Burger/.test(flatHtml) + }; + }); + + await b.close(); server.close(); + console.log('\n=== RESULTS ==='); + console.log('a seated order receipt groups items by seat (+ Shared) with subtotals:', out.grouped); + console.log('it still shows the order total:', out.total); + console.log('a non-seated order receipt stays a flat list:', out.flatUngrouped); + console.log('console errors:', errors.length?errors:'NONE'); + const ok = out.grouped && out.total && out.flatUngrouped && !errors.length; + process.exit(ok?0:1); +})().catch(e => { console.error('FATAL', e); process.exit(2); }); diff --git a/tests/server-assign.js b/tests/server-assign.js new file mode 100644 index 0000000..1b7bcf7 --- /dev/null +++ b/tests/server-assign.js @@ -0,0 +1,53 @@ +const { chromium } = require('playwright-core'); +const url = ('file://' + require('path').resolve(__dirname, '..', 'pos.html')); +const EXE = process.env.CHROMIUM_EXE || '/opt/pw-browsers/chromium-1194/chrome-linux/chrome'; +// 🧑‍🍳 SERVER ASSIGNMENT + "my tables": a server owns tables; the floor can filter to just theirs, and the +// assignment survives service-state changes and clears when the table is bussed empty. +const FLOW = { + flowId:"bistro", label:"Bistro", topology:"linear", + branding:{ name:"Bistro", brandColor:"#7a1f2b" }, + endpoints:{ customer:{persist:false}, payment:{tenders:["cash"], closeGate:"balanceLE0" } }, + staff:[ {id:"s1",name:"Riley",pin:"1111"}, {id:"s2",name:"Sam",pin:"2222"} ], + floor:{ label:"Dining Room", tables:[ {id:"t1",label:"1",seats:2}, {id:"t2",label:"2",seats:4} ] }, + catalog:[ {id:"x", name:"Plate", price:20, category:"food", path:[] } ], + stations:[ {id:"floor", type:"floor", label:"Floor", view:{} }, {id:"reg", type:"central", label:"Server", view:{money:true} } ] +}; +(async () => { + const errors = []; + const b = await chromium.launch({ executablePath: EXE, args:['--no-sandbox'] }); + const ctx = await b.newContext(); const p = await ctx.newPage(); + p.on('console', m => { if (m.type()==='error') errors.push(m.text()); }); + p.on('pageerror', e => errors.push('pageerror: '+e.message)); + await p.addInitScript(f => { window.CUSTOMPOS_FLOW = f; }, FLOW); + await p.goto(url); + const T = async () => (await p.locator('main').innerText()); + const server = async id => p.evaluate(i => (JSON.parse(localStorage.getItem(Object.keys(localStorage).find(k=>/pos/i.test(k))||"")||"{}").tables||{})[i], id); + + await p.getByRole('button',{name:/^Floor/}).first().click(); + const barOk = /Tables:/.test(await T()); // the "my tables" server filter bar + + // assign Riley to table 1 via the table panel + await p.locator('.tabletile').filter({ hasText: /^1/ }).click(); + await p.locator('.card').filter({ hasText: 'Server:' }).getByRole('button',{ name:'Riley' }).click(); + const assignedTile = /🧑‍🍳 Riley/.test(await T()); + const assignedDb = await p.evaluate(()=>{ const w=window; return (w.DB&&w.DB.tables&&w.DB.tables.t1&&w.DB.tables.t1.server)|| (w.tableServer?w.tableServer('t1'):null); }); + + // "my tables" — filter to Riley: only table 1 remains + await p.locator('.opts').filter({ hasText:'Tables:' }).getByRole('button',{ name:'Riley' }).click(); + const onlyMine = await p.locator('.tabletile').count(); + + // assignment persists across a state change; back to All + await p.locator('.opts').filter({ hasText:'Tables:' }).getByRole('button',{ name:/^All$/ }).click(); + await p.locator('.tabletile').filter({ hasText: /^1/ }).click(); + await p.getByRole('button',{name:/→ Seated/}).click(); + const persists = /🧑‍🍳 Riley/.test(await T()); + + await b.close(); + console.log('\n=== RESULTS ==='); + console.log('floor shows the "my tables" server filter:', barOk); + console.log('assigning a server tags the table (tile + DB):', assignedTile && assignedDb==='Riley'); + console.log('"my tables" filters the floor to one server (1 table):', onlyMine===1); + console.log('the assignment survives a service-state change:', persists); + console.log('console errors:', errors.length?errors:'NONE'); + process.exit(errors.length||!barOk||!assignedTile||assignedDb!=='Riley'||onlyMine!==1||!persists?1:0); +})().catch(e=>{ console.error('FATAL',e); process.exit(2); }); diff --git a/tests/split-seat.js b/tests/split-seat.js new file mode 100644 index 0000000..8191268 --- /dev/null +++ b/tests/split-seat.js @@ -0,0 +1,53 @@ +const { chromium } = require('playwright-core'); +const http = require('http'), fs = require('fs'), path = require('path'); +const EXE = process.env.CHROMIUM_EXE || '/opt/pw-browsers/chromium-1194/chrome-linux/chrome'; +const ROOT = path.resolve(__dirname, '..'); +const TYPES = { '.html':'text/html', '.js':'text/javascript' }; +function serve(){ return new Promise(r=>{ const s=http.createServer((rq,rs)=>{ if(rq.url==='/favicon.ico'){rs.statusCode=204;return rs.end();} const f=path.join(ROOT,rq.url.split('?')[0]); fs.readFile(f,(e,b)=>{ if(e){rs.statusCode=404;return rs.end('nf');} rs.setHeader('Content-Type',TYPES[path.extname(f)]||'text/plain'); rs.end(b); }); }); s.listen(0,'127.0.0.1',()=>r(s)); }); } + +// 💳👤 SPLIT & PAY BY SEAT — each guest pays their own. Drives the engine's own seat-split math + tender model +// on a real (built) engine: one bucket per seat (+ a "Shared" bucket), each seat's amount is its share of the +// total, tenders tag to a seat, and the order only clears when every seat is paid. (UI wiring verified live; +// this pins the money logic deterministically so it can't drift.) +(async () => { + const errors = []; + const server = await serve(); const port = server.address().port; + const b = await chromium.launch({ executablePath: EXE, args:['--no-sandbox'] }); + const ctx = await b.newContext(); const p = await ctx.newPage(); + p.on('console', m => { if (m.type()==='error') errors.push(m.text()); }); + p.on('pageerror', e => errors.push('pageerror: '+e.message)); + await p.goto(`http://127.0.0.1:${port}/builder.html`); + await p.locator('button.trade').filter({ hasText: 'Full-service' }).click(); + await p.getByRole('button',{name:/Build it for me/}).click(); + await p.waitForFunction(() => window.__build && window.__build.html); + + const out = await p.evaluate(() => { + const w=document.querySelector('#preview').contentWindow; + // seat1 $12, seat2 $34, shared $16 — no tax so the shares are exact + const r={ id:"RT", number:99, status:"READY", tenders:[], splitMode:"seat", + lines:[ {id:"a",name:"A",price:12,qty:1,seat:1}, {id:"b",name:"B",price:34,qty:1,seat:2}, {id:"c",name:"C",price:16,qty:1,seat:null} ] }; + const bkts=w.seatBuckets(r); const byLabel=l=>bkts.find(x=>x.label===l); + const amounts={ s1:w.seatAmount(r,byLabel("Seat 1")), s2:w.seatAmount(r,byLabel("Seat 2")), shared:w.seatAmount(r,byLabel("Shared")) }; + const bucketCount=bkts.length; + // pay each bucket with a seat-tagged tender; balance must only clear when all are paid + r.tenders.push({type:"cash",amount:12,seatKey:"s1"}); const bal1=w.recordBalance(r); const s1cleared=w.seatDue(r,byLabel("Seat 1"))===0; + r.tenders.push({type:"card",amount:34,seatKey:"s2"}); const bal2=w.recordBalance(r); + const stillOpen = bal2>0.0001; // shared not yet paid + r.tenders.push({type:"cash",amount:16,seatKey:"shared"}); + const balEnd=w.recordBalance(r); + const allTagged=(r.tenders||[]).every(t=>t.seatKey); + return { total:w.recordTotal(r), amounts, bucketCount, bal1, s1cleared, stillOpen, balEnd, allTagged }; + }); + + await b.close(); server.close(); + console.log('\n=== RESULTS ==='); + console.log('total $62; one bucket per seat + shared (3):', out.total===62 && out.bucketCount===3); + console.log('each seat billed its share (S1 12, S2 34, Shared 16):', out.amounts.s1===12 && out.amounts.s2===34 && out.amounts.shared===16); + console.log('paying seat 1 clears only seat 1 (bal 50):', out.bal1===50 && out.s1cleared); + console.log('order stays open until the shared bucket is paid too:', out.stillOpen && out.balEnd===0); + console.log('every tender is tagged to its seat:', out.allTagged); + console.log('console errors:', errors.length?errors:'NONE'); + const ok = out.total===62 && out.bucketCount===3 && out.amounts.s1===12 && out.amounts.s2===34 && out.amounts.shared===16 + && out.bal1===50 && out.s1cleared && out.stillOpen && out.balEnd===0 && out.allTagged && !errors.length; + process.exit(ok?0:1); +})().catch(e => { console.error('FATAL', e); process.exit(2); }); diff --git a/tests/table-transfer.js b/tests/table-transfer.js new file mode 100644 index 0000000..bd1cc8f --- /dev/null +++ b/tests/table-transfer.js @@ -0,0 +1,46 @@ +const { chromium } = require('playwright-core'); +const http = require('http'), fs = require('fs'), path = require('path'); +const EXE = process.env.CHROMIUM_EXE || '/opt/pw-browsers/chromium-1194/chrome-linux/chrome'; +const ROOT = path.resolve(__dirname, '..'); +const TYPES = { '.html':'text/html', '.js':'text/javascript' }; +function serve(){ return new Promise(r=>{ const s=http.createServer((rq,rs)=>{ if(rq.url==='/favicon.ico'){rs.statusCode=204;return rs.end();} const f=path.join(ROOT,rq.url.split('?')[0]); fs.readFile(f,(e,b)=>{ if(e){rs.statusCode=404;return rs.end('nf');} rs.setHeader('Content-Type',TYPES[path.extname(f)]||'text/plain'); rs.end(b); }); }); s.listen(0,'127.0.0.1',()=>r(s)); }); } + +// 🔀 TRANSFER / MERGE tables: move a table's open check to another table — a transfer when the target is empty, +// a merge when it already has a check. The source table is freed either way. (Money logic pinned on the built engine.) +(async () => { + const errors = []; + const server = await serve(); const port = server.address().port; + const b = await chromium.launch({ executablePath: EXE, args:['--no-sandbox'] }); + const ctx = await b.newContext(); const p = await ctx.newPage(); + p.on('console', m => { if (m.type()==='error') errors.push(m.text()); }); + p.on('pageerror', e => errors.push('pageerror: '+e.message)); + await p.goto(`http://127.0.0.1:${port}/builder.html`); + await p.locator('button.trade').filter({ hasText: 'Full-service' }).click(); + await p.getByRole('button',{name:/Build it for me/}).click(); + await p.waitForFunction(() => window.__build && window.__build.html); + + const out = await p.evaluate(() => { + const w=document.querySelector('#preview').contentWindow; + w.DB=w.loadDB(); + w.DB.records=[ {id:'A1',number:1,status:'READY',tableId:'t1',tenders:[],lines:[{id:'a',name:'Steak',price:30,qty:1}]}, + {id:'B1',number:2,status:'READY',tableId:'t2',tenders:[],lines:[{id:'b',name:'Wine',price:11,qty:1}]} ]; + const empty=w.floorStates?w.floorStates()[0].id:'empty'; + w.DB.tables={ t1:{state:'assigned',since:Date.now(),server:'Riley'}, t2:{state:'assigned',since:Date.now(),server:'Sam'} }; + w.saveDB(w.DB); + w.moveTable('t1','t2'); // merge (t2 occupied) + let db=w.loadDB(); + const merge={ onT2:(db.records||[]).filter(r=>r.tableId==='t2').length, onT1:(db.records||[]).filter(r=>r.tableId==='t1').length, t1empty:(db.tables.t1||{}).state===empty }; + w.moveTable('t2','t3'); // transfer (t3 empty) + db=w.loadDB(); + const transfer={ onT3:(db.records||[]).filter(r=>r.tableId==='t3').length, t3occupied:(db.tables.t3||{}).state && (db.tables.t3||{}).state!==empty, t2empty:(db.tables.t2||{}).state===empty }; + return { merge, transfer }; + }); + + await b.close(); server.close(); + console.log('\n=== RESULTS ==='); + console.log('merge onto an occupied table combines checks + frees the source:', out.merge.onT2===2 && out.merge.onT1===0 && out.merge.t1empty); + console.log('transfer to an empty table moves the checks + occupies it:', out.transfer.onT3===2 && out.transfer.t3occupied && out.transfer.t2empty); + console.log('console errors:', errors.length?errors:'NONE'); + const ok = out.merge.onT2===2 && out.merge.onT1===0 && out.merge.t1empty && out.transfer.onT3===2 && out.transfer.t3occupied && out.transfer.t2empty && !errors.length; + process.exit(ok?0:1); +})().catch(e => { console.error('FATAL', e); process.exit(2); }); diff --git a/tests/tip-out.js b/tests/tip-out.js new file mode 100644 index 0000000..a749859 --- /dev/null +++ b/tests/tip-out.js @@ -0,0 +1,43 @@ +const { chromium } = require('playwright-core'); +const http = require('http'), fs = require('fs'), path = require('path'); +const EXE = process.env.CHROMIUM_EXE || '/opt/pw-browsers/chromium-1194/chrome-linux/chrome'; +const ROOT = path.resolve(__dirname, '..'); +const TYPES = { '.html':'text/html', '.js':'text/javascript' }; +function serve(){ return new Promise(r=>{ const s=http.createServer((rq,rs)=>{ if(rq.url==='/favicon.ico'){rs.statusCode=204;return rs.end();} const f=path.join(ROOT,rq.url.split('?')[0]); fs.readFile(f,(e,b)=>{ if(e){rs.statusCode=404;return rs.end('nf');} rs.setHeader('Content-Type',TYPES[path.extname(f)]||'text/plain'); rs.end(b); }); }); s.listen(0,'127.0.0.1',()=>r(s)); }); } + +// 💸 TIP-OUT BY ROLE at close: when configured (tip.tipOut=[{role,pct}]), the report spells out each support +// role's cut of today's tips and what the servers keep — instead of doing it by hand for payroll. +(async () => { + const errors = []; + const server = await serve(); const port = server.address().port; + const b = await chromium.launch({ executablePath: EXE, args:['--no-sandbox'] }); + const ctx = await b.newContext(); const p = await ctx.newPage(); + p.on('console', m => { if (m.type()==='error') errors.push(m.text()); }); + p.on('pageerror', e => errors.push('pageerror: '+e.message)); + await p.goto(`http://127.0.0.1:${port}/builder.html`); + await p.locator('button.trade').filter({ hasText: 'Full-service' }).click(); + await p.getByRole('button',{name:/Build it for me/}).click(); + await p.waitForFunction(() => window.__build && window.__build.html); + + const out = await p.evaluate(async () => { + const doc=()=>document.querySelector('#preview').contentDocument; + const w=document.querySelector('#preview').contentWindow; + const wait=ms=>new Promise(r=>setTimeout(r,ms)); + w.DB=w.loadDB(); + w.DB.records=[{id:'RT',number:1,status:'PAID',ts:Date.now(),tip:100,tenders:[{type:'card',amount:200}],lines:[{id:'a',name:'Steak',price:100,qty:1,category:'entree'}]}]; + w.saveDB(w.DB); + const cB=re=>{const b=[...doc().querySelectorAll('button')].find(x=>re.test(x.textContent.trim()));if(b){b.click();return true;}return false;}; + cB(/^Got it/); cB(/^Office/); await wait(280); + const txt=doc().body.innerText; + return { shown:/Tip-out/.test(txt), kitchen:/Kitchen \(5%\)/.test(txt)&&/5\.00/.test(txt), bussers:/Bussers \(10%\)/.test(txt)&&/10\.00/.test(txt), kept:/Kept by servers/.test(txt)&&/85\.00/.test(txt) }; + }); + + await b.close(); server.close(); + console.log('\n=== RESULTS ==='); + console.log('the daily close shows a tip-out breakdown:', out.shown); + console.log('each role gets its % of tips (Kitchen 5%=$5, Bussers 10%=$10):', out.kitchen && out.bussers); + console.log('servers keep the remainder ($85 of $100):', out.kept); + console.log('console errors:', errors.length?errors:'NONE'); + const ok = out.shown && out.kitchen && out.bussers && out.kept && !errors.length; + process.exit(ok?0:1); +})().catch(e => { console.error('FATAL', e); process.exit(2); }); diff --git a/tests/upsell.js b/tests/upsell.js new file mode 100644 index 0000000..4a97c82 --- /dev/null +++ b/tests/upsell.js @@ -0,0 +1,44 @@ +const { chromium } = require('playwright-core'); +const http = require('http'), fs = require('fs'), path = require('path'); +const EXE = process.env.CHROMIUM_EXE || '/opt/pw-browsers/chromium-1194/chrome-linux/chrome'; +const ROOT = path.resolve(__dirname, '..'); +const TYPES = { '.html':'text/html', '.js':'text/javascript' }; +function serve(){ return new Promise(r=>{ const s=http.createServer((rq,rs)=>{ if(rq.url==='/favicon.ico'){rs.statusCode=204;return rs.end();} const f=path.join(ROOT,rq.url.split('?')[0]); fs.readFile(f,(e,b)=>{ if(e){rs.statusCode=404;return rs.end('nf');} rs.setHeader('Content-Type',TYPES[path.extname(f)]||'text/plain'); rs.end(b); }); }); s.listen(0,'127.0.0.1',()=>r(s)); }); } + +// 💡 SUGGESTED ADD-ON / UPSELL: an item's `suggest` list surfaces one-tap add buttons for complementary items +// not already on the ticket (Bubba → Fries / Milkshake / Drink). Tapping adds it and drops it from the suggestions. +(async () => { + const errors = []; + const server = await serve(); const port = server.address().port; + const b = await chromium.launch({ executablePath: EXE, args:['--no-sandbox'] }); + const ctx = await b.newContext(); const p = await ctx.newPage(); + p.on('console', m => { if (m.type()==='error') errors.push(m.text()); }); + p.on('pageerror', e => errors.push('pageerror: '+e.message)); + await p.goto(`http://127.0.0.1:${port}/builder.html`); + await p.locator('button.trade').filter({ hasText: 'Hamburger Barn' }).click(); + await p.getByRole('button',{name:/Build it for me/}).click(); + await p.waitForFunction(() => window.__build && window.__build.html); + + const out = await p.evaluate(async () => { + const doc=()=>document.querySelector('#preview').contentDocument; + const wait=ms=>new Promise(r=>setTimeout(r,ms)); + const cB=re=>{const b=[...doc().querySelectorAll('button')].find(x=>re.test(x.textContent.trim()));if(b){b.click();return true;}return false;}; + cB(/^Got it/); cB(/^Front Counter/); await wait(180); + const bubba=[...doc().querySelectorAll('.tile')].find(x=>/Bubba Burger/.test(x.textContent)); if(bubba)bubba.click(); await wait(100); + cB(/^Single/); cB(/Add to order/); await wait(120); + const shown=/Want to add/.test(doc().body.innerText); + const btns=[...doc().querySelectorAll('button')].map(b=>b.textContent.trim()).filter(x=>x.startsWith('+ ')&&/Fries|Milkshake|Fountain Drink/.test(x)); + const fb=[...doc().querySelectorAll('button')].find(x=>x.textContent.trim().startsWith('+ Fries')); if(fb)fb.click(); await wait(120); + const friesAdded=[...doc().querySelectorAll('.line')].some(l=>/Fries/.test(l.textContent)); + const friesGone=![...doc().querySelectorAll('button')].some(x=>x.textContent.trim().startsWith('+ Fries')); + return { shown, btnCount:btns.length, friesAdded, friesGone }; + }); + + await b.close(); server.close(); + console.log('\n=== RESULTS ==='); + console.log('adding a main surfaces its suggested add-ons (Fries/Milkshake/Drink):', out.shown && out.btnCount===3); + console.log('tapping a suggestion adds it and drops it from the list:', out.friesAdded && out.friesGone); + console.log('console errors:', errors.length?errors:'NONE'); + const ok = out.shown && out.btnCount===3 && out.friesAdded && out.friesGone && !errors.length; + process.exit(ok?0:1); +})().catch(e => { console.error('FATAL', e); process.exit(2); });