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

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()
}
})