Compare commits

..

1 commit

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
6 changed files with 89 additions and 68 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` filtered by status. The KDS view escalates color by age (`>5min`
orange, `>15min` red) and offers one-tap state transitions. 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 Today the monitor + KDS poll every 58 s. SSE / Nostr push is on
the roadmap. the roadmap.

View file

@ -54,20 +54,15 @@ the new tag set lands.
Each restaurant has an effective Nostr identity: Each restaurant has an effective Nostr identity:
- If `restaurant.nostr_pubkey` is set, that's a per-restaurant - If `restaurant.nostr_pubkey` is set, that's a per-restaurant
identity (a per-restaurant signer/vault is **out of scope** identity (storage of the matching secret key is **out of scope**
in v1; the column is informational until that's wired up). in v1; the column is informational until a vault is wired up).
- Otherwise, the wallet owner's signer is resolved via - Otherwise, the LNbits Account keypair of the wallet owner is
`lnbits.core.signers.resolve_for_wallet` (wallet → account → used (`account.pubkey` / `account.prvkey`).
signer, with a `can_sign()` gate). The signer backend
(`LocalSigner` / `RemoteBunkerSigner` / `ClientSideOnlySigner`)
is transparent to this extension.
`nostr_publisher.publish_event(client, event, signer)` hands the `nostr_publisher.publish_event(client, event, prvkey)` signs in
unsigned event dict to the resolved `NostrSigner`, which fills in place with `coincurve.PrivateKey.sign_schnorr` (BIP-340) and ships
`id` / `pubkey` / `sig` per NIP-01 / BIP-340 and ships to the relay to the relay via the [[architecture|nostrclient extension's]]
via the [[architecture|nostrclient extension's]] internal WebSocket. internal WebSocket.
The extension itself does no direct Schnorr signing — that lives in
the signer abstraction.
## What gets listened for ## What gets listened for

View file

@ -15,13 +15,11 @@ itself has no awareness of festivals.
Signing Signing
------- -------
Events are signed via the core `NostrSigner` abstraction Events are signed with the *restaurant's* keypair if `restaurant.nostr_pubkey`
(LocalSigner / RemoteBunkerSigner / ClientSideOnlySigner). The caller is set, otherwise with the LNbits Account keypair of the wallet's owner.
resolves a signer for the restaurant's effective Nostr identity and This lets a single LNbits account host multiple restaurants under
hands it to `publish_event` the signer fills in `id`/`pubkey`/`sig` distinct Nostr identities, while keeping a sane default for owners
per NIP-01. This lets a single LNbits account host multiple who don't care about identity separation.
restaurants under distinct Nostr identities while keeping a sane
default for owners who don't care about identity separation.
""" """
import json import json
@ -29,7 +27,7 @@ import re
import time import time
from typing import Optional from typing import Optional
from lnbits.core.signers import NostrSigner import coincurve
from loguru import logger from loguru import logger
from .models import MenuItem, Restaurant from .models import MenuItem, Restaurant
@ -204,33 +202,25 @@ def build_delete_event(
# --------------------------------------------------------------------- # # --------------------------------------------------------------------- #
def sign_nostr_event(nostr_event: NostrEvent, private_key_hex: str) -> None:
"""Schnorr-sign a NostrEvent in place (BIP-340)."""
privkey = coincurve.PrivateKey(bytes.fromhex(private_key_hex))
sig = privkey.sign_schnorr(bytes.fromhex(nostr_event.id))
nostr_event.sig = sig.hex()
async def publish_event( async def publish_event(
nostr_client, nostr_client,
nostr_event: NostrEvent, nostr_event: NostrEvent,
signer: NostrSigner, private_key_hex: str,
) -> Optional[NostrEvent]: ) -> Optional[NostrEvent]:
"""Sign and publish a built NostrEvent. Returns the event on success """Sign and publish a built NostrEvent. Returns the event on success
so callers can persist its id + created_at, or None on failure. so callers can persist its id + created_at, or None on failure."""
The unsigned event dict (`kind`/`created_at`/`tags`/`content`) is
handed to the signer, which fills in `id`/`pubkey`/`sig` same
NIP-01 serialization rules as our local `event_id` property uses,
so the returned id matches what we'd have computed locally. The
signer backend (LocalSigner / RemoteBunkerSigner) is transparent."""
if not nostr_client: if not nostr_client:
logger.debug("[RESTAURANT] No NostrClient; skipping publish") logger.debug("[RESTAURANT] No NostrClient; skipping publish")
return None return None
try: try:
unsigned = { sign_nostr_event(nostr_event, private_key_hex)
"kind": nostr_event.kind,
"created_at": nostr_event.created_at,
"tags": nostr_event.tags,
"content": nostr_event.content,
}
signed = await signer.sign_event(unsigned)
nostr_event.id = signed["id"]
nostr_event.pubkey = signed["pubkey"]
nostr_event.sig = signed["sig"]
await nostr_client.publish_nostr_event(nostr_event) await nostr_client.publish_nostr_event(nostr_event)
logger.info( logger.info(
f"[RESTAURANT] Published kind {nostr_event.kind} " f"[RESTAURANT] Published kind {nostr_event.kind} "

View file

@ -23,6 +23,9 @@ window.app = Vue.createApp({
statusColor(status) { statusColor(status) {
return {paid: 'positive', accepted: 'blue', ready: 'amber'}[status] || 'grey' return {paid: 'positive', accepted: 'blue', ready: 'amber'}[status] || 'grey'
}, },
cookingMode(order) {
return order && order.status === 'accepted'
},
cardClass(order) { cardClass(order) {
// Visually escalate as orders age. >5min = highlight; >15min = alarm. // Visually escalate as orders age. >5min = highlight; >15min = alarm.
// //

View file

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

View file

@ -22,8 +22,9 @@ from pydantic import BaseModel
from starlette.exceptions import HTTPException from starlette.exceptions import HTTPException
from lnbits.core.crud import get_user from lnbits.core.crud import get_user
from lnbits.core.crud.users import get_account
from lnbits.core.crud.wallets import get_wallet
from lnbits.core.models import Account, WalletTypeInfo from lnbits.core.models import Account, WalletTypeInfo
from lnbits.core.signers import NostrSigner, resolve_for_wallet
from lnbits.decorators import ( from lnbits.decorators import (
check_admin, check_admin,
check_user_exists, check_user_exists,
@ -107,47 +108,53 @@ restaurant_api_router = APIRouter()
# --------------------------------------------------------------------- # # --------------------------------------------------------------------- #
async def _resolve_signer( async def _resolve_signing_keypair(
restaurant: Restaurant, restaurant: Restaurant,
) -> Optional[NostrSigner]: ) -> Optional[tuple[str, str]]:
""" """
Resolve a `NostrSigner` for signing events on behalf of a restaurant. Resolve the (pubkey, prvkey) pair for signing Nostr events on behalf
of a restaurant.
Order of precedence: Order of precedence:
1. restaurant.nostr_pubkey is set per-restaurant identity. 1. restaurant.nostr_pubkey is set use a per-restaurant key.
(A per-restaurant signer/vault is out of scope here; until (Storage of the corresponding prvkey is intentionally out of
that lands this branch is a no-op. Returns None.) scope here; for now this branch is a no-op until we ship a
2. Otherwise fall back to the wallet owner's signer via secret-management approach. Returns None.)
`resolve_for_wallet` (wallet account signer with a 2. Otherwise fall back to the LNbits Account keypair of the
can_sign-check; soft-fails to None on missing wallet, missing wallet owner.
account, unclassified row, or ClientSideOnlySigner accounts
where the server has no signing authority).
""" """
if restaurant.nostr_pubkey: if restaurant.nostr_pubkey:
# TODO: per-restaurant signer / secret vault. # TODO: per-restaurant secret key vault.
return None return None
return await resolve_for_wallet(restaurant.wallet) wallet_obj = await get_wallet(restaurant.wallet)
if not wallet_obj:
return None
account = await get_account(wallet_obj.user)
if not account or not account.pubkey or not account.prvkey:
return None
return account.pubkey, account.prvkey
async def _publish_restaurant(restaurant: Restaurant) -> None: async def _publish_restaurant(restaurant: Restaurant) -> None:
settings = await get_settings() settings = await get_settings()
if not settings.nostr_publish_enabled: if not settings.nostr_publish_enabled:
return return
signer = await _resolve_signer(restaurant) keypair = await _resolve_signing_keypair(restaurant)
if signer is None: if not keypair:
return return
pubkey, prvkey = keypair
from . import nostr_client from . import nostr_client
event = build_restaurant_metadata_event(restaurant, signer.pubkey) event = build_restaurant_metadata_event(restaurant, pubkey)
published = await publish_event(nostr_client, event, signer) published = await publish_event(nostr_client, event, prvkey)
if published: if published:
restaurant.nostr_event_id = published.id restaurant.nostr_event_id = published.id
restaurant.nostr_event_created_at = published.created_at restaurant.nostr_event_created_at = published.created_at
if not restaurant.nostr_pubkey: if not restaurant.nostr_pubkey:
# Echo back the resolved pubkey so the row carries it for # Echo back the resolved pubkey so the row carries it for
# discovery (e.g. webapp follows this pubkey). # discovery (e.g. webapp follows this pubkey).
restaurant.nostr_pubkey = signer.pubkey restaurant.nostr_pubkey = pubkey
await update_restaurant(restaurant) await update_restaurant(restaurant)
@ -181,17 +188,18 @@ async def _publish_menu_item(item: MenuItem) -> None:
restaurant = await get_restaurant(item.restaurant_id) restaurant = await get_restaurant(item.restaurant_id)
if not restaurant: if not restaurant:
return return
signer = await _resolve_signer(restaurant) keypair = await _resolve_signing_keypair(restaurant)
if signer is None: if not keypair:
return return
pubkey, prvkey = keypair
from . import nostr_client from . import nostr_client
ancestors = await _ancestor_names_for_node(item.node_id) ancestors = await _ancestor_names_for_node(item.node_id)
event = build_menu_item_event( event = build_menu_item_event(
item, restaurant, signer.pubkey, ancestor_names=ancestors item, restaurant, pubkey, ancestor_names=ancestors
) )
published = await publish_event(nostr_client, event, signer) published = await publish_event(nostr_client, event, prvkey)
if published: if published:
item.nostr_event_id = published.id item.nostr_event_id = published.id
item.nostr_event_created_at = published.created_at item.nostr_event_created_at = published.created_at
@ -205,14 +213,15 @@ async def _publish_menu_item_delete(item: MenuItem) -> None:
restaurant = await get_restaurant(item.restaurant_id) restaurant = await get_restaurant(item.restaurant_id)
if not restaurant: if not restaurant:
return return
signer = await _resolve_signer(restaurant) keypair = await _resolve_signing_keypair(restaurant)
if signer is None: if not keypair:
return return
pubkey, prvkey = keypair
from . import nostr_client from . import nostr_client
event = build_delete_event(30402, item.id, signer.pubkey, "Menu item removed") event = build_delete_event(30402, item.id, pubkey, "Menu item removed")
await publish_event(nostr_client, event, signer) await publish_event(nostr_client, event, prvkey)
def _require_owner(restaurant: Restaurant, wallet: WalletTypeInfo) -> None: def _require_owner(restaurant: Restaurant, wallet: WalletTypeInfo) -> None: