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()
|
||||
}
|
||||
})
|
||||
146
templates/restaurant/index.html
Normal file
146
templates/restaurant/index.html
Normal 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 %}
|
||||
104
templates/restaurant/kds.html
Normal file
104
templates/restaurant/kds.html
Normal 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 %}
|
||||
350
templates/restaurant/menu.html
Normal file
350
templates/restaurant/menu.html
Normal 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 %}
|
||||
155
templates/restaurant/orders.html
Normal file
155
templates/restaurant/orders.html
Normal 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 %}
|
||||
133
templates/restaurant/settings.html
Normal file
133
templates/restaurant/settings.html
Normal 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 %}
|
||||
Loading…
Add table
Add a link
Reference in a new issue