diff --git a/static/components/merchant-tab.js b/static/components/merchant-tab.js new file mode 100644 index 0000000..c82d853 --- /dev/null +++ b/static/components/merchant-tab.js @@ -0,0 +1,37 @@ +window.app.component('merchant-tab', { + name: 'merchant-tab', + template: '#merchant-tab', + delimiters: ['${', '}'], + props: [ + 'merchant-id', + 'inkey', + 'adminkey', + 'show-keys', + 'merchant-active', + 'public-key', + 'private-key', + 'is-admin' + ], + computed: { + marketClientUrl: function () { + return '/nostrmarket/market' + } + }, + methods: { + toggleShowKeys: function () { + this.$emit('toggle-show-keys') + }, + hideKeys: function () { + this.$emit('hide-keys') + }, + handleMerchantDeleted: function () { + this.$emit('merchant-deleted') + }, + toggleMerchantState: function () { + this.$emit('toggle-merchant-state') + }, + restartNostrConnection: function () { + this.$emit('restart-nostr-connection') + } + } +}) diff --git a/static/components/product-list.js b/static/components/product-list.js new file mode 100644 index 0000000..eb42281 --- /dev/null +++ b/static/components/product-list.js @@ -0,0 +1,256 @@ +window.app.component('product-list', { + name: 'product-list', + template: '#product-list', + delimiters: ['${', '}'], + props: ['adminkey', 'inkey', 'stall-filter'], + data: function () { + return { + filter: '', + stalls: [], + products: [], + pendingProducts: [], + selectedStall: null, + productDialog: { + showDialog: false, + showRestore: false, + data: null + }, + productsTable: { + columns: [ + {name: 'name', align: 'left', label: 'Name', field: 'name'}, + {name: 'stall', align: 'left', label: 'Stall', field: 'stall_id'}, + {name: 'price', align: 'left', label: 'Price', field: 'price'}, + {name: 'quantity', align: 'left', label: 'Quantity', field: 'quantity'}, + {name: 'actions', align: 'right', label: 'Actions', field: ''} + ], + pagination: { + rowsPerPage: 10 + } + } + } + }, + computed: { + stallOptions: function () { + return this.stalls.map(s => ({ + label: s.name, + value: s.id + })) + }, + filteredProducts: function () { + if (!this.selectedStall) { + return this.products + } + return this.products.filter(p => p.stall_id === this.selectedStall) + } + }, + watch: { + stallFilter: { + immediate: true, + handler(newVal) { + if (newVal) { + this.selectedStall = newVal + } + } + } + }, + methods: { + getStalls: async function () { + try { + const {data} = await LNbits.api.request( + 'GET', + '/nostrmarket/api/v1/stall?pending=false', + this.inkey + ) + this.stalls = data + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + getProducts: async function () { + try { + // Fetch products from all stalls + const allProducts = [] + for (const stall of this.stalls) { + const {data} = await LNbits.api.request( + 'GET', + `/nostrmarket/api/v1/stall/product/${stall.id}?pending=false`, + this.inkey + ) + allProducts.push(...data) + } + this.products = allProducts + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + getPendingProducts: async function () { + try { + // Fetch pending products from all stalls + const allPending = [] + for (const stall of this.stalls) { + const {data} = await LNbits.api.request( + 'GET', + `/nostrmarket/api/v1/stall/product/${stall.id}?pending=true`, + this.inkey + ) + allPending.push(...data) + } + this.pendingProducts = allPending + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + getStallName: function (stallId) { + const stall = this.stalls.find(s => s.id === stallId) + return stall ? stall.name : 'Unknown' + }, + getStallCurrency: function (stallId) { + const stall = this.stalls.find(s => s.id === stallId) + return stall ? stall.currency : 'sat' + }, + getStall: function (stallId) { + return this.stalls.find(s => s.id === stallId) + }, + newEmptyProductData: function () { + return { + id: null, + stall_id: this.stalls.length ? this.stalls[0].id : null, + name: '', + categories: [], + images: [], + image: null, + price: 0, + quantity: 0, + config: { + description: '', + use_autoreply: false, + autoreply_message: '' + } + } + }, + showNewProductDialog: function () { + this.productDialog.data = this.newEmptyProductData() + this.productDialog.showDialog = true + }, + editProduct: function (product) { + this.productDialog.data = {...product, image: null} + if (!this.productDialog.data.config) { + this.productDialog.data.config = {description: ''} + } + this.productDialog.showDialog = true + }, + sendProductFormData: async function () { + const data = { + stall_id: this.productDialog.data.stall_id, + id: this.productDialog.data.id, + name: this.productDialog.data.name, + images: this.productDialog.data.images || [], + price: this.productDialog.data.price, + quantity: this.productDialog.data.quantity, + categories: this.productDialog.data.categories || [], + config: this.productDialog.data.config + } + this.productDialog.showDialog = false + + if (this.productDialog.data.id) { + data.pending = false + await this.updateProduct(data) + } else { + await this.createProduct(data) + } + }, + createProduct: async function (payload) { + try { + const {data} = await LNbits.api.request( + 'POST', + '/nostrmarket/api/v1/product', + this.adminkey, + payload + ) + this.products.unshift(data) + this.$q.notify({ + type: 'positive', + message: 'Product Created' + }) + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + updateProduct: async function (product) { + try { + const {data} = await LNbits.api.request( + 'PATCH', + '/nostrmarket/api/v1/product/' + product.id, + this.adminkey, + product + ) + const index = this.products.findIndex(p => p.id === product.id) + if (index !== -1) { + this.products.splice(index, 1, data) + } else { + this.products.unshift(data) + } + this.$q.notify({ + type: 'positive', + message: 'Product Updated' + }) + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + deleteProduct: function (product) { + LNbits.utils + .confirmDialog(`Are you sure you want to delete "${product.name}"?`) + .onOk(async () => { + try { + await LNbits.api.request( + 'DELETE', + '/nostrmarket/api/v1/product/' + product.id, + this.adminkey + ) + this.products = this.products.filter(p => p.id !== product.id) + this.$q.notify({ + type: 'positive', + message: 'Product Deleted' + }) + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }) + }, + toggleProductActive: async function (product) { + await this.updateProduct({...product, active: !product.active}) + }, + addProductImage: function () { + if (!this.productDialog.data.image) return + if (!this.productDialog.data.images) { + this.productDialog.data.images = [] + } + this.productDialog.data.images.push(this.productDialog.data.image) + this.productDialog.data.image = null + }, + removeProductImage: function (imageUrl) { + const index = this.productDialog.data.images.indexOf(imageUrl) + if (index !== -1) { + this.productDialog.data.images.splice(index, 1) + } + }, + openSelectPendingProductDialog: async function () { + await this.getPendingProducts() + this.productDialog.showRestore = true + }, + openRestoreProductDialog: function (pendingProduct) { + pendingProduct.pending = true + this.productDialog.data = {...pendingProduct, image: null} + this.productDialog.showDialog = true + }, + shortLabel: function (value = '') { + if (value.length <= 44) return value + return value.substring(0, 40) + '...' + } + }, + created: async function () { + await this.getStalls() + await this.getProducts() + } +}) diff --git a/static/components/shipping-zones-list.js b/static/components/shipping-zones-list.js new file mode 100644 index 0000000..a8cf58f --- /dev/null +++ b/static/components/shipping-zones-list.js @@ -0,0 +1,203 @@ +window.app.component('shipping-zones-list', { + name: 'shipping-zones-list', + props: ['adminkey', 'inkey'], + template: '#shipping-zones-list', + delimiters: ['${', '}'], + data: function () { + return { + zones: [], + zoneDialog: { + showDialog: false, + data: { + id: null, + name: '', + countries: [], + cost: 0, + currency: 'sat' + } + }, + currencies: [], + shippingZoneOptions: [ + 'Free (digital)', + 'Flat rate', + 'Worldwide', + 'Europe', + 'Australia', + 'Austria', + 'Belgium', + 'Brazil', + 'Canada', + 'Denmark', + 'Finland', + 'France', + 'Germany', + 'Greece', + 'Hong Kong', + 'Hungary', + 'Ireland', + 'Indonesia', + 'Israel', + 'Italy', + 'Japan', + 'Kazakhstan', + 'Korea', + 'Luxembourg', + 'Malaysia', + 'Mexico', + 'Netherlands', + 'New Zealand', + 'Norway', + 'Poland', + 'Portugal', + 'Romania', + 'Russia', + 'Saudi Arabia', + 'Singapore', + 'Spain', + 'Sweden', + 'Switzerland', + 'Thailand', + 'Turkey', + 'Ukraine', + 'United Kingdom**', + 'United States***', + 'Vietnam', + 'China' + ], + zonesTable: { + columns: [ + { + name: 'name', + align: 'left', + label: 'Name', + field: 'name' + }, + { + name: 'countries', + align: 'left', + label: 'Countries', + field: 'countries' + }, + { + name: 'currency', + align: 'left', + label: 'Currency', + field: 'currency' + }, + { + name: 'cost', + align: 'left', + label: 'Cost', + field: 'cost' + }, + { + name: 'actions', + align: 'right', + label: 'Actions', + field: '' + } + ], + pagination: { + rowsPerPage: 10 + } + } + } + }, + methods: { + openZoneDialog: function (data) { + data = data || { + id: null, + name: '', + countries: [], + cost: 0, + currency: 'sat' + } + this.zoneDialog.data = {...data} + this.zoneDialog.showDialog = true + }, + getZones: async function () { + try { + const {data} = await LNbits.api.request( + 'GET', + '/nostrmarket/api/v1/zone', + this.inkey + ) + this.zones = data + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + sendZoneFormData: async function () { + this.zoneDialog.showDialog = false + if (this.zoneDialog.data.id) { + await this.updateShippingZone(this.zoneDialog.data) + } else { + await this.createShippingZone(this.zoneDialog.data) + } + await this.getZones() + }, + createShippingZone: async function (newZone) { + try { + await LNbits.api.request( + 'POST', + '/nostrmarket/api/v1/zone', + this.adminkey, + newZone + ) + this.$q.notify({ + type: 'positive', + message: 'Zone created!' + }) + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + updateShippingZone: async function (updatedZone) { + try { + await LNbits.api.request( + 'PATCH', + `/nostrmarket/api/v1/zone/${updatedZone.id}`, + this.adminkey, + updatedZone + ) + this.$q.notify({ + type: 'positive', + message: 'Zone updated!' + }) + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + confirmDeleteZone: function (zone) { + LNbits.utils + .confirmDialog(`Are you sure you want to delete zone "${zone.name}"?`) + .onOk(async () => { + await this.deleteShippingZone(zone.id) + }) + }, + deleteShippingZone: async function (zoneId) { + try { + await LNbits.api.request( + 'DELETE', + `/nostrmarket/api/v1/zone/${zoneId}`, + this.adminkey + ) + this.$q.notify({ + type: 'positive', + message: 'Zone deleted!' + }) + await this.getZones() + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + getCurrencies() { + const currencies = window.g.allowedCurrencies || [] + this.currencies = ['sat', ...currencies] + } + }, + created: async function () { + await this.getZones() + this.getCurrencies() + } +}) diff --git a/static/components/stall-list.js b/static/components/stall-list.js index 1ef4d70..4108a56 100644 --- a/static/components/stall-list.js +++ b/static/components/stall-list.js @@ -2,7 +2,7 @@ window.app.component('stall-list', { name: 'stall-list', template: '#stall-list', delimiters: ['${', '}'], - props: [`adminkey`, 'inkey', 'wallet-options'], + props: ['adminkey', 'inkey', 'wallet-options'], data: function () { return { filter: '', @@ -20,39 +20,25 @@ window.app.component('stall-list', { shippingZones: [] } }, + editDialog: { + show: false, + data: { + id: '', + name: '', + description: '', + wallet: null, + currency: 'sat', + shippingZones: [] + } + }, zoneOptions: [], stallsTable: { columns: [ - { - name: '', - align: 'left', - label: '', - field: '' - }, - { - name: 'id', - align: 'left', - label: 'Name', - field: 'id' - }, - { - name: 'currency', - align: 'left', - label: 'Currency', - field: 'currency' - }, - { - name: 'description', - align: 'left', - label: 'Description', - field: 'description' - }, - { - name: 'shippingZones', - align: 'left', - label: 'Shipping Zones', - field: 'shippingZones' - } + {name: 'name', align: 'left', label: 'Name', field: 'name'}, + {name: 'currency', align: 'left', label: 'Currency', field: 'currency'}, + {name: 'description', align: 'left', label: 'Description', field: row => row.config?.description || ''}, + {name: 'shippingZones', align: 'left', label: 'Shipping Zones', field: row => row.shipping_zones?.map(z => z.name).join(', ') || ''}, + {name: 'actions', align: 'right', label: 'Actions', field: ''} ], pagination: { rowsPerPage: 10 @@ -65,6 +51,11 @@ window.app.component('stall-list', { return this.zoneOptions.filter( z => z.currency === this.stallDialog.data.currency ) + }, + editFilteredZoneOptions: function () { + return this.zoneOptions.filter( + z => z.currency === this.editDialog.data.currency + ) } }, methods: { @@ -94,7 +85,6 @@ window.app.component('stall-list', { stall ) this.stallDialog.show = false - data.expanded = false this.stalls.unshift(data) this.$q.notify({ type: 'positive', @@ -114,7 +104,6 @@ window.app.component('stall-list', { stallData ) this.stallDialog.show = false - data.expanded = false this.stalls.unshift(data) this.$q.notify({ type: 'positive', @@ -124,44 +113,66 @@ window.app.component('stall-list', { LNbits.utils.notifyApiError(error) } }, - deleteStall: async function (pendingStall) { - LNbits.utils - .confirmDialog( - ` - Are you sure you want to delete this pending stall '${pendingStall.name}'? - ` - ) - .onOk(async () => { - try { - await LNbits.api.request( - 'DELETE', - '/nostrmarket/api/v1/stall/' + pendingStall.id, - this.adminkey - ) - this.$q.notify({ - type: 'positive', - message: 'Pending Stall Deleted', - timeout: 5000 - }) - } catch (error) { - console.warn(error) - LNbits.utils.notifyApiError(error) - } - }) - }, - getCurrencies: async function () { + updateStall: async function () { try { + const stallData = { + id: this.editDialog.data.id, + name: this.editDialog.data.name, + wallet: this.editDialog.data.wallet, + currency: this.editDialog.data.currency, + shipping_zones: this.editDialog.data.shippingZones, + config: { + description: this.editDialog.data.description + } + } const {data} = await LNbits.api.request( - 'GET', - '/nostrmarket/api/v1/currencies', - this.inkey + 'PUT', + `/nostrmarket/api/v1/stall/${stallData.id}`, + this.adminkey, + stallData ) - - return ['sat', ...data] + this.editDialog.show = false + const index = this.stalls.findIndex(s => s.id === data.id) + if (index !== -1) { + this.stalls.splice(index, 1, data) + } + this.$q.notify({ + type: 'positive', + message: 'Stall updated!' + }) } catch (error) { LNbits.utils.notifyApiError(error) } - return [] + }, + deleteStall: async function (stall) { + try { + await LNbits.api.request( + 'DELETE', + '/nostrmarket/api/v1/stall/' + stall.id, + this.adminkey + ) + this.stalls = this.stalls.filter(s => s.id !== stall.id) + this.pendingStalls = this.pendingStalls.filter(s => s.id !== stall.id) + this.$q.notify({ + type: 'positive', + message: 'Stall deleted' + }) + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + confirmDeleteStall: function (stall) { + LNbits.utils + .confirmDialog( + `Products and orders will be deleted also! Are you sure you want to delete stall "${stall.name}"?` + ) + .onOk(async () => { + await this.deleteStall(stall) + }) + }, + getCurrencies: function () { + const currencies = window.g.allowedCurrencies || [] + return ['sat', ...currencies] }, getStalls: async function (pending = false) { try { @@ -170,7 +181,7 @@ window.app.component('stall-list', { `/nostrmarket/api/v1/stall?pending=${pending}`, this.inkey ) - return data.map(s => ({...s, expanded: false})) + return data } catch (error) { LNbits.utils.notifyApiError(error) } @@ -194,20 +205,8 @@ window.app.component('stall-list', { } return [] }, - handleStallDeleted: function (stallId) { - this.stalls = _.reject(this.stalls, function (obj) { - return obj.id === stallId - }) - }, - handleStallUpdated: function (stall) { - const index = this.stalls.findIndex(r => r.id === stall.id) - if (index !== -1) { - stall.expanded = true - this.stalls.splice(index, 1, stall) - } - }, openCreateStallDialog: async function (stallData) { - this.currencies = await this.getCurrencies() + this.currencies = this.getCurrencies() this.zoneOptions = await this.getZones() if (!this.zoneOptions || !this.zoneOptions.length) { this.$q.notify({ @@ -225,6 +224,24 @@ window.app.component('stall-list', { } this.stallDialog.show = true }, + openEditStallDialog: async function (stall) { + this.currencies = this.getCurrencies() + this.zoneOptions = await this.getZones() + this.editDialog.data = { + id: stall.id, + name: stall.name, + description: stall.config?.description || '', + wallet: stall.wallet, + currency: stall.currency, + shippingZones: (stall.shipping_zones || []).map(z => ({ + ...z, + label: z.name + ? `${z.name} (${z.countries.join(', ')})` + : z.countries.join(', ') + })) + } + this.editDialog.show = true + }, openSelectPendingStallDialog: async function () { this.stallDialog.showRestore = true this.pendingStalls = await this.getStalls(true) @@ -246,8 +263,11 @@ window.app.component('stall-list', { })) }) }, - customerSelectedForOrder: function (customerPubkey) { - this.$emit('customer-selected-for-order', customerPubkey) + goToProducts: function (stall) { + this.$emit('go-to-products', stall.id) + }, + goToOrders: function (stall) { + this.$emit('go-to-orders', stall.id) }, shortLabel(value = '') { if (value.length <= 64) return value @@ -256,7 +276,7 @@ window.app.component('stall-list', { }, created: async function () { this.stalls = await this.getStalls() - this.currencies = await this.getCurrencies() + this.currencies = this.getCurrencies() this.zoneOptions = await this.getZones() } }) diff --git a/static/js/index.js b/static/js/index.js index 3d89779..b9861ef 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -5,6 +5,8 @@ window.app = Vue.createApp({ mixins: [window.windowMixin], data: function () { return { + activeTab: 'merchant', + selectedStallFilter: null, merchant: {}, shippingZones: [], activeChatCustomer: '', @@ -212,6 +214,86 @@ window.app = Vue.createApp({ LNbits.utils.notifyApiError(error) } }) + }, + publishNip15: async function () { + try { + const {data: stalls} = await LNbits.api.request( + 'GET', + '/nostrmarket/api/v1/stall?pending=false', + this.g.user.wallets[0].inkey + ) + for (const stall of stalls) { + await LNbits.api.request( + 'PUT', + `/nostrmarket/api/v1/stall/${stall.id}`, + this.g.user.wallets[0].adminkey, + stall + ) + } + // Fetch products from all stalls + let productCount = 0 + for (const stall of stalls) { + const {data: products} = await LNbits.api.request( + 'GET', + `/nostrmarket/api/v1/stall/product/${stall.id}?pending=false`, + this.g.user.wallets[0].inkey + ) + for (const product of products) { + await LNbits.api.request( + 'PATCH', + `/nostrmarket/api/v1/product/${product.id}`, + this.g.user.wallets[0].adminkey, + product + ) + productCount++ + } + } + this.$q.notify({ + type: 'positive', + message: `Published ${stalls.length} stall(s) and ${productCount} product(s) to Nostr (NIP-15)` + }) + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + refreshNip15: async function () { + LNbits.utils + .confirmDialog('This will sync your stalls and products from Nostr relays. Continue?') + .onOk(async () => { + try { + await LNbits.api.request( + 'PUT', + '/nostrmarket/api/v1/restart', + this.g.user.wallets[0].adminkey + ) + this.$q.notify({ + type: 'positive', + message: 'Refreshing NIP-15 data from Nostr...' + }) + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }) + }, + deleteNip15: async function () { + LNbits.utils + .confirmDialog( + 'WARNING: This will delete all your stalls and products from Nostr relays. This cannot be undone! Are you sure?' + ) + .onOk(async () => { + this.$q.notify({ + type: 'info', + message: 'Delete NIP-15 from Nostr not yet implemented' + }) + }) + }, + goToProducts: function (stallId) { + this.selectedStallFilter = stallId + this.activeTab = 'products' + }, + goToOrders: function (stallId) { + this.selectedStallFilter = stallId + this.activeTab = 'orders' } }, created: async function () { diff --git a/templates/nostrmarket/components/merchant-tab.html b/templates/nostrmarket/components/merchant-tab.html new file mode 100644 index 0000000..ef1be39 --- /dev/null +++ b/templates/nostrmarket/components/merchant-tab.html @@ -0,0 +1,96 @@ +
+
+
+
+
+ +
+
+
+ + +
+
+
+ + + Restart the connection to the nostrclient extension + + +
+
+
+
+
+ Hide Keys +
+
+
+
+ +
+
+
+
+
+ + +
Nostr Market Extension
+
+ + + + + + + A decentralized marketplace powered by Nostr and Lightning Network. + Create stalls, add products, and start selling with Bitcoin. + + + + + + + Open Market Client + + + + + +
+
+
+
diff --git a/templates/nostrmarket/components/product-list.html b/templates/nostrmarket/components/product-list.html new file mode 100644 index 0000000..292db52 --- /dev/null +++ b/templates/nostrmarket/components/product-list.html @@ -0,0 +1,289 @@ +
+
+
+ + + +
+
+ + + + + All Stalls + + + + + + + + + + + + + + + + +
+
+ +
+
+ +
+
+ +
+ +
No stalls found. Please create a stall first in the Stalls tab.
+
+ + + + + + + + + + + + + + + +
+
+ +
+
+ +
+
+ + +
+ +
+
+ + +
+ + + + + + +
+
+ +
+ + Create Product + Cancel +
+
+
+
+ + + + +
+ + + + + + + Restore + + +
+
There are no products to be restored.
+
+ Close +
+
+
+
diff --git a/templates/nostrmarket/components/shipping-zones-list.html b/templates/nostrmarket/components/shipping-zones-list.html new file mode 100644 index 0000000..2ff9f28 --- /dev/null +++ b/templates/nostrmarket/components/shipping-zones-list.html @@ -0,0 +1,122 @@ +
+
+
+ +
+
+ + + + + + + + + + + + +
+
+ Update +
+
+ Create Shipping Zone +
+ + Cancel +
+
+
+
+
diff --git a/templates/nostrmarket/components/stall-list.html b/templates/nostrmarket/components/stall-list.html index 673e8a7..36b1db9 100644 --- a/templates/nostrmarket/components/stall-list.html +++ b/templates/nostrmarket/components/stall-list.html @@ -1,43 +1,38 @@
-
-
- - - - New Stall - Create a new stall - - - - - Restore Stall - Restore existing stall from Nostr - - - +
+
-