diff --git a/static/js/menu.js b/static/js/menu.js index 9c747e4..3b08d20 100644 --- a/static/js/menu.js +++ b/static/js/menu.js @@ -1,16 +1,46 @@ +/* + * Menu builder — q-tree + items panel. + * + * The server's GET /api/v1/restaurants/{id}/menu returns: + * { restaurant, tree: [], items: [] } + * `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 || {}, - categories: [], - items: [], - selectedCategoryId: null, - categoryDialog: { + 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, - data: {restaurant_id: '', name: '', description: ''} + editing: false, + parentId: null, + parentName: '', + data: this._blankNode() }, + + moveDialog: { + show: false, + nodeId: null, + nodeName: '', + newParentId: null + }, + itemDialog: { show: false, data: this._blankItem(), @@ -19,6 +49,7 @@ window.app = Vue.createApp({ allergensText: '', ingredientsText: '' }, + modifiersDialog: { show: false, itemId: null, @@ -27,24 +58,62 @@ window.app = Vue.createApp({ } } }, + computed: { - selectedCategory() { - return this.categories.find((c) => c.id === this.selectedCategoryId) + selectedNode() { + return this._findNode(this.tree, this.selectedNodeId) }, filteredItems() { - if (!this.selectedCategoryId) return this.items - return this.items.filter((i) => i.node_id === this.selectedCategoryId) + if (!this.selectedNodeId) return this.enrichedItems + return this.enrichedItems.filter( + (i) => i.node_id === this.selectedNodeId + ) }, - categoryOptions() { - return this.categories.map((c) => ({label: c.name, value: c.id})) + /** 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() { - // 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: { + _blankNode() { + return { + id: null, + name: '', + description: '', + image_url: '', + sort_order: 0 + } + }, _blankItem() { return { restaurant_id: '', @@ -76,41 +145,124 @@ window.app = Vue.createApp({ .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 + }, - // -------- categories -------- + // -------- fetch -------- 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 + 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) } }, - openCategoryDialog() { - this.categoryDialog.data = { - restaurant_id: this.restaurant.id, - name: '', - description: '' + + // -------- 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.categoryDialog.show = true + this.nodeDialog.show = true }, - async saveCategory() { + 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 { - await RestaurantAPI.createCategory(this.adminkey, this.categoryDialog.data) - this.categoryDialog.show = false + 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 deleteCategory(cat) { - if (!confirm(`Delete category ${cat.name}?`)) return + 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.deleteCategory(this.adminkey, cat.id) + 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) @@ -122,8 +274,8 @@ window.app = Vue.createApp({ const item = existing ? {...existing} : {...this._blankItem(), restaurant_id: this.restaurant.id} - if (!item.node_id && this.selectedCategoryId) { - item.node_id = this.selectedCategoryId + if (!item.node_id && this.selectedNodeId) { + item.node_id = this.selectedNodeId } this.itemDialog.data = item this.itemDialog.imagesText = (item.images || []).join(', ') @@ -140,6 +292,7 @@ window.app = Vue.createApp({ 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( @@ -172,7 +325,6 @@ window.app = Vue.createApp({ 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 @@ -183,6 +335,16 @@ window.app = Vue.createApp({ 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 @@ -199,10 +361,7 @@ window.app = Vue.createApp({ kind, selection }) - await this.openModifiersDialog({ - id: this.modifiersDialog.itemId, - name: this.modifiersDialog.itemName - }) + await this._refreshModifiers() } catch (err) { LNbits.utils.notifyApiError(err) } @@ -211,10 +370,7 @@ window.app = Vue.createApp({ 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 - }) + await this._refreshModifiers() } catch (err) { LNbits.utils.notifyApiError(err) } @@ -231,10 +387,7 @@ window.app = Vue.createApp({ name, price_delta }) - await this.openModifiersDialog({ - id: this.modifiersDialog.itemId, - name: this.modifiersDialog.itemName - }) + await this._refreshModifiers() } catch (err) { LNbits.utils.notifyApiError(err) } @@ -243,15 +396,13 @@ window.app = Vue.createApp({ 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 - }) + await this._refreshModifiers() } catch (err) { LNbits.utils.notifyApiError(err) } } }, + async created() { await this.fetchMenu() } diff --git a/templates/restaurant/menu.html b/templates/restaurant/menu.html index eb7a476..ab1d605 100644 --- a/templates/restaurant/menu.html +++ b/templates/restaurant/menu.html @@ -24,66 +24,127 @@ + + +
- -
- Categories - -
- - - - - - - - - - No categories - - - + + Menu tree + + + + + No nodes yet. Click "Top-level" to add a root category. + + + + +
- -
+ +
Items - - + + +
- No items in this category yet. + + No items at this node. Use "New item" to add one — items can + live on any node, not just leaves. + + Select a node on the left to see its items. @@ -146,29 +207,75 @@
- - - -
New category
- + + + +
+ Edit node + New node +
+ + + +
+ Adding under: +
- - + + +
+
+
+
+ + + + +
+ Move +
+ + +
+ +
@@ -177,16 +284,20 @@ -
{{ '{{ itemDialog.data.id ? "Edit" : "New" }}' }} item
+
+ Edit item + New item +