feat(cms): Vue 3 + Quasar 2 UMD CMS templates

LNbits convention: extends base.html, declares window.app =
Vue.createApp({mixins: [windowMixin], data, methods, created}); the
LNbits init-app.js loads after extension scripts and finishes the
mount with app.use(Quasar) + app.mount('#vue').

Pages
- index.html      restaurant list / dashboard with create dialog;
                  scoped to the logged-in user's wallets.
- menu.html       category sidebar + items grid; full item dialog
                  with price/currency/images/dietary/allergens/
                  ingredients/calories/stock/availability/featured.
                  Modifier groups managed in a separate dialog
                  with required|optional + one|many semantics.
- orders.html     filterable q-table with status colors and inline
                  state-machine actions (accept/ready/complete/
                  cancel). Polls every 8s.
- kds.html        kitchen display: card-per-order, items + selected
                  modifiers + notes, age-based color escalation
                  (>5min orange, >15min red), polls every 5s. The
                  poll loop is a stand-in until SSE/Nostr push
                  lands.
- settings.html   restaurant profile editor + delete + per-instance
                  ext settings panel (Nostr publish toggle, auto-
                  accept, invoice expiry).

Static
- js/api.js       single REST client (LNbits.api.request wrapper)
                  used by all pages.
- js/index.js     dashboard logic.
- js/menu.js      menu CRUD.
- js/orders.js    order monitor.
- js/kds.js       kitchen display.
- js/settings.js  settings persistence.

Customer kiosk UI lives in ~/dev/webapp; this extension only ships
the operator console.
This commit is contained in:
Padreug 2026-04-29 23:49:56 +02:00
commit 3382462af4
11 changed files with 1576 additions and 0 deletions

90
static/js/api.js Normal file
View file

@ -0,0 +1,90 @@
/*
* Thin REST client for the restaurant extension CMS.
*
* Exposes window.RestaurantAPI with one method per resource.
* Every call is gated by the calling wallet's admin or invoice key
* pulled from g.user.wallets[0] (or a wallet passed in explicitly).
*/
;(function () {
const baseUrl = '/restaurant/api/v1'
function call(adminkey, method, path, body) {
return LNbits.api.request(method, baseUrl + path, adminkey, body)
}
window.RestaurantAPI = {
// Restaurants
listRestaurants: (key, allWallets = false) =>
call(key, 'GET', `/restaurants?all_wallets=${allWallets}`),
getRestaurant: (id) => call(null, 'GET', `/restaurants/${id}`),
createRestaurant: (key, data) => call(key, 'POST', '/restaurants', data),
updateRestaurant: (key, id, data) =>
call(key, 'PUT', `/restaurants/${id}`, data),
deleteRestaurant: (key, id) =>
call(key, 'DELETE', `/restaurants/${id}`),
// Categories
listCategories: (id) => call(null, 'GET', `/restaurants/${id}/categories`),
createCategory: (key, data) => call(key, 'POST', '/categories', data),
deleteCategory: (key, id) => call(key, 'DELETE', `/categories/${id}`),
// Subcategories
listSubcategories: (catId) =>
call(null, 'GET', `/categories/${catId}/subcategories`),
createSubcategory: (key, data) => call(key, 'POST', '/subcategories', data),
deleteSubcategory: (key, id) => call(key, 'DELETE', `/subcategories/${id}`),
// Menu items
getMenu: (id) => call(null, 'GET', `/restaurants/${id}/menu`),
getMenuItem: (id) => call(null, 'GET', `/menu_items/${id}`),
createMenuItem: (key, data) => call(key, 'POST', '/menu_items', data),
updateMenuItem: (key, id, data) =>
call(key, 'PUT', `/menu_items/${id}`, data),
deleteMenuItem: (key, id) => call(key, 'DELETE', `/menu_items/${id}`),
// Modifier groups + modifiers
listModifierGroups: (itemId) =>
call(null, 'GET', `/menu_items/${itemId}/modifier_groups`),
createModifierGroup: (key, data) =>
call(key, 'POST', '/modifier_groups', data),
deleteModifierGroup: (key, id) =>
call(key, 'DELETE', `/modifier_groups/${id}`),
listModifiers: (groupId) =>
call(null, 'GET', `/modifier_groups/${groupId}/modifiers`),
createModifier: (key, data) => call(key, 'POST', '/modifiers', data),
deleteModifier: (key, id) => call(key, 'DELETE', `/modifiers/${id}`),
// Availability windows
listAvailability: (itemId) =>
call(null, 'GET', `/menu_items/${itemId}/availability_windows`),
createAvailability: (key, data) =>
call(key, 'POST', '/availability_windows', data),
deleteAvailability: (key, id) =>
call(key, 'DELETE', `/availability_windows/${id}`),
// Orders
listOrders: (key, restaurantId, statuses, limit = 200) => {
const qs = new URLSearchParams({limit})
;(statuses || []).forEach((s) => qs.append('statuses', s))
return call(key, 'GET', `/restaurants/${restaurantId}/orders?${qs}`)
},
getOrder: (id) => call(null, 'GET', `/orders/${id}`),
placeOrder: (data) => call(null, 'POST', '/orders', data),
quoteOrder: (items) => call(null, 'POST', '/orders/quote', items),
transitionOrder: (key, id, newStatus) =>
call(key, 'PUT', `/orders/${id}/status/${newStatus}`),
// Print jobs
listPrintJobs: (key, restaurantId, status) =>
call(
key,
'GET',
`/restaurants/${restaurantId}/print_jobs${status ? `?status=${status}` : ''}`
),
ackPrintJob: (key, id) => call(key, 'PUT', `/print_jobs/${id}/ack`),
// Settings
getSettings: (key) => call(key, 'GET', '/settings'),
updateSettings: (key, data) => call(key, 'PUT', '/settings', data)
}
})()

85
static/js/index.js Normal file
View file

@ -0,0 +1,85 @@
window.app = Vue.createApp({
el: '#vue',
mixins: [windowMixin],
data() {
return {
restaurants: [],
restaurantDialog: {
show: false,
data: this._blankRestaurant()
}
}
},
methods: {
_blankRestaurant() {
return {
wallet: '',
name: '',
slug: '',
description: '',
location: '',
currency: 'sat',
timezone: 'UTC'
}
},
openRestaurantDialog() {
this.restaurantDialog.data = this._blankRestaurant()
if (this.g.user.wallets.length) {
this.restaurantDialog.data.wallet = this.g.user.wallets[0].id
}
this.restaurantDialog.show = true
},
async fetchRestaurants() {
if (!this.g.user.wallets.length) return
const key = this.g.user.wallets[0].adminkey
try {
const {data} = await RestaurantAPI.listRestaurants(key, true)
this.restaurants = data
} catch (err) {
LNbits.utils.notifyApiError(err)
}
},
async createRestaurant() {
const key = this.g.user.wallets.find(
(w) => w.id === this.restaurantDialog.data.wallet
)?.adminkey
if (!key) {
Quasar.Notify.create({type: 'negative', message: 'Pick a wallet'})
return
}
try {
const {data} = await RestaurantAPI.createRestaurant(
key,
this.restaurantDialog.data
)
this.restaurants.unshift(data)
this.restaurantDialog.show = false
Quasar.Notify.create({
type: 'positive',
message: `Created ${data.name}`
})
} catch (err) {
LNbits.utils.notifyApiError(err)
}
}
},
computed: {
'g.user.walletOptions'() {
return this.g.user.wallets.map((w) => ({
label: w.name,
value: w.id
}))
}
},
async created() {
// Decorate g.user with wallet options for the dialog select.
this.g.user.walletOptions = this.g.user.wallets.map((w) => ({
label: w.name,
value: w.id
}))
await this.fetchRestaurants()
}
})

74
static/js/kds.js Normal file
View file

@ -0,0 +1,74 @@
window.app = Vue.createApp({
el: '#vue',
mixins: [windowMixin],
data() {
return {
restaurant: window.RESTAURANT_BOOTSTRAP || {},
active: [],
pollHandle: null,
activeStatuses: ['paid', 'accepted', 'ready']
}
},
computed: {
invoicekey() {
const w = this.g.user.wallets.find((w) => w.id === this.restaurant.wallet)
return (w && w.inkey) || (this.g.user.wallets[0] && this.g.user.wallets[0].inkey)
},
adminkey() {
const w = this.g.user.wallets.find((w) => w.id === this.restaurant.wallet)
return (w && w.adminkey) || (this.g.user.wallets[0] && this.g.user.wallets[0].adminkey)
}
},
methods: {
statusColor(status) {
return {paid: 'positive', accepted: 'blue', ready: 'amber'}[status] || 'grey'
},
cardClass(order) {
// Visually escalate as orders age. >5min = highlight; >15min = alarm.
const ageSec = (Date.now() - new Date(order.time).getTime()) / 1000
if (order.status === 'ready') return 'bg-amber-1'
if (ageSec > 900) return 'bg-red-1'
if (ageSec > 300) return 'bg-orange-1'
return ''
},
async fetchActive() {
try {
const {data: orders} = await RestaurantAPI.listOrders(
this.invoicekey,
this.restaurant.id,
this.activeStatuses
)
// Hydrate items per card.
for (const o of orders) {
try {
const {data} = await RestaurantAPI.getOrder(o.id)
o._items = data.items
} catch (e) {
o._items = []
}
}
// Newest at the bottom-right (left-to-right reading order in kitchen).
this.active = orders.sort(
(a, b) => new Date(a.time).getTime() - new Date(b.time).getTime()
)
} catch (err) {
LNbits.utils.notifyApiError(err)
}
},
async transition(order, newStatus) {
try {
await RestaurantAPI.transitionOrder(this.adminkey, order.id, newStatus)
await this.fetchActive()
} catch (err) {
LNbits.utils.notifyApiError(err)
}
}
},
async created() {
await this.fetchActive()
this.pollHandle = setInterval(() => this.fetchActive(), 5000)
},
beforeUnmount() {
if (this.pollHandle) clearInterval(this.pollHandle)
}
})

259
static/js/menu.js Normal file
View file

@ -0,0 +1,259 @@
window.app = Vue.createApp({
el: '#vue',
mixins: [windowMixin],
data() {
return {
restaurant: window.RESTAURANT_BOOTSTRAP || {},
categories: [],
items: [],
selectedCategoryId: null,
categoryDialog: {
show: false,
data: {restaurant_id: '', name: '', description: ''}
},
itemDialog: {
show: false,
data: this._blankItem(),
imagesText: '',
dietaryText: '',
allergensText: '',
ingredientsText: ''
},
modifiersDialog: {
show: false,
itemId: null,
itemName: '',
groups: []
}
}
},
computed: {
selectedCategory() {
return this.categories.find((c) => c.id === this.selectedCategoryId)
},
filteredItems() {
if (!this.selectedCategoryId) return this.items
return this.items.filter((i) => i.category_id === this.selectedCategoryId)
},
categoryOptions() {
return this.categories.map((c) => ({label: c.name, value: c.id}))
},
adminkey() {
// The wallet that owns this restaurant.
const w = this.g.user.wallets.find((w) => w.id === this.restaurant.wallet)
return (w && w.adminkey) || (this.g.user.wallets[0] && this.g.user.wallets[0].adminkey)
}
},
methods: {
_blankItem() {
return {
restaurant_id: '',
category_id: null,
subcategory_id: null,
name: '',
description: '',
price: 0,
currency: 'sat',
sku: '',
images: [],
dietary: [],
allergens: [],
ingredients: [],
calories: null,
sort_order: 0,
is_available: true,
is_featured: false,
stock: null
}
},
formatPrice(value, currency) {
const n = Number(value || 0)
const fmt = new Intl.NumberFormat(this.g.locale || 'en-US')
return `${fmt.format(n)} ${currency || ''}`.trim()
},
parseCsv(s) {
return (s || '')
.split(',')
.map((x) => x.trim())
.filter(Boolean)
},
// -------- categories --------
async fetchMenu() {
try {
const {data} = await RestaurantAPI.getMenu(this.restaurant.id)
this.categories = data.categories
this.items = data.items
if (!this.selectedCategoryId && this.categories.length) {
this.selectedCategoryId = this.categories[0].id
}
} catch (err) {
LNbits.utils.notifyApiError(err)
}
},
openCategoryDialog() {
this.categoryDialog.data = {
restaurant_id: this.restaurant.id,
name: '',
description: ''
}
this.categoryDialog.show = true
},
async saveCategory() {
try {
await RestaurantAPI.createCategory(this.adminkey, this.categoryDialog.data)
this.categoryDialog.show = false
await this.fetchMenu()
} catch (err) {
LNbits.utils.notifyApiError(err)
}
},
async deleteCategory(cat) {
if (!confirm(`Delete category ${cat.name}?`)) return
try {
await RestaurantAPI.deleteCategory(this.adminkey, cat.id)
await this.fetchMenu()
} catch (err) {
LNbits.utils.notifyApiError(err)
}
},
// -------- items --------
openItemDialog(existing) {
const item = existing
? {...existing}
: {...this._blankItem(), restaurant_id: this.restaurant.id}
if (!item.category_id && this.selectedCategoryId) {
item.category_id = this.selectedCategoryId
}
this.itemDialog.data = item
this.itemDialog.imagesText = (item.images || []).join(', ')
this.itemDialog.dietaryText = (item.dietary || []).join(', ')
this.itemDialog.allergensText = (item.allergens || []).join(', ')
this.itemDialog.ingredientsText = (item.ingredients || []).join(', ')
this.itemDialog.show = true
},
async saveItem() {
const payload = {
...this.itemDialog.data,
images: this.parseCsv(this.itemDialog.imagesText),
dietary: this.parseCsv(this.itemDialog.dietaryText),
allergens: this.parseCsv(this.itemDialog.allergensText),
ingredients: this.parseCsv(this.itemDialog.ingredientsText)
}
try {
if (this.itemDialog.data.id) {
await RestaurantAPI.updateMenuItem(
this.adminkey,
this.itemDialog.data.id,
payload
)
} else {
await RestaurantAPI.createMenuItem(this.adminkey, payload)
}
this.itemDialog.show = false
await this.fetchMenu()
} catch (err) {
LNbits.utils.notifyApiError(err)
}
},
async deleteItem(item) {
if (!confirm(`Delete ${item.name}?`)) return
try {
await RestaurantAPI.deleteMenuItem(this.adminkey, item.id)
await this.fetchMenu()
} catch (err) {
LNbits.utils.notifyApiError(err)
}
},
// -------- modifier groups --------
async openModifiersDialog(item) {
this.modifiersDialog.itemId = item.id
this.modifiersDialog.itemName = item.name
try {
const {data: groups} = await RestaurantAPI.listModifierGroups(item.id)
// Hydrate each group with its modifiers.
for (const g of groups) {
const {data: mods} = await RestaurantAPI.listModifiers(g.id)
g._modifiers = mods
}
this.modifiersDialog.groups = groups
this.modifiersDialog.show = true
} catch (err) {
LNbits.utils.notifyApiError(err)
}
},
async addModifierGroup() {
const name = prompt('Group name (e.g. "Choose your protein")')
if (!name) return
const kind = confirm('Required group? (Cancel = optional addon)')
? 'required'
: 'optional'
const selection = confirm('Single choice? (Cancel = multi-select)')
? 'one'
: 'many'
try {
await RestaurantAPI.createModifierGroup(this.adminkey, {
menu_item_id: this.modifiersDialog.itemId,
name,
kind,
selection
})
await this.openModifiersDialog({
id: this.modifiersDialog.itemId,
name: this.modifiersDialog.itemName
})
} catch (err) {
LNbits.utils.notifyApiError(err)
}
},
async deleteModifierGroup(grp) {
if (!confirm(`Delete group ${grp.name}?`)) return
try {
await RestaurantAPI.deleteModifierGroup(this.adminkey, grp.id)
await this.openModifiersDialog({
id: this.modifiersDialog.itemId,
name: this.modifiersDialog.itemName
})
} catch (err) {
LNbits.utils.notifyApiError(err)
}
},
async addModifier(grp) {
const name = prompt('Modifier name (e.g. "Chicken")')
if (!name) return
const priceStr = prompt('Price delta (in the same currency as the item)')
if (priceStr === null) return
const price_delta = parseFloat(priceStr) || 0
try {
await RestaurantAPI.createModifier(this.adminkey, {
group_id: grp.id,
name,
price_delta
})
await this.openModifiersDialog({
id: this.modifiersDialog.itemId,
name: this.modifiersDialog.itemName
})
} catch (err) {
LNbits.utils.notifyApiError(err)
}
},
async deleteModifier(grp, mod) {
if (!confirm(`Delete ${mod.name}?`)) return
try {
await RestaurantAPI.deleteModifier(this.adminkey, mod.id)
await this.openModifiersDialog({
id: this.modifiersDialog.itemId,
name: this.modifiersDialog.itemName
})
} catch (err) {
LNbits.utils.notifyApiError(err)
}
}
},
async created() {
await this.fetchMenu()
}
})

108
static/js/orders.js Normal file
View file

@ -0,0 +1,108 @@
window.app = Vue.createApp({
el: '#vue',
mixins: [windowMixin],
data() {
return {
restaurant: window.RESTAURANT_BOOTSTRAP || {},
orders: [],
statusFilter: ['pending', 'paid', 'accepted', 'ready'],
statusOptions: [
{label: 'Pending', value: 'pending'},
{label: 'Paid', value: 'paid'},
{label: 'Accepted', value: 'accepted'},
{label: 'Ready', value: 'ready'},
{label: 'Completed', value: 'completed'},
{label: 'Canceled', value: 'canceled'},
{label: 'Refunded', value: 'refunded'}
],
orderDialog: {show: false, order: null, items: []},
columns: [
{
name: 'time',
label: 'When',
align: 'left',
field: (r) => r.time,
format: (v) => LNbits.utils.formatTimestamp(v)
},
{name: 'id', label: 'ID', align: 'left', field: (r) => r.id.slice(0, 8)},
{
name: 'customer',
label: 'Customer',
align: 'left',
field: (r) => r.customer_name || (r.customer_pubkey ? r.customer_pubkey.slice(0, 12) + '…' : '—')
},
{name: 'status', label: 'Status', align: 'left', field: 'status'},
{name: 'channel', label: 'Channel', align: 'left', field: 'channel'},
{name: 'total', label: 'Total', align: 'right', field: 'total_msat'},
{name: 'actions', label: '', align: 'right'}
],
pollHandle: null
}
},
computed: {
invoicekey() {
const w = this.g.user.wallets.find((w) => w.id === this.restaurant.wallet)
return (w && w.inkey) || (this.g.user.wallets[0] && this.g.user.wallets[0].inkey)
},
adminkey() {
const w = this.g.user.wallets.find((w) => w.id === this.restaurant.wallet)
return (w && w.adminkey) || (this.g.user.wallets[0] && this.g.user.wallets[0].adminkey)
}
},
methods: {
formatSat(msat) {
const sats = Math.round((msat || 0) / 1000)
const fmt = new Intl.NumberFormat(this.g.locale || 'en-US')
return `${fmt.format(sats)} sat`
},
statusColor(status) {
return {
pending: 'grey',
paid: 'positive',
accepted: 'blue',
ready: 'amber',
completed: 'teal',
canceled: 'negative',
refunded: 'purple'
}[status] || 'grey'
},
async fetchOrders() {
try {
const {data} = await RestaurantAPI.listOrders(
this.invoicekey,
this.restaurant.id,
this.statusFilter
)
this.orders = data
} catch (err) {
LNbits.utils.notifyApiError(err)
}
},
async viewOrder(order) {
try {
const {data} = await RestaurantAPI.getOrder(order.id)
this.orderDialog.order = data.order
this.orderDialog.items = data.items
this.orderDialog.show = true
} catch (err) {
LNbits.utils.notifyApiError(err)
}
},
async transition(order, newStatus) {
try {
await RestaurantAPI.transitionOrder(this.adminkey, order.id, newStatus)
await this.fetchOrders()
} catch (err) {
LNbits.utils.notifyApiError(err)
}
}
},
async created() {
await this.fetchOrders()
// Poll every 8s; replaced by SSE/Nostr push in a future iteration.
this.pollHandle = setInterval(() => this.fetchOrders(), 8000)
},
beforeUnmount() {
if (this.pollHandle) clearInterval(this.pollHandle)
}
})

72
static/js/settings.js Normal file
View file

@ -0,0 +1,72 @@
window.app = Vue.createApp({
el: '#vue',
mixins: [windowMixin],
data() {
return {
form: window.RESTAURANT_BOOTSTRAP || {},
relaysText: '',
extSettings: null,
isAdmin: false
}
},
computed: {
adminkey() {
const w = this.g.user.wallets.find((w) => w.id === this.form.wallet)
return (w && w.adminkey) || (this.g.user.wallets[0] && this.g.user.wallets[0].adminkey)
}
},
methods: {
async save() {
try {
const payload = {
...this.form,
nostr_relays: (this.relaysText || '')
.split(',')
.map((s) => s.trim())
.filter(Boolean)
}
const {data} = await RestaurantAPI.updateRestaurant(
this.adminkey,
this.form.id,
payload
)
this.form = data
this.relaysText = (data.nostr_relays || []).join(', ')
Quasar.Notify.create({type: 'positive', message: 'Saved'})
} catch (err) {
LNbits.utils.notifyApiError(err)
}
},
async deleteRestaurant() {
if (!confirm(`Permanently delete ${this.form.name} and all its data?`)) return
try {
await RestaurantAPI.deleteRestaurant(this.adminkey, this.form.id)
window.location.href = '/restaurant/'
} catch (err) {
LNbits.utils.notifyApiError(err)
}
},
async fetchExtSettings() {
try {
const {data} = await RestaurantAPI.getSettings(this.adminkey)
this.extSettings = data
this.isAdmin = true
} catch (err) {
// Non-admins get 401/403 — silently swallow.
this.isAdmin = false
}
},
async saveExtSettings() {
if (!this.extSettings) return
try {
await RestaurantAPI.updateSettings(this.adminkey, this.extSettings)
} catch (err) {
LNbits.utils.notifyApiError(err)
}
}
},
async created() {
this.relaysText = (this.form.nostr_relays || []).join(', ')
await this.fetchExtSettings()
}
})

View file

@ -0,0 +1,146 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block page %}
<div class="row q-col-gutter-md">
<div class="col-12 col-md-8 col-lg-9 q-gutter-y-md">
<q-card>
<q-card-section class="row items-center justify-between">
<div>
<span class="text-h6">Your Restaurants</span>
<div class="text-caption text-grey-6">
One LNbits wallet can host many restaurants. Create one per
kitchen / location.
</div>
</div>
<q-btn unelevated color="primary" icon="add" @click="openRestaurantDialog">
New restaurant
</q-btn>
</q-card-section>
<q-separator></q-separator>
<q-card-section v-if="restaurants.length === 0">
<div class="text-grey-6 text-italic">
No restaurants yet. Click "New restaurant" to get started.
</div>
</q-card-section>
<q-list separator v-else>
<q-item
v-for="r in restaurants"
:key="r.id"
clickable
:to="`/restaurant/${r.slug}`"
>
<q-item-section avatar>
<q-avatar v-if="r.logo_url">
<img :src="r.logo_url" />
</q-avatar>
<q-avatar v-else color="primary" text-color="white" icon="restaurant"></q-avatar>
</q-item-section>
<q-item-section>
<q-item-label v-text="r.name"></q-item-label>
<q-item-label caption>
<span v-text="r.slug"></span>
<q-badge
v-if="r.is_open"
color="positive"
class="q-ml-sm"
label="Open"
></q-badge>
<q-badge v-else color="grey" class="q-ml-sm" label="Closed"></q-badge>
<q-badge
v-if="r.nostr_pubkey"
color="purple"
class="q-ml-sm"
icon="hub"
label="Nostr"
></q-badge>
</q-item-label>
</q-item-section>
<q-item-section side>
<q-btn
flat
dense
icon="settings"
:to="`/restaurant/${r.slug}/settings`"
@click.stop
></q-btn>
</q-item-section>
</q-item>
</q-list>
</q-card>
</div>
<div class="col-12 col-md-4 col-lg-3 q-gutter-y-md">
<q-card>
<q-card-section>
<h6 class="q-my-none">{{ SITE_TITLE }} Restaurant CMS</h6>
</q-card-section>
<q-separator></q-separator>
<q-card-section class="text-caption text-grey-7">
Build menus, manage modifiers and inventory, and watch orders
in real time. Customer-facing UI lives in the AIO webapp;
this is the operator console.
</q-card-section>
</q-card>
</div>
</div>
<!-- ===================== New restaurant dialog ===================== -->
<q-dialog v-model="restaurantDialog.show" persistent>
<q-card class="q-pa-md" style="width: 500px; max-width: 95vw">
<h6 class="q-my-none">New restaurant</h6>
<q-form @submit="createRestaurant" class="q-gutter-md q-mt-sm">
<q-input
filled
dense
v-model.trim="restaurantDialog.data.name"
label="Name"
:rules="[val => !!val || 'Required']"
></q-input>
<q-input
filled
dense
v-model.trim="restaurantDialog.data.slug"
label="Slug (used in URLs)"
hint="lowercase, no spaces; e.g. emporium"
:rules="[
val => !!val || 'Required',
val => /^[a-z0-9-]+$/.test(val) || 'lowercase, digits, dashes'
]"
></q-input>
<q-input
filled
dense
v-model="restaurantDialog.data.description"
label="Short description"
type="textarea"
rows="2"
></q-input>
<q-input
filled
dense
v-model.trim="restaurantDialog.data.location"
label="Location"
hint="freeform; e.g. San Marcos La Laguna, GT"
></q-input>
<q-select
filled
dense
v-model="restaurantDialog.data.wallet"
:options="g.user.walletOptions"
label="Receiving wallet"
emit-value
map-options
></q-select>
<div class="row justify-end q-gutter-sm">
<q-btn flat label="Cancel" @click="restaurantDialog.show = false"></q-btn>
<q-btn unelevated color="primary" type="submit" label="Create"></q-btn>
</div>
</q-form>
</q-card>
</q-dialog>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script src="{{ static_url_for('restaurant/static', path='js/api.js') }}"></script>
<script src="{{ static_url_for('restaurant/static', path='js/index.js') }}"></script>
{% endblock %}

View file

@ -0,0 +1,104 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block page %}
<div class="row q-col-gutter-md">
<div class="col-12">
<q-card>
<q-card-section class="row items-center justify-between">
<div>
<span class="text-h6">Kitchen display</span>
<span class="text-caption text-grey-6 q-ml-md" v-text="restaurant.name"></span>
</div>
<q-btn flat dense icon="refresh" @click="fetchActive"></q-btn>
</q-card-section>
</q-card>
<div class="row q-col-gutter-md q-mt-md">
<div
v-for="order in active"
:key="order.id"
class="col-12 col-sm-6 col-md-4 col-lg-3"
>
<q-card
:class="cardClass(order)"
flat
bordered
style="height: 100%"
>
<q-card-section>
<div class="row items-center justify-between">
<div class="text-h6" v-text="'#' + order.id.slice(0,6)"></div>
<q-badge :color="statusColor(order.status)" :label="order.status"></q-badge>
</div>
<div class="text-caption text-grey-6 q-mt-xs">
<span v-text="LNbits.utils.formatTimestamp(order.time)"></span>
<span class="q-ml-sm" v-if="order.note" title="Order note">
<q-icon name="notes"></q-icon>
</span>
</div>
</q-card-section>
<q-separator></q-separator>
<q-card-section style="font-size: 1rem">
<div v-for="line in order._items || []" :key="line.id">
<div>
<strong v-text="line.quantity + 'x'"></strong>
<span class="q-ml-sm" v-text="line.name"></span>
</div>
<div
v-if="line.selected_modifiers && line.selected_modifiers.length"
class="text-caption text-grey-7 q-pl-md"
>
<span
v-for="(m, i) in line.selected_modifiers"
:key="i"
>
<span v-if="i > 0">, </span>
<span v-text="m.name"></span>
</span>
</div>
<div
v-if="line.note"
class="text-caption text-amber-9 q-pl-md"
>
<q-icon name="info" size="xs"></q-icon>
<span v-text="line.note"></span>
</div>
</div>
</q-card-section>
<q-card-actions align="around">
<q-btn
v-if="['paid','pending'].includes(order.status)"
size="sm"
color="primary"
@click="transition(order, 'accepted')"
label="Accept"
></q-btn>
<q-btn
v-if="order.status === 'accepted'"
size="sm"
color="amber-8"
@click="transition(order, 'ready')"
label="Ready"
></q-btn>
<q-btn
v-if="order.status === 'ready'"
size="sm"
color="teal"
@click="transition(order, 'completed')"
label="Done"
></q-btn>
</q-card-actions>
</q-card>
</div>
<div v-if="active.length === 0" class="col-12 text-center text-grey-6 q-pa-xl">
Nothing in the queue.
</div>
</div>
</div>
</div>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script>
window.RESTAURANT_BOOTSTRAP = {{ restaurant | tojson | safe }}
</script>
<script src="{{ static_url_for('restaurant/static', path='js/api.js') }}"></script>
<script src="{{ static_url_for('restaurant/static', path='js/kds.js') }}"></script>
{% endblock %}

View file

@ -0,0 +1,350 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block page %}
<div class="row q-col-gutter-md">
<!-- ============================ Sidebar ============================ -->
<div class="col-12 col-md-3 q-gutter-y-md">
<q-card>
<q-card-section>
<div class="text-subtitle1" v-text="restaurant.name"></div>
<div class="text-caption text-grey-6">Menu builder</div>
</q-card-section>
<q-separator></q-separator>
<q-list>
<q-item clickable :to="`/restaurant/${restaurant.slug}/orders`">
<q-item-section avatar><q-icon name="receipt_long"></q-icon></q-item-section>
<q-item-section>Orders</q-item-section>
</q-item>
<q-item clickable :to="`/restaurant/${restaurant.slug}/kds`">
<q-item-section avatar><q-icon name="kitchen"></q-icon></q-item-section>
<q-item-section>Kitchen display</q-item-section>
</q-item>
<q-item clickable :to="`/restaurant/${restaurant.slug}/settings`">
<q-item-section avatar><q-icon name="settings"></q-icon></q-item-section>
<q-item-section>Settings</q-item-section>
</q-item>
</q-list>
</q-card>
<q-card>
<q-card-section>
<div class="row items-center justify-between q-mb-sm">
<span class="text-subtitle2">Categories</span>
<q-btn flat dense icon="add" @click="openCategoryDialog"></q-btn>
</div>
<q-list>
<q-item
v-for="cat in categories"
:key="cat.id"
clickable
:active="selectedCategoryId === cat.id"
@click="selectedCategoryId = cat.id"
>
<q-item-section v-text="cat.name"></q-item-section>
<q-item-section side>
<q-btn
flat
dense
icon="delete"
size="sm"
@click.stop="deleteCategory(cat)"
></q-btn>
</q-item-section>
</q-item>
<q-item v-if="categories.length === 0">
<q-item-section class="text-grey-6 text-italic">
No categories
</q-item-section>
</q-item>
</q-list>
</q-card-section>
</q-card>
</div>
<!-- ============================ Items ============================ -->
<div class="col-12 col-md-9 q-gutter-y-md">
<q-card>
<q-card-section class="row items-center justify-between">
<div>
<span class="text-h6">Items</span>
<q-badge v-if="selectedCategory" class="q-ml-sm" color="primary">
<span v-text="selectedCategory.name"></span>
</q-badge>
</div>
<q-btn
unelevated
color="primary"
icon="add"
label="New item"
:disable="!categories.length"
@click="openItemDialog()"
></q-btn>
</q-card-section>
<q-separator></q-separator>
<q-card-section v-if="filteredItems.length === 0" class="text-grey-6 text-italic">
No items in this category yet.
</q-card-section>
<q-list separator v-else>
<q-item v-for="item in filteredItems" :key="item.id">
<q-item-section avatar v-if="item.images && item.images.length">
<q-avatar><img :src="item.images[0]" /></q-avatar>
</q-item-section>
<q-item-section>
<q-item-label>
<span v-text="item.name"></span>
<q-badge
v-if="!item.is_available"
color="grey"
class="q-ml-sm"
label="Unavailable"
></q-badge>
<q-badge
v-if="item.stock !== null && item.stock !== undefined && item.stock <= 0"
color="negative"
class="q-ml-sm"
label="Sold out"
></q-badge>
<q-badge
v-if="item.nostr_event_id"
color="purple"
class="q-ml-sm"
label="Published"
title="Live on Nostr"
></q-badge>
</q-item-label>
<q-item-label caption>
<span v-text="formatPrice(item.price, item.currency)"></span>
<span
v-if="item.dietary && item.dietary.length"
class="q-ml-md text-grey-6"
>
<q-icon name="eco" size="xs"></q-icon>
<span v-text="item.dietary.join(', ')"></span>
</span>
<span
v-if="item.allergens && item.allergens.length"
class="q-ml-md text-amber-9"
>
<q-icon name="warning" size="xs"></q-icon>
<span v-text="item.allergens.join(', ')"></span>
</span>
</q-item-label>
<q-item-label caption v-if="item.description" v-text="item.description"></q-item-label>
</q-item-section>
<q-item-section side>
<q-btn-group flat>
<q-btn flat dense icon="tune" title="Modifiers" @click="openModifiersDialog(item)"></q-btn>
<q-btn flat dense icon="edit" @click="openItemDialog(item)"></q-btn>
<q-btn flat dense icon="delete" @click="deleteItem(item)"></q-btn>
</q-btn-group>
</q-item-section>
</q-item>
</q-list>
</q-card>
</div>
</div>
<!-- ===================== Category dialog ===================== -->
<q-dialog v-model="categoryDialog.show" persistent>
<q-card class="q-pa-md" style="width: 400px; max-width: 95vw">
<h6 class="q-my-none">New category</h6>
<q-form @submit="saveCategory" class="q-gutter-md q-mt-sm">
<q-input
filled
dense
v-model.trim="categoryDialog.data.name"
label="Name"
:rules="[v => !!v || 'Required']"
></q-input>
<q-input
filled
dense
v-model="categoryDialog.data.description"
label="Description"
type="textarea"
rows="2"
></q-input>
<div class="row justify-end q-gutter-sm">
<q-btn flat label="Cancel" @click="categoryDialog.show = false"></q-btn>
<q-btn unelevated color="primary" type="submit" label="Create"></q-btn>
</div>
</q-form>
</q-card>
</q-dialog>
<!-- ===================== Item dialog ===================== -->
<q-dialog v-model="itemDialog.show" persistent>
<q-card class="q-pa-md" style="width: 600px; max-width: 95vw">
<h6 class="q-my-none">{{ '{{ itemDialog.data.id ? "Edit" : "New" }}' }} item</h6>
<q-form @submit="saveItem" class="q-gutter-md q-mt-sm">
<q-select
filled
dense
v-model="itemDialog.data.category_id"
:options="categoryOptions"
emit-value
map-options
label="Category"
></q-select>
<q-input
filled
dense
v-model.trim="itemDialog.data.name"
label="Name"
:rules="[v => !!v || 'Required']"
></q-input>
<q-input
filled
dense
v-model="itemDialog.data.description"
label="Description"
type="textarea"
rows="2"
></q-input>
<div class="row q-col-gutter-sm">
<q-input
class="col"
filled
dense
type="number"
v-model.number="itemDialog.data.price"
label="Price"
></q-input>
<q-select
class="col"
filled
dense
v-model="itemDialog.data.currency"
:options="['sat','USD','EUR','GTQ','BRL','GBP']"
label="Currency"
></q-select>
</div>
<q-input
filled
dense
v-model.trim="itemDialog.imagesText"
label="Image URLs (comma-separated)"
></q-input>
<q-input
filled
dense
v-model.trim="itemDialog.dietaryText"
label="Dietary tags (comma-separated, e.g. vegan, gluten_free)"
></q-input>
<q-input
filled
dense
v-model.trim="itemDialog.allergensText"
label="Allergens (comma-separated)"
></q-input>
<q-input
filled
dense
v-model.trim="itemDialog.ingredientsText"
label="Ingredients (comma-separated)"
></q-input>
<div class="row q-col-gutter-sm">
<q-input
class="col"
filled
dense
type="number"
v-model.number="itemDialog.data.stock"
label="Stock (blank = unlimited)"
></q-input>
<q-input
class="col"
filled
dense
type="number"
v-model.number="itemDialog.data.calories"
label="Calories"
></q-input>
</div>
<div class="row q-col-gutter-md">
<q-toggle
v-model="itemDialog.data.is_available"
label="Available"
></q-toggle>
<q-toggle
v-model="itemDialog.data.is_featured"
label="Featured"
></q-toggle>
</div>
<div class="row justify-end q-gutter-sm">
<q-btn flat label="Cancel" @click="itemDialog.show = false"></q-btn>
<q-btn unelevated color="primary" type="submit" label="Save"></q-btn>
</div>
</q-form>
</q-card>
</q-dialog>
<!-- ===================== Modifiers dialog ===================== -->
<q-dialog v-model="modifiersDialog.show">
<q-card class="q-pa-md" style="width: 700px; max-width: 95vw">
<div class="row items-center justify-between">
<h6 class="q-my-none">
Modifiers — <span v-text="modifiersDialog.itemName"></span>
</h6>
<q-btn flat icon="close" v-close-popup></q-btn>
</div>
<q-card flat class="q-mt-md">
<q-card-section>
<div class="row items-center justify-between q-mb-sm">
<span class="text-subtitle2">Groups</span>
<q-btn dense flat icon="add" @click="addModifierGroup"></q-btn>
</div>
<q-expansion-item
v-for="grp in modifiersDialog.groups"
:key="grp.id"
:label="grp.name"
:caption="`${grp.kind} / ${grp.selection}`"
>
<q-card>
<q-card-section>
<q-list>
<q-item v-for="mod in grp._modifiers || []" :key="mod.id">
<q-item-section>
<q-item-label v-text="mod.name"></q-item-label>
<q-item-label caption>
<span v-text="formatPrice(mod.price_delta, restaurant.currency)"></span>
</q-item-label>
</q-item-section>
<q-item-section side>
<q-btn flat dense icon="delete" @click="deleteModifier(grp, mod)"></q-btn>
</q-item-section>
</q-item>
</q-list>
<q-btn
flat
dense
icon="add"
label="Add modifier"
@click="addModifier(grp)"
></q-btn>
<q-btn
flat
dense
color="negative"
icon="delete"
label="Delete group"
@click="deleteModifierGroup(grp)"
></q-btn>
</q-card-section>
</q-card>
</q-expansion-item>
</q-card-section>
</q-card>
</q-card>
</q-dialog>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script>
window.RESTAURANT_BOOTSTRAP = {{ restaurant | tojson | safe }}
</script>
<script src="{{ static_url_for('restaurant/static', path='js/api.js') }}"></script>
<script src="{{ static_url_for('restaurant/static', path='js/menu.js') }}"></script>
{% endblock %}

View file

@ -0,0 +1,155 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block page %}
<div class="row q-col-gutter-md">
<div class="col-12 q-gutter-y-md">
<q-card>
<q-card-section class="row items-center justify-between">
<div>
<span class="text-h6">Orders</span>
<span class="text-caption text-grey-6 q-ml-md" v-text="restaurant.name"></span>
</div>
<div class="row q-gutter-sm items-center">
<q-select
dense
outlined
v-model="statusFilter"
:options="statusOptions"
multiple
emit-value
map-options
label="Status"
style="min-width: 220px"
@update:model-value="fetchOrders"
></q-select>
<q-btn flat dense icon="refresh" @click="fetchOrders"></q-btn>
</div>
</q-card-section>
<q-separator></q-separator>
<q-table
:rows="orders"
:columns="columns"
row-key="id"
flat
:pagination="{rowsPerPage: 25}"
>
<template v-slot:body-cell-status="props">
<q-td :props="props">
<q-badge :color="statusColor(props.row.status)" :label="props.row.status"></q-badge>
</q-td>
</template>
<template v-slot:body-cell-total="props">
<q-td :props="props">
<span v-text="formatSat(props.row.total_msat)"></span>
</q-td>
</template>
<template v-slot:body-cell-actions="props">
<q-td :props="props">
<q-btn flat dense icon="visibility" @click="viewOrder(props.row)"></q-btn>
<q-btn
flat
dense
icon="check"
v-if="['paid','pending'].includes(props.row.status)"
@click="transition(props.row, 'accepted')"
title="Accept"
></q-btn>
<q-btn
flat
dense
icon="restaurant"
v-if="props.row.status === 'accepted'"
@click="transition(props.row, 'ready')"
title="Ready"
></q-btn>
<q-btn
flat
dense
icon="done_all"
v-if="props.row.status === 'ready'"
@click="transition(props.row, 'completed')"
title="Complete"
></q-btn>
<q-btn
flat
dense
icon="cancel"
color="negative"
v-if="!['completed','canceled','refunded'].includes(props.row.status)"
@click="transition(props.row, 'canceled')"
title="Cancel"
></q-btn>
</q-td>
</template>
</q-table>
</q-card>
</div>
</div>
<!-- ===================== Order detail dialog ===================== -->
<q-dialog v-model="orderDialog.show">
<q-card class="q-pa-md" style="width: 600px; max-width: 95vw">
<div class="row items-center justify-between">
<h6 class="q-my-none">
Order <span class="text-grey-6" v-text="orderDialog.order && orderDialog.order.id.slice(0,8)"></span>
</h6>
<q-btn flat icon="close" v-close-popup></q-btn>
</div>
<q-card-section v-if="orderDialog.order">
<div class="text-subtitle2 q-mb-sm">
<q-badge :color="statusColor(orderDialog.order.status)" :label="orderDialog.order.status"></q-badge>
<span class="q-ml-md" v-text="formatSat(orderDialog.order.total_msat)"></span>
</div>
<div class="text-caption text-grey-6 q-mb-md">
<span v-if="orderDialog.order.customer_name">
Customer: <span v-text="orderDialog.order.customer_name"></span>
</span>
<span v-if="orderDialog.order.customer_pubkey" class="q-ml-md">
Pubkey: <span class="text-monospace" v-text="orderDialog.order.customer_pubkey.slice(0,16) + '...'"></span>
</span>
</div>
<q-list separator>
<q-item v-for="line in orderDialog.items" :key="line.id">
<q-item-section>
<q-item-label>
<span v-text="line.quantity + 'x '"></span>
<span v-text="line.name"></span>
</q-item-label>
<q-item-label
caption
v-if="line.selected_modifiers && line.selected_modifiers.length"
>
<span
v-for="(m, i) in line.selected_modifiers"
:key="i"
>
<span v-if="i > 0">, </span>
<span v-text="m.name"></span>
</span>
</q-item-label>
<q-item-label caption v-if="line.note">
Note: <span v-text="line.note"></span>
</q-item-label>
</q-item-section>
<q-item-section side>
<span v-text="formatSat(line.line_total_msat)"></span>
</q-item-section>
</q-item>
</q-list>
<div v-if="orderDialog.order.note" class="q-mt-md text-caption">
<strong>Order note:</strong>
<span v-text="orderDialog.order.note"></span>
</div>
</q-card-section>
</q-card>
</q-dialog>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script>
window.RESTAURANT_BOOTSTRAP = {{ restaurant | tojson | safe }}
</script>
<script src="{{ static_url_for('restaurant/static', path='js/api.js') }}"></script>
<script src="{{ static_url_for('restaurant/static', path='js/orders.js') }}"></script>
{% endblock %}

View file

@ -0,0 +1,133 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block page %}
<div class="row q-col-gutter-md">
<div class="col-12 col-md-8 q-gutter-y-md">
<q-card>
<q-card-section>
<span class="text-h6">Settings — <span v-text="form.name"></span></span>
</q-card-section>
<q-separator></q-separator>
<q-card-section>
<q-form @submit="save" class="q-gutter-md">
<q-input filled dense v-model.trim="form.name" label="Name"></q-input>
<q-input filled dense v-model.trim="form.slug" label="Slug" disable></q-input>
<q-input
filled
dense
v-model="form.description"
label="Description"
type="textarea"
rows="2"
></q-input>
<div class="row q-col-gutter-sm">
<q-input
class="col"
filled
dense
v-model.trim="form.currency"
label="Currency"
hint="3-letter code, e.g. sat / USD / GTQ"
></q-input>
<q-input
class="col"
filled
dense
v-model.trim="form.timezone"
label="Timezone"
hint="IANA, e.g. America/Guatemala"
></q-input>
</div>
<q-input filled dense v-model.trim="form.location" label="Location"></q-input>
<q-input filled dense v-model.trim="form.geohash" label="Geohash"></q-input>
<div class="row q-col-gutter-sm">
<q-input class="col" filled dense v-model.trim="form.logo_url" label="Logo URL"></q-input>
<q-input class="col" filled dense v-model.trim="form.banner_url" label="Banner URL"></q-input>
</div>
<div class="row q-col-gutter-md">
<q-toggle v-model="form.is_open" label="Currently open"></q-toggle>
<q-toggle v-model="form.accepts_lightning" label="Accepts Lightning"></q-toggle>
<q-toggle v-model="form.accepts_cash" label="Accepts cash"></q-toggle>
</div>
<q-input
filled
dense
type="number"
v-model.number="form.tax_rate"
label="Tax rate (%)"
></q-input>
<q-input
filled
dense
v-model.trim="form.printer_endpoint"
label="Printer endpoint"
hint="URL the thermal printer subscribes to (or 'nostr:<pubkey>')"
></q-input>
<q-separator></q-separator>
<div class="text-subtitle1">Nostr</div>
<q-input
filled
dense
v-model.trim="form.nostr_pubkey"
label="Restaurant Nostr pubkey (hex)"
hint="Optional: per-restaurant signing identity. Leave blank to use the LNbits account keypair."
></q-input>
<q-input
filled
dense
v-model.trim="relaysText"
label="Preferred relays (comma-separated)"
></q-input>
<div class="row justify-end q-gutter-sm">
<q-btn flat label="Delete restaurant" color="negative" @click="deleteRestaurant"></q-btn>
<q-btn unelevated color="primary" type="submit" label="Save"></q-btn>
</div>
</q-form>
</q-card-section>
</q-card>
</div>
<div class="col-12 col-md-4 q-gutter-y-md">
<q-card>
<q-card-section>
<span class="text-subtitle1">Extension settings</span>
<div class="text-caption text-grey-6">Admin-only, applies to every restaurant on this LNbits instance.</div>
</q-card-section>
<q-card-section v-if="extSettings">
<q-toggle
v-model="extSettings.nostr_publish_enabled"
label="Publish menus to Nostr"
@update:model-value="saveExtSettings"
></q-toggle>
<q-toggle
v-model="extSettings.nostr_orders_enabled"
label="Accept orders via Nostr DMs"
@update:model-value="saveExtSettings"
:disable="true"
hint="NIP-17 unwrap not yet wired up"
></q-toggle>
<q-toggle
v-model="extSettings.auto_accept_orders"
label="Auto-accept paid orders"
@update:model-value="saveExtSettings"
></q-toggle>
<q-input
filled
dense
type="number"
v-model.number="extSettings.invoice_expiry_seconds"
label="Invoice expiry (sec)"
@blur="saveExtSettings"
></q-input>
</q-card-section>
</q-card>
</div>
</div>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script>
window.RESTAURANT_BOOTSTRAP = {{ restaurant | tojson | safe }}
</script>
<script src="{{ static_url_for('restaurant/static', path='js/api.js') }}"></script>
<script src="{{ static_url_for('restaurant/static', path='js/settings.js') }}"></script>
{% endblock %}