Compare commits

...
Sign in to create a new pull request.

2 commits

Author SHA1 Message Date
4739ec0127 feat(cms): KDS cooking-mode bumps item + modifier visibility
When a card hits `accepted` the items section bumps base font to
1.25rem and modifier/note lines to 1.15rem + medium weight; the
muted grey on modifiers drops to inherited color. All via Vue
`:style` bindings — class-based CSS rules lose to lnbits' upstream
`!important` on Quasar typography utilities (even with our own
`!important`), so inline wins without an arms race.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 07:01:49 +02:00
cbbb3c743b feat(scripts): add seed_lightning_cafe.py — sats-priced demo restaurant
The canonical demo seed for the restaurant extension + webapp
bundle. Drops a "Lightning Cafe" restaurant at slug=lightning-cafe
with currency='sat', exercising every feature surface so it
doubles as a visual smoke-test fixture:

- Menu tree (depth 2: Drinks→{Coffee,Cold,Tea}, Food→{Breakfast,
  Lunch,Snacks})
- 19 items with descriptions, dietary tags, allergens, ingredients,
  calorie counts
- Featured items (is_featured=1) — Latte, Avocado Toast, Lightning
  Burger
- Sold-out item (is_available=0) — Daily Special
- Low-stock item (stock=3, low_stock_threshold=5) — Fresh OJ
- Multiple modifier groups per item showing all three patterns:
    required/one  (radio, e.g. Milk choice)
    required/many (limited multi, e.g. Burger extras max 4)
    optional/many (free multi, e.g. Avocado Toast toppings)
- Modifier price deltas in sats (+50 for oat milk, +200 for egg, …)

The /var/lib/lnbits/data path is in the auto-detect list so the
script Just Works on the standard NixOS lnbits service layout.

Usage:
  python3 scripts/seed_lightning_cafe.py --wallet <wallet_id> --force

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:21:41 +02:00
4 changed files with 854 additions and 1 deletions

View file

@ -70,6 +70,26 @@ 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 58 s. SSE / Nostr push is on
the roadmap.

View file

@ -0,0 +1,826 @@
#!/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 <wallet_id> \\
[--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()

View file

@ -23,6 +23,9 @@ 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.
//

View file

@ -37,7 +37,9 @@
</div>
</q-card-section>
<q-separator></q-separator>
<q-card-section style="font-size: 1rem">
<q-card-section
:style="{'font-size': cookingMode(order) ? '1.25rem' : '1rem'}"
>
<div v-for="line in order._items || []" :key="line.id">
<div>
<strong v-text="line.quantity + 'x'"></strong>
@ -46,6 +48,7 @@
<div
v-if="line.selected_modifiers && line.selected_modifiers.length"
class="text-caption text-grey-7 q-pl-md"
:style="cookingMode(order) ? 'font-size: 1.15rem; font-weight: 500; color: inherit' : ''"
>
<span
v-for="(m, i) in line.selected_modifiers"
@ -58,6 +61,7 @@
<div
v-if="line.note"
class="text-caption text-amber-9 q-pl-md"
:style="cookingMode(order) ? 'font-size: 1.15rem; font-weight: 500' : ''"
>
<q-icon name="info" size="xs"></q-icon>
<span v-text="line.note"></span>