diff --git a/docs/cms.md b/docs/cms.md index a438095..59bef8c 100644 --- a/docs/cms.md +++ b/docs/cms.md @@ -70,26 +70,6 @@ Both use the same data source (`GET /restaurants/{id}/orders`) filtered by status. The KDS view escalates color by age (`>5min` orange, `>15min` red) and offers one-tap state transitions. -When a card transitions to `accepted` (driven by `cookingMode(order)` -in `kds.js`), three inline `:style` bindings kick in: - -- the items `q-card-section` switches base font between `1rem` and - `1.25rem`, -- the modifier list (`.text-caption.text-grey-7`) bumps to `1.15rem` - + medium weight + `color: inherit` (drops the muted grey), -- the per-line note (`.text-caption.text-amber-9`) bumps to `1.15rem` - + medium weight; color is left alone so it stays amber. - -All cooking-mode styling is inline because an upstream `!important` -rule (likely an lnbits theme override on Quasar's typography -utilities) defeats class-based CSS rules — even with `!important` -on our side. Inline `:style` wins without needing the arms race. -Card chrome and the age-based `bg-{color}-1` from `cardClass()` -are untouched. The amber -per-line note keeps its color because only `.text-grey-7` is -overridden. No background rules; card chrome and the age-based -`bg-{color}-1` from `cardClass()` are untouched. - Today the monitor + KDS poll every 5–8 s. SSE / Nostr push is on the roadmap. diff --git a/scripts/seed_lightning_cafe.py b/scripts/seed_lightning_cafe.py deleted file mode 100644 index 05b2fe8..0000000 --- a/scripts/seed_lightning_cafe.py +++ /dev/null @@ -1,826 +0,0 @@ -#!/usr/bin/env python3 -""" -Seed "Lightning Cafe" — the canonical demo restaurant. - -This seed is tracked in the repo on purpose: it's the data the demo -deploy lands customers on, and it exercises every feature the -restaurant extension and webapp bundle expose, so it doubles as a -visual smoke-test fixture. - -Features showcased ------------------- -- Menu tree (root + nested subcategories up to depth 2) -- Items with descriptions, dietary tags, allergens, ingredients, - calorie counts -- Featured items (is_featured=1) — pinned to top of category -- Sold-out items (is_available=0) — "Sold out" badge -- Low-stock items (stock <= low_stock_threshold) — "Low stock" hint -- Multiple modifier groups per item: required/one (radio), - required/many (limited multi-select), optional/many (free multi) -- Modifier price deltas in sats - -Prices are integer sats (no fractional). The extension's -_SAT_ALIASES accept 'sat' as the canonical name; we use that. - -Usage: - python scripts/seed_lightning_cafe.py --wallet \\ - [--db /path/to/ext_restaurant.sqlite3] \\ - [--slug lightning-cafe] \\ - [--force] -""" - -from __future__ import annotations - -import argparse -import json -import os -import sqlite3 -import sys -import uuid -from datetime import datetime, timezone -from pathlib import Path -from typing import Optional - - -CURRENCY = "sat" - - -# --------------------------------------------------------------------- # -# Menu definition # -# --------------------------------------------------------------------- # - -MENU: list[dict] = [ - # ───────────────────────────── DRINKS ───────────────────────────── # - { - "name": "Drinks", - "sort": 0, - "children": [ - { - "name": "Coffee", - "sort": 0, - "items": [ - { - "name": "Espresso", - "price": 200, - "desc": "Double shot, locally roasted", - "ingredients": ["espresso beans", "water"], - "calories": 5, - }, - { - "name": "Americano", - "price": 250, - "desc": "Espresso lengthened with hot water", - "ingredients": ["espresso beans", "water"], - "calories": 10, - }, - { - "name": "Latte", - "price": 500, - "desc": "Espresso + steamed milk", - "ingredients": ["espresso beans", "milk"], - "allergens": ["dairy"], - "calories": 120, - "featured": True, - "modifier_groups": [ - { - "name": "Milk", - "kind": "required", - "selection": "one", - "min": 1, - "max": 1, - "modifiers": [ - {"name": "Whole", "delta": 0, "default": True}, - {"name": "Oat", "delta": 50}, - {"name": "Almond", "delta": 50}, - {"name": "Soy", "delta": 50}, - ], - }, - { - "name": "Flavor (optional)", - "kind": "optional", - "selection": "one", - "min": 0, - "max": 1, - "modifiers": [ - {"name": "Vanilla", "delta": 75}, - {"name": "Caramel", "delta": 75}, - {"name": "Hazelnut", "delta": 75}, - ], - }, - ], - }, - { - "name": "Cappuccino", - "price": 500, - "desc": "Espresso + thick microfoam", - "ingredients": ["espresso beans", "milk"], - "allergens": ["dairy"], - "calories": 80, - }, - ], - }, - { - "name": "Cold", - "sort": 1, - "items": [ - { - "name": "Iced Coffee", - "price": 350, - "desc": "Cold brew over ice", - "dietary": ["vegan", "gluten_free"], - "calories": 5, - }, - { - "name": "Mineral Water", - "price": 150, - "desc": "Sparkling, 500ml", - "dietary": ["vegan", "gluten_free"], - "calories": 0, - }, - { - "name": "Fresh Orange Juice", - "price": 400, - "desc": "Squeezed to order", - "dietary": ["vegan", "gluten_free"], - "ingredients": ["oranges"], - "calories": 110, - # Demo: low stock — should render the "low stock" hint. - "stock": 3, - "low_stock_threshold": 5, - }, - ], - }, - { - "name": "Tea", - "sort": 2, - "items": [ - { - "name": "Earl Grey", - "price": 300, - "desc": "Bergamot black tea", - "ingredients": ["black tea", "bergamot oil"], - "calories": 0, - }, - { - "name": "Matcha Latte", - "price": 600, - "desc": "Ceremonial-grade matcha + steamed milk", - "allergens": ["dairy"], - "calories": 110, - "modifier_groups": [ - { - "name": "Milk", - "kind": "required", - "selection": "one", - "min": 1, - "max": 1, - "modifiers": [ - {"name": "Whole", "delta": 0, "default": True}, - {"name": "Oat", "delta": 50}, - {"name": "Almond", "delta": 50}, - ], - }, - ], - }, - { - "name": "Chai Latte", - "price": 600, - "desc": "House-spiced chai with steamed milk", - "allergens": ["dairy"], - "ingredients": [ - "black tea", - "cardamom", - "ginger", - "cinnamon", - "cloves", - "milk", - ], - "calories": 140, - }, - ], - }, - ], - }, - # ─────────────────────────────── FOOD ─────────────────────────────── # - { - "name": "Food", - "sort": 1, - "children": [ - { - "name": "Breakfast", - "sort": 0, - "items": [ - { - "name": "Avocado Toast", - "price": 1200, - "desc": "Sourdough, smashed avocado, lime, chili flakes", - "dietary": ["vegan"], - "allergens": ["gluten"], - "ingredients": [ - "sourdough", - "avocado", - "lime", - "chili flakes", - "olive oil", - ], - "calories": 380, - "featured": True, - "modifier_groups": [ - { - "name": "Add protein", - "kind": "optional", - "selection": "one", - "min": 0, - "max": 1, - "modifiers": [ - {"name": "Poached egg", "delta": 200}, - {"name": "Smoked salmon", "delta": 600}, - {"name": "Halloumi", "delta": 400}, - ], - }, - { - "name": "Toppings", - "kind": "optional", - "selection": "many", - "min": 0, - "max": 4, - "modifiers": [ - {"name": "Cherry tomatoes", "delta": 100}, - {"name": "Feta", "delta": 200}, - {"name": "Microgreens", "delta": 150}, - {"name": "Everything-bagel seasoning", "delta": 50}, - ], - }, - ], - }, - { - "name": "Granola Bowl", - "price": 900, - "desc": "House granola, yogurt, seasonal fruit, honey", - "dietary": ["vegetarian"], - "allergens": ["dairy", "nuts", "gluten"], - "calories": 420, - }, - { - "name": "Breakfast Burrito", - "price": 1500, - "desc": "Scrambled eggs, black beans, cheese, pico, salsa verde", - "allergens": ["dairy", "eggs", "gluten"], - "calories": 580, - "modifier_groups": [ - { - "name": "Heat level", - "kind": "required", - "selection": "one", - "min": 1, - "max": 1, - "modifiers": [ - {"name": "Mild", "delta": 0, "default": True}, - {"name": "Medium", "delta": 0}, - {"name": "Spicy", "delta": 0}, - {"name": "Volcano 🌋", "delta": 0}, - ], - }, - { - "name": "Add-ons", - "kind": "optional", - "selection": "many", - "min": 0, - "max": 3, - "modifiers": [ - {"name": "Avocado", "delta": 150}, - {"name": "Bacon", "delta": 200}, - {"name": "Extra cheese", "delta": 100}, - ], - }, - ], - }, - ], - }, - { - "name": "Lunch", - "sort": 1, - "items": [ - { - "name": "Lightning Burger", - "price": 2500, - "desc": "Grass-fed beef, aged cheddar, brioche bun, " - "house aioli, butter lettuce, tomato", - "allergens": ["dairy", "eggs", "gluten"], - "calories": 780, - "featured": True, - "modifier_groups": [ - { - "name": "Doneness", - "kind": "required", - "selection": "one", - "min": 1, - "max": 1, - "modifiers": [ - {"name": "Medium rare", "delta": 0, "default": True}, - {"name": "Medium", "delta": 0}, - {"name": "Medium well", "delta": 0}, - {"name": "Well done", "delta": 0}, - ], - }, - { - "name": "Side", - "kind": "required", - "selection": "one", - "min": 1, - "max": 1, - "modifiers": [ - {"name": "Fries", "delta": 0, "default": True}, - {"name": "Sweet potato fries", "delta": 200}, - {"name": "Side salad", "delta": 0}, - ], - }, - { - "name": "Extras", - "kind": "optional", - "selection": "many", - "min": 0, - "max": 4, - "modifiers": [ - {"name": "Bacon", "delta": 300}, - {"name": "Fried egg", "delta": 200}, - {"name": "Avocado", "delta": 250}, - {"name": "Caramelized onions", "delta": 100}, - ], - }, - ], - }, - { - "name": "Caesar Salad", - "price": 1400, - "desc": "Romaine, parmesan, garlic croutons, classic dressing", - "allergens": ["dairy", "eggs", "gluten", "fish"], - "calories": 320, - "modifier_groups": [ - { - "name": "Add protein", - "kind": "optional", - "selection": "one", - "min": 0, - "max": 1, - "modifiers": [ - {"name": "Grilled chicken", "delta": 500}, - {"name": "Anchovies", "delta": 200}, - {"name": "Shrimp", "delta": 700}, - ], - }, - ], - }, - { - "name": "Veggie Wrap", - "price": 1100, - "desc": "Hummus, roasted veg, greens, in a spinach tortilla", - "dietary": ["vegan"], - "allergens": ["gluten", "sesame"], - "calories": 410, - }, - { - "name": "Daily Special", - "price": 1800, - "desc": "Ask the cashier — changes daily", - # Demo: sold out — should render "Sold out" badge. - "available": False, - }, - ], - }, - { - "name": "Snacks", - "sort": 2, - "items": [ - { - "name": "Almond Croissant", - "price": 450, - "desc": "Buttery, frangipane-filled, sliced almonds", - "allergens": ["dairy", "gluten", "nuts", "eggs"], - "calories": 460, - }, - { - "name": "Chocolate Chip Cookie", - "price": 300, - "desc": "Brown butter, dark chocolate, sea salt", - "allergens": ["dairy", "gluten", "eggs"], - "calories": 280, - }, - { - "name": "Energy Ball", - "price": 250, - "desc": "Dates, oats, cocoa, almonds, coconut", - "dietary": ["vegan", "gluten_free"], - "allergens": ["nuts"], - "calories": 180, - }, - ], - }, - ], - }, -] - - -# --------------------------------------------------------------------- # -# DB path detection # -# --------------------------------------------------------------------- # - -_DB_CANDIDATES = [ - os.environ.get("LNBITS_DATA_FOLDER", "") + "/ext_restaurant.sqlite3", - "./data/ext_restaurant.sqlite3", - "../data/ext_restaurant.sqlite3", - str(Path.home() / ".lnbits/data/ext_restaurant.sqlite3"), - "/var/lib/lnbits/data/ext_restaurant.sqlite3", - "/var/lib/lnbits/ext_restaurant.sqlite3", -] - - -def find_db(explicit: Optional[str]) -> Path: - if explicit: - path = Path(explicit).expanduser().resolve() - if not path.exists(): - sys.exit(f"error: db not found at {path}") - return path - for cand in _DB_CANDIDATES: - if cand and Path(cand).exists(): - return Path(cand).resolve() - sys.exit( - "error: ext_restaurant.sqlite3 not found in any default location.\n" - " Pass --db /path/to/ext_restaurant.sqlite3 explicitly.\n" - f" Tried: {[c for c in _DB_CANDIDATES if c]}" - ) - - -# --------------------------------------------------------------------- # -# Helpers # -# --------------------------------------------------------------------- # - - -def now_ts() -> int: - # LNbits's Database on SQLite stores TIMESTAMP columns as Unix - # integer seconds (see dict_to_model → datetime.fromtimestamp). - return int(datetime.now(tz=timezone.utc).timestamp()) - - -def new_id() -> str: - return uuid.uuid4().hex - - -def restaurant_extra() -> str: - # `fields` is a dict at the Python model level, but LNbits's - # dict_to_model expects dict-typed fields *inside* a JSON-encoded - # submodel to be stored as a JSON string (it calls json.loads on - # the value when re-hydrating). - return json.dumps({"notes": None, "fields": json.dumps({})}) - - -def restaurant_socials() -> str: - return json.dumps( - { - "website": None, - "instagram": None, - "facebook": None, - "twitter": None, - "nostr": None, - } - ) - - -def restaurant_open_hours() -> str: - # Open 7am-9pm every day. `schedule` is a dict inside the - # OpenHours submodel — must be a JSON string per LNbits's - # dict_to_model convention. - schedule = { - str(d): [{"start": "07:00", "end": "21:00"}] for d in range(7) - } - return json.dumps({"schedule": json.dumps(schedule)}) - - -def item_extra() -> str: - return json.dumps({"notes": None, "fields": json.dumps({})}) - - -# --------------------------------------------------------------------- # -# Seeder # -# --------------------------------------------------------------------- # - - -def seed(db_path: Path, wallet: str, slug: str, force: bool) -> None: - conn = sqlite3.connect(str(db_path)) - conn.row_factory = sqlite3.Row - cur = conn.cursor() - - cur.execute( - "SELECT id, name FROM restaurants WHERE slug = ? AND wallet = ?", - (slug, wallet), - ) - existing = cur.fetchone() - if existing: - if not force: - sys.exit( - f"error: a restaurant with slug='{slug}' already exists for " - f"wallet={wallet} (id={existing['id']}, name={existing['name']!r}).\n" - f" Pass --force to drop and recreate, or pick a different " - f"--slug." - ) - _drop_restaurant(cur, existing["id"]) - print(f" dropped existing restaurant {existing['id']}") - - restaurant_id = new_id() - now = now_ts() - - print(f"seeding 'Lightning Cafe' (id={restaurant_id})") - print(f" db: {db_path}") - print(f" wallet: {wallet}") - print(f" slug: {slug}") - print(f" currency: {CURRENCY}") - - cur.execute( - """ - INSERT INTO restaurants ( - id, wallet, name, slug, description, currency, timezone, - location, geohash, logo_url, banner_url, social_links, - open_hours, is_open, accepts_cash, accepts_lightning, - tip_presets, tax_rate, printer_endpoint, nostr_pubkey, - nostr_relays, nostr_event_id, nostr_event_created_at, - extra, time - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - restaurant_id, - wallet, - "Lightning Cafe", - slug, - "A demo cafe paying in sats — coffee, brunch, and the full " - "feature tour of the LNbits restaurant extension.", - CURRENCY, - "UTC", - "The Internet", - None, - None, - None, - restaurant_socials(), - restaurant_open_hours(), - 1, - 1, - 1, - json.dumps([10, 15, 20]), - 0.0, - None, - None, - json.dumps([]), - None, - None, - restaurant_extra(), - now, - ), - ) - - for root_sort, root in enumerate(MENU): - root_id = new_id() - cur.execute( - """ - INSERT INTO menu_nodes ( - id, restaurant_id, parent_id, name, description, - sort_order, image_url, depth, path, time - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - root_id, - restaurant_id, - None, - root["name"], - None, - root.get("sort", root_sort), - None, - 0, - root_id, - now, - ), - ) - _insert_items_in_node( - cur, restaurant_id, root_id, root.get("items", []), now - ) - for child_sort, child in enumerate(root.get("children", [])): - child_id = new_id() - cur.execute( - """ - INSERT INTO menu_nodes ( - id, restaurant_id, parent_id, name, description, - sort_order, image_url, depth, path, time - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - child_id, - restaurant_id, - root_id, - child["name"], - None, - child.get("sort", child_sort), - None, - 1, - f"{root_id}/{child_id}", - now, - ), - ) - _insert_items_in_node( - cur, - restaurant_id, - child_id, - child.get("items", []), - now, - ) - - conn.commit() - - cur.execute( - "SELECT COUNT(*) AS n FROM menu_nodes WHERE restaurant_id = ?", - (restaurant_id,), - ) - nodes_n = cur.fetchone()["n"] - cur.execute( - "SELECT COUNT(*) AS n FROM menu_items WHERE restaurant_id = ?", - (restaurant_id,), - ) - items_n = cur.fetchone()["n"] - cur.execute( - "SELECT COUNT(*) AS n FROM modifier_groups mg " - "JOIN menu_items mi ON mi.id = mg.menu_item_id " - "WHERE mi.restaurant_id = ?", - (restaurant_id,), - ) - groups_n = cur.fetchone()["n"] - - conn.close() - - print( - f" inserted {nodes_n} menu nodes, {items_n} items, " - f"{groups_n} modifier groups" - ) - print(f"\n open at http://localhost:5001/restaurant/{slug}") - print(f" webapp: http://localhost:5187/r/{slug}") - - -def _insert_items_in_node( - cur: sqlite3.Cursor, - restaurant_id: str, - node_id: str, - items: list[dict], - now: int, -) -> None: - for sort_order, item in enumerate(items): - item_id = new_id() - cur.execute( - """ - INSERT INTO menu_items ( - id, restaurant_id, node_id, name, description, price, - currency, sku, images, dietary, allergens, ingredients, - calories, sort_order, is_available, is_featured, stock, - low_stock_threshold, nostr_event_id, nostr_event_created_at, - extra, time - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - item_id, - restaurant_id, - node_id, - item["name"], - item.get("desc"), - float(item["price"]), - CURRENCY, - None, - json.dumps([]), - json.dumps(item.get("dietary", [])), - json.dumps(item.get("allergens", [])), - json.dumps(item.get("ingredients", [])), - item.get("calories"), - sort_order, - 0 if item.get("available") is False else 1, - 1 if item.get("featured") else 0, - item.get("stock"), - item.get("low_stock_threshold"), - None, - None, - item_extra(), - now, - ), - ) - for group_sort, group in enumerate(item.get("modifier_groups", [])): - group_id = new_id() - cur.execute( - """ - INSERT INTO modifier_groups ( - id, menu_item_id, name, kind, selection, - min_selections, max_selections, sort_order, time - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - group_id, - item_id, - group["name"], - group.get("kind", "required"), - group.get("selection", "one"), - group.get("min", 0), - group.get("max"), - group_sort, - now, - ), - ) - for mod_sort, mod in enumerate(group.get("modifiers", [])): - cur.execute( - """ - INSERT INTO modifiers ( - id, group_id, name, description, price_delta, - is_default, sort_order, time - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - new_id(), - group_id, - mod["name"], - mod.get("desc"), - float(mod.get("delta", 0)), - 1 if mod.get("default") else 0, - mod_sort, - now, - ), - ) - - -def _drop_restaurant(cur: sqlite3.Cursor, restaurant_id: str) -> None: - cur.execute( - """ - DELETE FROM modifiers WHERE group_id IN ( - SELECT mg.id FROM modifier_groups mg - JOIN menu_items mi ON mi.id = mg.menu_item_id - WHERE mi.restaurant_id = ? - ) - """, - (restaurant_id,), - ) - cur.execute( - """ - DELETE FROM modifier_groups WHERE menu_item_id IN ( - SELECT id FROM menu_items WHERE restaurant_id = ? - ) - """, - (restaurant_id,), - ) - cur.execute( - "DELETE FROM availability_windows WHERE menu_item_id IN (" - "SELECT id FROM menu_items WHERE restaurant_id = ?)", - (restaurant_id,), - ) - cur.execute( - "DELETE FROM menu_items WHERE restaurant_id = ?", - (restaurant_id,), - ) - cur.execute( - "DELETE FROM menu_nodes WHERE restaurant_id = ?", - (restaurant_id,), - ) - cur.execute( - "DELETE FROM restaurants WHERE id = ?", - (restaurant_id,), - ) - - -# --------------------------------------------------------------------- # -# main # -# --------------------------------------------------------------------- # - - -def main() -> None: - p = argparse.ArgumentParser( - description=( - "Seed Lightning Cafe (sats-priced demo restaurant) into " - "ext_restaurant.sqlite3" - ) - ) - p.add_argument( - "--wallet", - required=True, - help="LNbits wallet id (operator's wallet)", - ) - p.add_argument( - "--db", - help="Path to ext_restaurant.sqlite3 (auto-detected if omitted)", - ) - p.add_argument( - "--slug", - default="lightning-cafe", - help="URL slug (default: lightning-cafe)", - ) - p.add_argument( - "--force", - action="store_true", - help="Drop existing restaurant with this slug+wallet and recreate", - ) - args = p.parse_args() - - db_path = find_db(args.db) - seed(db_path, args.wallet, args.slug, args.force) - - -if __name__ == "__main__": - main() diff --git a/static/js/kds.js b/static/js/kds.js index f7fd258..ab4f0cc 100644 --- a/static/js/kds.js +++ b/static/js/kds.js @@ -23,9 +23,6 @@ window.app = Vue.createApp({ statusColor(status) { return {paid: 'positive', accepted: 'blue', ready: 'amber'}[status] || 'grey' }, - cookingMode(order) { - return order && order.status === 'accepted' - }, cardClass(order) { // Visually escalate as orders age. >5min = highlight; >15min = alarm. // diff --git a/templates/restaurant/kds.html b/templates/restaurant/kds.html index 8ba3052..0474f28 100644 --- a/templates/restaurant/kds.html +++ b/templates/restaurant/kds.html @@ -37,9 +37,7 @@ - +
@@ -48,7 +46,6 @@