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

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