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:
parent
c37b17d474
commit
3382462af4
11 changed files with 1576 additions and 0 deletions
90
static/js/api.js
Normal file
90
static/js/api.js
Normal 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
85
static/js/index.js
Normal 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
74
static/js/kds.js
Normal 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
259
static/js/menu.js
Normal 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
108
static/js/orders.js
Normal 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
72
static/js/settings.js
Normal 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()
|
||||
}
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue