Prerequisite for the customer webapp module (aiolabs/webapp, branch feat/restaurant-bundle): the webapp's /r/:slug route needs to resolve a slug to a Restaurant payload without an admin key. crud.get_restaurant_by_slug already exists (used by the server- rendered CMS routes in views.py); just expose it as a public REST endpoint. Mirrors api_get_restaurant by id and is declared before the bare-id route so the static prefix wins FastAPI's path match. Verified live against seeded 'Big Jay's Bustaurant': GET /restaurant/api/v1/restaurants/by-slug/big-jays-bustaurant -> 200 with the Restaurant payload.
137 lines
4.4 KiB
Markdown
137 lines
4.4 KiB
Markdown
# Webapp integration
|
|
|
|
The customer-facing UI lives in `~/dev/webapp` (the AIO webapp).
|
|
This note describes the contract between the webapp and any number
|
|
of restaurants, especially the multi-restaurant cart pattern.
|
|
|
|
## Discovery
|
|
|
|
A webapp can either talk to one restaurant directly or aggregate
|
|
many. There's no central directory inside this extension — grouping
|
|
("festival", "collective space", "food court") is **emergent** via
|
|
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:
|
|
|
|
```
|
|
{
|
|
"kind": 30000, // NIP-51 follow set / generic list
|
|
"tags": [
|
|
["d", "festival-2026"],
|
|
["title", "Atitlan Bitcoin Festival 2026"],
|
|
["p", "<restaurant1-pubkey>"],
|
|
["p", "<restaurant2-pubkey>"],
|
|
["p", "<restaurant3-pubkey>"]
|
|
],
|
|
...
|
|
}
|
|
```
|
|
|
|
The webapp:
|
|
|
|
1. Resolves the list event for the festival / venue.
|
|
2. Fans out to fetch each restaurant's profile (kind 0) and menu
|
|
(kind 30402) over Nostr, **or** falls back to
|
|
`GET /restaurant/api/v1/restaurants/{id}/menu` over REST.
|
|
3. Subscribes to each restaurant pubkey for live updates.
|
|
|
|
No central wallet, no central database, no per-restaurant
|
|
onboarding flow specific to "joining a festival". Restaurants
|
|
exist; festivals are curated views.
|
|
|
|
## Multi-restaurant cart
|
|
|
|
A single customer can put items from multiple restaurants into one
|
|
cart. On checkout, the webapp issues **one order per restaurant**;
|
|
each restaurant returns its own bolt11; the customer pays N
|
|
invoices. There is no payment splitter and no umbrella order on
|
|
the server side.
|
|
|
|
The pattern, in pseudocode (this lives in the webapp, not the
|
|
extension):
|
|
|
|
```js
|
|
// 1. Group cart by restaurant.
|
|
const cartByRestaurant = groupBy(cart.lines, line => line.restaurant_id)
|
|
|
|
// 2. Pre-flight quote per restaurant.
|
|
const quotes = await Promise.all(
|
|
Object.entries(cartByRestaurant).map(([rid, lines]) =>
|
|
fetch(`/restaurant/api/v1/orders/quote`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(lines.map(l => ({
|
|
menu_item_id: l.id,
|
|
quantity: l.qty,
|
|
selected_modifiers: l.modifiers
|
|
})))
|
|
}).then(r => r.json()).then(j => ({rid, lines, msat: j.required_msat}))
|
|
)
|
|
)
|
|
|
|
// 3. Fail fast on insufficient balance.
|
|
const totalMsat = quotes.reduce((s, q) => s + q.msat, 0)
|
|
if (walletBalanceMsat < totalMsat) {
|
|
return showInsufficientBalanceError()
|
|
}
|
|
|
|
// 4. Open one order per restaurant.
|
|
const orders = []
|
|
for (const q of quotes) {
|
|
const res = await fetch(`/restaurant/api/v1/orders`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
restaurant_id: q.rid,
|
|
items: q.lines.map(asCreateOrderItem),
|
|
customer_pubkey: window.user.nostrPubkey,
|
|
parent_order_ref: cart.id,
|
|
tip_msat: q.tipMsat,
|
|
payment_method: 'lightning'
|
|
})
|
|
}).then(r => r.json())
|
|
orders.push(res)
|
|
}
|
|
|
|
// 5. Pay each bolt11 in sequence.
|
|
for (const o of orders) {
|
|
await payInvoice(o.invoice.bolt11)
|
|
}
|
|
```
|
|
|
|
## Atomicity
|
|
|
|
Sequential per-restaurant payments are best-effort, not atomic. In
|
|
practice — for internal LNbits transfers — payment failures are
|
|
rare and usually transient. If one of N invoices does fail mid-flow:
|
|
|
|
- The successful orders are already paid; their restaurants will
|
|
print and prepare them.
|
|
- The failed order has issued an invoice that simply expires.
|
|
- The customer settles the gap in person at the failing restaurant
|
|
(cash, retry, or refund of the others).
|
|
|
|
The pre-flight `/orders/quote` step plus a balance check before
|
|
opening any invoice eliminates the most common cause (insufficient
|
|
funds). HODL invoices for true atomicity are on the roadmap and
|
|
would replace step 4 once they ship.
|
|
|
|
## `parent_order_ref`
|
|
|
|
The webapp may pass an opaque `parent_order_ref` (e.g. its own
|
|
cart id) when posting `/orders`. The extension stores it on the
|
|
`orders` row but never reads it — useful only for the webapp to
|
|
correlate its umbrella cart with the per-restaurant orders later.
|
|
|
|
## See also
|
|
|
|
- [[architecture]]
|
|
- [[order-flow]]
|
|
- [[api-reference]]
|
|
- [[nostr-layer]]
|