Compare commits
No commits in common. "42746d73214e8f6ea8cc52f68cecfc63b363c5bd" and "dd756ecfc3f993957dd38b251392b1eea21378f2" have entirely different histories.
42746d7321
...
dd756ecfc3
7 changed files with 16 additions and 107 deletions
|
|
@ -15,7 +15,6 @@ the catalog.
|
||||||
| Method | Path | Notes |
|
| Method | Path | Notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `GET` | `/restaurants/{id}` | Restaurant profile |
|
| `GET` | `/restaurants/{id}` | Restaurant profile |
|
||||||
| `GET` | `/restaurants/by-slug/{slug}` | Restaurant profile by URL slug — used by webapps that route on `/r/:slug` and need to resolve to an `id` before any other lookup. 404 when no match |
|
|
||||||
| `GET` | `/restaurants/{id}/menu` | `{restaurant, tree, items}` — the canonical [[menu-tree|menu tree]] (hydrated children + items per node) plus a flat enriched items list with modifier groups + availability windows pre-joined |
|
| `GET` | `/restaurants/{id}/menu` | `{restaurant, tree, items}` — the canonical [[menu-tree|menu tree]] (hydrated children + items per node) plus a flat enriched items list with modifier groups + availability windows pre-joined |
|
||||||
| `GET` | `/menu_items/{id}` | Single item |
|
| `GET` | `/menu_items/{id}` | Single item |
|
||||||
| `GET` | `/menu_nodes/{id}` | Single node row |
|
| `GET` | `/menu_nodes/{id}` | Single node row |
|
||||||
|
|
|
||||||
20
docs/cms.md
20
docs/cms.md
|
|
@ -73,26 +73,6 @@ orange, `>15min` red) and offers one-tap state transitions.
|
||||||
Today the monitor + KDS poll every 5–8 s. SSE / Nostr push is on
|
Today the monitor + KDS poll every 5–8 s. SSE / Nostr push is on
|
||||||
the roadmap.
|
the roadmap.
|
||||||
|
|
||||||
### Dark-mode color discipline
|
|
||||||
|
|
||||||
Quasar's pale `bg-{color}-1` utility classes (e.g. `bg-orange-1`,
|
|
||||||
`bg-red-1`, `bg-amber-1`) pair fine with the default light theme
|
|
||||||
but render **white-on-cream** under LNbits' dark theme — the
|
|
||||||
q-card otherwise inherits the body's light text color. The KDS
|
|
||||||
cards pin a dark text class alongside every pale background so
|
|
||||||
the card stays legible regardless of theme:
|
|
||||||
|
|
||||||
```js
|
|
||||||
if (order.status === 'ready') return 'bg-amber-1 text-grey-9'
|
|
||||||
if (ageSec > 900) return 'bg-red-1 text-grey-9'
|
|
||||||
if (ageSec > 300) return 'bg-orange-1 text-grey-9'
|
|
||||||
return '' // theme-default branch keeps q-card's own text color
|
|
||||||
```
|
|
||||||
|
|
||||||
Any future surface that ages / escalates with `bg-{color}-1` must
|
|
||||||
do the same. Never assume a pale background "just works" on dark
|
|
||||||
theme.
|
|
||||||
|
|
||||||
## Settings
|
## Settings
|
||||||
|
|
||||||
`settings.html` saves restaurant fields via
|
`settings.html` saves restaurant fields via
|
||||||
|
|
|
||||||
|
|
@ -37,22 +37,14 @@ States and their meaning:
|
||||||
2. Re-prices every line item against the live menu (modifier ids
|
2. Re-prices every line item against the live menu (modifier ids
|
||||||
are matched server-side; the customer's claimed `price_delta`
|
are matched server-side; the customer's claimed `price_delta`
|
||||||
values are ignored).
|
values are ignored).
|
||||||
3. Converts each item's `price` from its declared `currency` to
|
3. Sums `subtotal_msat`, applies `tax_rate`, adds `tip_msat` →
|
||||||
msat. For sat-denominated items (`currency` ∈ `{sat, sats,
|
|
||||||
satoshi}`) this is a flat `× 1000`. For fiat (`USD`, `GTQ`, …)
|
|
||||||
it calls `lnbits.utils.exchange_rates.fiat_amount_as_satoshis`
|
|
||||||
to look up the live rate, then `× 1000`. The conversion lives
|
|
||||||
in `services._price_to_msat` so the rate lookup is the same path
|
|
||||||
the quote endpoint uses — a customer's preview and the recorded
|
|
||||||
`order.total_msat` cannot drift apart between request and place.
|
|
||||||
4. Sums `subtotal_msat`, applies `tax_rate`, adds `tip_msat` →
|
|
||||||
`total_msat`.
|
`total_msat`.
|
||||||
5. For Lightning / internal: calls
|
4. For Lightning / internal: calls
|
||||||
`lnbits.core.services.create_invoice` with
|
`lnbits.core.services.create_invoice` with
|
||||||
`extra={"tag": "restaurant", "restaurant_id": ...}`.
|
`extra={"tag": "restaurant", "restaurant_id": ...}`.
|
||||||
6. Persists the order with `id = payment_hash` so the listener can
|
5. Persists the order with `id = payment_hash` so the listener can
|
||||||
look it up cheaply, plus one `order_items` row per line.
|
look it up cheaply, plus one `order_items` row per line.
|
||||||
7. For cash: `payment_method = "cash"` skips invoice creation and
|
6. For cash: `payment_method = "cash"` skips invoice creation and
|
||||||
marks the order `accepted` directly.
|
marks the order `accepted` directly.
|
||||||
|
|
||||||
Returns `(Order, OrderInvoice | None)`. The webapp pays the bolt11.
|
Returns `(Order, OrderInvoice | None)`. The webapp pays the bolt11.
|
||||||
|
|
|
||||||
|
|
@ -9,17 +9,7 @@ of restaurants, especially the multi-restaurant cart pattern.
|
||||||
A webapp can either talk to one restaurant directly or aggregate
|
A webapp can either talk to one restaurant directly or aggregate
|
||||||
many. There's no central directory inside this extension — grouping
|
many. There's no central directory inside this extension — grouping
|
||||||
("festival", "collective space", "food court") is **emergent** via
|
("festival", "collective space", "food court") is **emergent** via
|
||||||
NIP-51 list events curated by whoever runs the venue.
|
NIP-51 list events curated by whoever runs the venue:
|
||||||
|
|
||||||
For the **single-venue** case, a webapp that routes on a URL slug
|
|
||||||
(`/r/big-jays-bustaurant`) resolves the slug → restaurant via the
|
|
||||||
public `GET /restaurants/by-slug/{slug}` endpoint
|
|
||||||
([[api-reference]]) and proceeds with that one `id` for menu reads
|
|
||||||
and order placement. Slug is just a URL nicety — internally
|
|
||||||
everything continues to key on the restaurant `id`.
|
|
||||||
|
|
||||||
For the **aggregator** case (multiple restaurants in one cart), the
|
|
||||||
webapp consumes a curated NIP-51 list event:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
{
|
{
|
||||||
|
|
|
||||||
45
services.py
45
services.py
|
|
@ -25,7 +25,6 @@ from loguru import logger
|
||||||
|
|
||||||
from lnbits.core.services import create_invoice
|
from lnbits.core.services import create_invoice
|
||||||
from lnbits.helpers import urlsafe_short_hash
|
from lnbits.helpers import urlsafe_short_hash
|
||||||
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
|
|
||||||
|
|
||||||
from .crud import (
|
from .crud import (
|
||||||
create_order,
|
create_order,
|
||||||
|
|
@ -56,33 +55,9 @@ from .models import (
|
||||||
# --------------------------------------------------------------------- #
|
# --------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
_SAT_ALIASES = {"sat", "sats", "satoshi", "satoshis", "msat", "msats"}
|
def _to_msat(amount: float) -> int:
|
||||||
|
"""Convert a sat amount (possibly fractional) to integer msat."""
|
||||||
|
|
||||||
def _is_sat_currency(currency: str | None) -> bool:
|
|
||||||
return (currency or "sat").strip().lower() in _SAT_ALIASES
|
|
||||||
|
|
||||||
|
|
||||||
async def _price_to_msat(amount: float, currency: str | None) -> int:
|
|
||||||
"""
|
|
||||||
Convert a menu-item-currency amount to integer **msat**.
|
|
||||||
|
|
||||||
- For native sat-denominated prices (currency in
|
|
||||||
`_SAT_ALIASES`) this is a flat ×1000.
|
|
||||||
- For any other ISO-ish currency code (GTQ, USD, EUR, BRL, …)
|
|
||||||
we round-trip through LNbits's `fiat_amount_as_satoshis`
|
|
||||||
which queries the same exchange-rate pool the rest of LNbits
|
|
||||||
uses, then multiply by 1000.
|
|
||||||
|
|
||||||
Side-effect-free helper; safe to call multiple times per
|
|
||||||
request (rates are LNbits-cached internally).
|
|
||||||
"""
|
|
||||||
if amount == 0:
|
|
||||||
return 0
|
|
||||||
if _is_sat_currency(currency):
|
|
||||||
return int(round(amount * 1000))
|
return int(round(amount * 1000))
|
||||||
sats = await fiat_amount_as_satoshis(amount, (currency or "").upper())
|
|
||||||
return int(round(sats * 1000))
|
|
||||||
|
|
||||||
|
|
||||||
async def _price_line_item(line: CreateOrderItem) -> tuple[OrderItemRow, int]:
|
async def _price_line_item(line: CreateOrderItem) -> tuple[OrderItemRow, int]:
|
||||||
|
|
@ -106,9 +81,7 @@ async def _price_line_item(line: CreateOrderItem) -> tuple[OrderItemRow, int]:
|
||||||
if item.stock is not None and item.stock < line.quantity:
|
if item.stock is not None and item.stock < line.quantity:
|
||||||
raise ValueError(f"Menu item {item.name!r} is out of stock")
|
raise ValueError(f"Menu item {item.name!r} is out of stock")
|
||||||
|
|
||||||
# Resolve & price modifiers against canonical DB rows. Modifier
|
# Resolve & price modifiers against canonical DB rows.
|
||||||
# `price_delta` is denominated in the same currency as the parent
|
|
||||||
# item (we don't carry a per-modifier currency).
|
|
||||||
resolved: list[SelectedModifier] = []
|
resolved: list[SelectedModifier] = []
|
||||||
delta_msat_each = 0
|
delta_msat_each = 0
|
||||||
requested_ids = {m.modifier_id for m in line.selected_modifiers if m.modifier_id}
|
requested_ids = {m.modifier_id for m in line.selected_modifiers if m.modifier_id}
|
||||||
|
|
@ -128,13 +101,9 @@ async def _price_line_item(line: CreateOrderItem) -> tuple[OrderItemRow, int]:
|
||||||
price_delta=mod.price_delta,
|
price_delta=mod.price_delta,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
delta_msat_each += await _price_to_msat(
|
delta_msat_each += _to_msat(mod.price_delta)
|
||||||
mod.price_delta, item.currency
|
|
||||||
)
|
|
||||||
|
|
||||||
unit_price_msat = (
|
unit_price_msat = _to_msat(item.price) + delta_msat_each
|
||||||
await _price_to_msat(item.price, item.currency)
|
|
||||||
) + delta_msat_each
|
|
||||||
line_total_msat = unit_price_msat * line.quantity
|
line_total_msat = unit_price_msat * line.quantity
|
||||||
|
|
||||||
row = OrderItemRow(
|
row = OrderItemRow(
|
||||||
|
|
@ -365,8 +334,8 @@ async def quote_balance_required(items: list[CreateOrderItem]) -> int:
|
||||||
item = await get_menu_item(line.menu_item_id)
|
item = await get_menu_item(line.menu_item_id)
|
||||||
if not item:
|
if not item:
|
||||||
continue
|
continue
|
||||||
unit = await _price_to_msat(item.price, item.currency)
|
unit = _to_msat(item.price)
|
||||||
for sm in line.selected_modifiers:
|
for sm in line.selected_modifiers:
|
||||||
unit += await _price_to_msat(sm.price_delta or 0, item.currency)
|
unit += _to_msat(sm.price_delta or 0)
|
||||||
total += unit * line.quantity
|
total += unit * line.quantity
|
||||||
return total
|
return total
|
||||||
|
|
|
||||||
|
|
@ -25,16 +25,10 @@ window.app = Vue.createApp({
|
||||||
},
|
},
|
||||||
cardClass(order) {
|
cardClass(order) {
|
||||||
// Visually escalate as orders age. >5min = highlight; >15min = alarm.
|
// Visually escalate as orders age. >5min = highlight; >15min = alarm.
|
||||||
//
|
|
||||||
// Pair every pale `bg-{color}-1` with an explicit dark text color
|
|
||||||
// — otherwise on LNbits dark mode the q-card inherits light text
|
|
||||||
// and renders white-on-cream, which is unreadable. The non-
|
|
||||||
// highlighted branch (default theme) returns '' so the q-card
|
|
||||||
// keeps its theme-aware defaults.
|
|
||||||
const ageSec = (Date.now() - new Date(order.time).getTime()) / 1000
|
const ageSec = (Date.now() - new Date(order.time).getTime()) / 1000
|
||||||
if (order.status === 'ready') return 'bg-amber-1 text-grey-9'
|
if (order.status === 'ready') return 'bg-amber-1'
|
||||||
if (ageSec > 900) return 'bg-red-1 text-grey-9'
|
if (ageSec > 900) return 'bg-red-1'
|
||||||
if (ageSec > 300) return 'bg-orange-1 text-grey-9'
|
if (ageSec > 300) return 'bg-orange-1'
|
||||||
return ''
|
return ''
|
||||||
},
|
},
|
||||||
async fetchActive() {
|
async fetchActive() {
|
||||||
|
|
|
||||||
15
views_api.py
15
views_api.py
|
|
@ -59,7 +59,6 @@ from .crud import (
|
||||||
get_print_job,
|
get_print_job,
|
||||||
get_print_jobs,
|
get_print_jobs,
|
||||||
get_restaurant,
|
get_restaurant,
|
||||||
get_restaurant_by_slug,
|
|
||||||
get_restaurants,
|
get_restaurants,
|
||||||
get_settings,
|
get_settings,
|
||||||
move_menu_node,
|
move_menu_node,
|
||||||
|
|
@ -249,20 +248,6 @@ async def api_list_restaurants(
|
||||||
return await get_restaurants(wallet_ids)
|
return await get_restaurants(wallet_ids)
|
||||||
|
|
||||||
|
|
||||||
@restaurant_api_router.get("/api/v1/restaurants/by-slug/{slug}")
|
|
||||||
async def api_get_restaurant_by_slug(slug: str) -> Restaurant:
|
|
||||||
"""Public — used by the customer webapp to resolve a URL slug
|
|
||||||
(e.g. /r/big-jays-bustaurant) to a restaurant. Mirrors
|
|
||||||
api_get_restaurant; declared *before* the bare-id route so the
|
|
||||||
static prefix wins the path match in FastAPI's router."""
|
|
||||||
restaurant = await get_restaurant_by_slug(slug)
|
|
||||||
if not restaurant:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.NOT_FOUND, detail="Restaurant not found."
|
|
||||||
)
|
|
||||||
return restaurant
|
|
||||||
|
|
||||||
|
|
||||||
@restaurant_api_router.get("/api/v1/restaurants/{restaurant_id}")
|
@restaurant_api_router.get("/api/v1/restaurants/{restaurant_id}")
|
||||||
async def api_get_restaurant(restaurant_id: str) -> Restaurant:
|
async def api_get_restaurant(restaurant_id: str) -> Restaurant:
|
||||||
"""Public — used by the webapp to fetch profile metadata."""
|
"""Public — used by the webapp to fetch profile metadata."""
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue