Replace the flat sidebar + dead-subcategory-modal with a real
arbitrary-depth tree builder using Quasar's q-tree.
templates/restaurant/menu.html:
Three-pane layout (sidebar / tree / items).
q-tree binds to the hydrated tree returned by
GET /api/v1/restaurants/{id}/menu. Custom default-header slot
renders the node name + an item-count badge + a child-count
hint, with inline buttons:
add (disabled at depth 3),
edit, drive_file_move, delete (with cascade prompt).
Top-level button above the tree adds root nodes.
Items pane filters to the selected node, with a 'New item' that
opens the item dialog with node_id pre-selected. The item
dialog's node_id picker is a flat-indented q-select of every
node in the restaurant (em-space indentation per depth level).
A dedicated Move dialog uses the same flat-indented picker, but
filters out the moved node + its descendants and any depth-3
candidate (cycle / depth pre-checks; server enforces both too).
static/js/menu.js:
Vue 3 + Quasar 2 UMD. Loads {tree, items} once, builds a
flatNodes index for the option lists, and refetches after every
mutation (≤50 nodes per restaurant — trivial; SSE/Nostr push is
v2). Helpers:
_findNode — recursive lookup by id
_flatten — depth-first walk producing the option list
selectedNode / filteredItems / allNodeOptions /
moveTargetOptions / adminkey computeds.
Delete prompts surface child-count + item-count and pass
cascade=true when needed.
CMS now lets the operator build menus like
Drinks
├─ Hot Beverages
│ ├─ Coffee-based
│ └─ Cacao-based
└─ Cold (with its own items)
including items at any non-leaf level, satisfying the design
constraint.
409 lines
13 KiB
JavaScript
409 lines
13 KiB
JavaScript
/*
|
|
* Menu builder — q-tree + items panel.
|
|
*
|
|
* The server's GET /api/v1/restaurants/{id}/menu returns:
|
|
* { restaurant, tree: [<root MenuNode>], items: [<enriched item>] }
|
|
* `tree` is a hydrated tree (each node has children + items already
|
|
* attached, plus depth + path). `items` is the flat enriched list
|
|
* (with modifier groups, modifier options, availability windows
|
|
* pre-joined) — used here to populate the items panel by node_id.
|
|
*
|
|
* Tree mutations (create / rename / move / delete) hit the
|
|
* /api/v1/menu_nodes/* endpoints. We refetch the whole menu after
|
|
* each mutation; for ≤50 nodes per restaurant this is trivial and
|
|
* keeps state simple. SSE/Nostr push refresh is a v2.
|
|
*/
|
|
window.app = Vue.createApp({
|
|
el: '#vue',
|
|
mixins: [windowMixin],
|
|
data() {
|
|
return {
|
|
restaurant: window.RESTAURANT_BOOTSTRAP || {},
|
|
tree: [], // hydrated root nodes
|
|
flatNodes: [], // flat list of every node (id + name + depth + path)
|
|
enrichedItems: [], // flat list of items with modifier groups attached
|
|
selectedNodeId: null,
|
|
expandedNodeIds: [],
|
|
maxDepth: 3, // 0..3 = 4 levels; mirrors models.MAX_MENU_DEPTH
|
|
|
|
nodeDialog: {
|
|
show: false,
|
|
editing: false,
|
|
parentId: null,
|
|
parentName: '',
|
|
data: this._blankNode()
|
|
},
|
|
|
|
moveDialog: {
|
|
show: false,
|
|
nodeId: null,
|
|
nodeName: '',
|
|
newParentId: null
|
|
},
|
|
|
|
itemDialog: {
|
|
show: false,
|
|
data: this._blankItem(),
|
|
imagesText: '',
|
|
dietaryText: '',
|
|
allergensText: '',
|
|
ingredientsText: ''
|
|
},
|
|
|
|
modifiersDialog: {
|
|
show: false,
|
|
itemId: null,
|
|
itemName: '',
|
|
groups: []
|
|
}
|
|
}
|
|
},
|
|
|
|
computed: {
|
|
selectedNode() {
|
|
return this._findNode(this.tree, this.selectedNodeId)
|
|
},
|
|
filteredItems() {
|
|
if (!this.selectedNodeId) return this.enrichedItems
|
|
return this.enrichedItems.filter(
|
|
(i) => i.node_id === this.selectedNodeId
|
|
)
|
|
},
|
|
/** All nodes as a flat indented q-select option list, used for
|
|
* the item dialog's node_id picker and the move dialog. */
|
|
allNodeOptions() {
|
|
return this.flatNodes.map((n) => ({
|
|
label: `${'\u2003'.repeat(n.depth)}${n.name}`,
|
|
value: n.id
|
|
}))
|
|
},
|
|
/** Move-dialog targets exclude the node being moved + its
|
|
* descendants (cycle prevention is enforced server-side too,
|
|
* but we don't show the user options that would be rejected). */
|
|
moveTargetOptions() {
|
|
if (!this.moveDialog.nodeId) return this.allNodeOptions
|
|
const moving = this.flatNodes.find((n) => n.id === this.moveDialog.nodeId)
|
|
if (!moving) return this.allNodeOptions
|
|
const prefix = moving.path
|
|
return this.flatNodes
|
|
.filter(
|
|
(n) =>
|
|
n.id !== moving.id &&
|
|
n.path !== prefix &&
|
|
!n.path.startsWith(prefix + '/') &&
|
|
n.depth < this.maxDepth // can't add a child to a depth-3 node
|
|
)
|
|
.map((n) => ({
|
|
label: `${'\u2003'.repeat(n.depth)}${n.name}`,
|
|
value: n.id
|
|
}))
|
|
},
|
|
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: {
|
|
_blankNode() {
|
|
return {
|
|
id: null,
|
|
name: '',
|
|
description: '',
|
|
image_url: '',
|
|
sort_order: 0
|
|
}
|
|
},
|
|
_blankItem() {
|
|
return {
|
|
restaurant_id: '',
|
|
node_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)
|
|
},
|
|
_findNode(nodes, id) {
|
|
if (!id) return null
|
|
for (const n of nodes) {
|
|
if (n.id === id) return n
|
|
const inChild = this._findNode(n.children || [], id)
|
|
if (inChild) return inChild
|
|
}
|
|
return null
|
|
},
|
|
_flatten(nodes, out) {
|
|
for (const n of nodes) {
|
|
out.push({id: n.id, name: n.name, depth: n.depth, path: n.path})
|
|
this._flatten(n.children || [], out)
|
|
}
|
|
return out
|
|
},
|
|
|
|
// -------- fetch --------
|
|
async fetchMenu() {
|
|
try {
|
|
const {data} = await RestaurantAPI.getMenu(this.restaurant.id)
|
|
this.tree = data.tree || []
|
|
this.enrichedItems = data.items || []
|
|
this.flatNodes = this._flatten(this.tree, [])
|
|
// Auto-expand all on first load so the operator sees structure.
|
|
if (!this.expandedNodeIds.length && this.flatNodes.length) {
|
|
this.expandedNodeIds = this.flatNodes.map((n) => n.id)
|
|
}
|
|
} catch (err) {
|
|
LNbits.utils.notifyApiError(err)
|
|
}
|
|
},
|
|
|
|
// -------- nodes --------
|
|
openNodeDialog(existing, parent) {
|
|
if (existing) {
|
|
this.nodeDialog.editing = true
|
|
this.nodeDialog.parentId = null
|
|
this.nodeDialog.parentName = ''
|
|
this.nodeDialog.data = {
|
|
id: existing.id,
|
|
name: existing.name,
|
|
description: existing.description || '',
|
|
image_url: existing.image_url || '',
|
|
sort_order: existing.sort_order || 0
|
|
}
|
|
} else {
|
|
this.nodeDialog.editing = false
|
|
this.nodeDialog.parentId = parent ? parent.id : null
|
|
this.nodeDialog.parentName = parent ? parent.name : ''
|
|
this.nodeDialog.data = this._blankNode()
|
|
}
|
|
this.nodeDialog.show = true
|
|
},
|
|
async saveNode() {
|
|
const payload = {
|
|
restaurant_id: this.restaurant.id,
|
|
parent_id: this.nodeDialog.parentId,
|
|
name: this.nodeDialog.data.name,
|
|
description: this.nodeDialog.data.description || null,
|
|
image_url: this.nodeDialog.data.image_url || null,
|
|
sort_order: this.nodeDialog.data.sort_order || 0
|
|
}
|
|
try {
|
|
if (this.nodeDialog.editing) {
|
|
await RestaurantAPI.updateMenuNode(
|
|
this.adminkey,
|
|
this.nodeDialog.data.id,
|
|
payload
|
|
)
|
|
} else {
|
|
await RestaurantAPI.createMenuNode(this.adminkey, payload)
|
|
}
|
|
this.nodeDialog.show = false
|
|
await this.fetchMenu()
|
|
} catch (err) {
|
|
LNbits.utils.notifyApiError(err)
|
|
}
|
|
},
|
|
async deleteNode(node) {
|
|
const hasChildren = (node.children || []).length > 0
|
|
const hasItems = (node.items || []).length > 0
|
|
let cascade = false
|
|
if (hasChildren || hasItems) {
|
|
const msg = hasChildren && hasItems
|
|
? `${node.name} has child nodes AND items. Delete the whole subtree (items will be detached, not destroyed)?`
|
|
: hasChildren
|
|
? `${node.name} has child nodes. Delete the whole subtree?`
|
|
: `${node.name} has items. Detach them and delete the node?`
|
|
if (!confirm(msg)) return
|
|
cascade = true
|
|
} else {
|
|
if (!confirm(`Delete ${node.name}?`)) return
|
|
}
|
|
try {
|
|
await RestaurantAPI.deleteMenuNode(this.adminkey, node.id, cascade)
|
|
if (this.selectedNodeId === node.id) this.selectedNodeId = null
|
|
await this.fetchMenu()
|
|
} catch (err) {
|
|
LNbits.utils.notifyApiError(err)
|
|
}
|
|
},
|
|
|
|
// -------- move --------
|
|
openMoveDialog(node) {
|
|
this.moveDialog.nodeId = node.id
|
|
this.moveDialog.nodeName = node.name
|
|
this.moveDialog.newParentId = null
|
|
this.moveDialog.show = true
|
|
},
|
|
async confirmMove() {
|
|
try {
|
|
await RestaurantAPI.moveMenuNode(
|
|
this.adminkey,
|
|
this.moveDialog.nodeId,
|
|
this.moveDialog.newParentId || null
|
|
)
|
|
this.moveDialog.show = false
|
|
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.node_id && this.selectedNodeId) {
|
|
item.node_id = this.selectedNodeId
|
|
}
|
|
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)
|
|
}
|
|
// Strip the synthetic UI-only id when creating; the server sets it.
|
|
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)
|
|
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 _refreshModifiers() {
|
|
const {data: groups} = await RestaurantAPI.listModifierGroups(
|
|
this.modifiersDialog.itemId
|
|
)
|
|
for (const g of groups) {
|
|
const {data: mods} = await RestaurantAPI.listModifiers(g.id)
|
|
g._modifiers = mods
|
|
}
|
|
this.modifiersDialog.groups = groups
|
|
},
|
|
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._refreshModifiers()
|
|
} 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._refreshModifiers()
|
|
} 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._refreshModifiers()
|
|
} 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._refreshModifiers()
|
|
} catch (err) {
|
|
LNbits.utils.notifyApiError(err)
|
|
}
|
|
}
|
|
},
|
|
|
|
async created() {
|
|
await this.fetchMenu()
|
|
}
|
|
})
|