diff --git a/static/js/api.js b/static/js/api.js new file mode 100644 index 0000000..2ae4e15 --- /dev/null +++ b/static/js/api.js @@ -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) + } +})() diff --git a/static/js/index.js b/static/js/index.js new file mode 100644 index 0000000..b31c029 --- /dev/null +++ b/static/js/index.js @@ -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() + } +}) diff --git a/static/js/kds.js b/static/js/kds.js new file mode 100644 index 0000000..bc0980d --- /dev/null +++ b/static/js/kds.js @@ -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) + } +}) diff --git a/static/js/menu.js b/static/js/menu.js new file mode 100644 index 0000000..ed645c9 --- /dev/null +++ b/static/js/menu.js @@ -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() + } +}) diff --git a/static/js/orders.js b/static/js/orders.js new file mode 100644 index 0000000..0ca82de --- /dev/null +++ b/static/js/orders.js @@ -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) + } +}) diff --git a/static/js/settings.js b/static/js/settings.js new file mode 100644 index 0000000..1a29b69 --- /dev/null +++ b/static/js/settings.js @@ -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() + } +}) diff --git a/templates/restaurant/index.html b/templates/restaurant/index.html new file mode 100644 index 0000000..c12bde3 --- /dev/null +++ b/templates/restaurant/index.html @@ -0,0 +1,146 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} +