Merge pull request #129 from BenGWeeks/feature/tab-navigation-119

feat: restructure UI with tab-based navigation
This commit is contained in:
Arc 2025-12-24 03:53:20 +00:00 committed by GitHub
commit db550bc9dd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 1707 additions and 310 deletions

View file

@ -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')
}
}
})

View file

@ -0,0 +1,261 @@
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()
}
})

View file

@ -0,0 +1,209 @@
window.app.component('shipping-zones-list', {
name: 'shipping-zones-list',
props: ['adminkey', 'inkey'],
template: '#shipping-zones-list',
delimiters: ['${', '}'],
data: function () {
return {
zones: [],
filter: '',
zoneDialog: {
showDialog: false,
data: {
id: null,
name: '',
countries: [],
cost: 0,
currency: 'sat'
}
},
currencies: [],
shippingZoneOptions: [
'Free (digital)',
'Worldwide',
'Europe',
'Australia',
'Austria',
'Belgium',
'Brazil',
'Canada',
'China',
'Denmark',
'Finland',
'France',
'Germany',
'Greece',
'Hong Kong',
'Hungary',
'Indonesia',
'Ireland',
'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'
],
zonesTable: {
columns: [
{
name: 'name',
align: 'left',
label: 'Name',
field: 'name',
sortable: true
},
{
name: 'countries',
align: 'left',
label: 'Countries',
field: 'countries',
sortable: true
},
{
name: 'cost',
align: 'left',
label: 'Cost',
field: 'cost',
sortable: true
},
{
name: 'currency',
align: 'left',
label: 'Currency',
field: 'currency',
sortable: true
},
{
name: 'actions',
align: 'right',
label: 'Actions',
field: ''
}
],
pagination: {
rowsPerPage: 10,
sortBy: 'name',
descending: false
}
}
}
},
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()
}
})

View file

@ -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,21 +20,21 @@ 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: 'name', align: 'left', label: 'Name', field: 'name'},
{
name: 'currency',
align: 'left',
@ -45,14 +45,15 @@ window.app.component('stall-list', {
name: 'description',
align: 'left',
label: 'Description',
field: 'description'
field: row => row.config?.description || ''
},
{
name: 'shippingZones',
align: 'left',
label: 'Shipping Zones',
field: 'shippingZones'
}
field: row => row.shipping_zones?.map(z => z.name).join(', ') || ''
},
{name: 'actions', align: 'right', label: 'Actions', field: ''}
],
pagination: {
rowsPerPage: 10
@ -65,6 +66,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 +100,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 +119,6 @@ window.app.component('stall-list', {
stallData
)
this.stallDialog.show = false
data.expanded = false
this.stalls.unshift(data)
this.$q.notify({
type: 'positive',
@ -124,29 +128,61 @@ 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}'?
`
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(
'PUT',
`/nostrmarket/api/v1/stall/${stallData.id}`,
this.adminkey,
stallData
)
.onOk(async () => {
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)
}
},
deleteStall: async function (stall) {
try {
await LNbits.api.request(
'DELETE',
'/nostrmarket/api/v1/stall/' + pendingStall.id,
'/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: 'Pending Stall Deleted',
timeout: 5000
message: 'Stall deleted'
})
} catch (error) {
console.warn(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 () {
@ -160,7 +196,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)
}
@ -184,18 +220,6 @@ 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 = this.getCurrencies()
this.zoneOptions = await this.getZones()
@ -215,6 +239,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)
@ -236,8 +278,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

View file

@ -5,6 +5,8 @@ window.app = Vue.createApp({
mixins: [window.windowMixin],
data: function () {
return {
activeTab: 'merchant',
selectedStallFilter: null,
merchant: {},
shippingZones: [],
activeChatCustomer: '',
@ -212,6 +214,88 @@ 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 () {

View file

@ -0,0 +1,104 @@
<div>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-8">
<div class="row items-center q-col-gutter-sm q-mb-md">
<div class="col-12 col-sm-auto">
<merchant-details
:merchant-id="merchantId"
:inkey="inkey"
:adminkey="adminkey"
:show-keys="showKeys"
@toggle-show-keys="toggleShowKeys"
@merchant-deleted="handleMerchantDeleted"
></merchant-details>
</div>
<div class="col-12 col-sm-auto q-mx-sm">
<div class="row items-center no-wrap">
<q-toggle
:model-value="merchantActive"
@update:model-value="toggleMerchantState()"
size="md"
checked-icon="check"
color="primary"
unchecked-icon="clear"
/>
<span
class="q-ml-sm"
v-text="merchantActive ? 'Accepting Orders': 'Orders Paused'"
></span>
</div>
</div>
<div v-if="isAdmin" class="col-12 col-sm-auto q-ml-sm-auto">
<q-btn
label="Restart Nostr Connection"
color="grey"
outline
size="sm"
@click="restartNostrConnection"
>
<q-tooltip>
Restart the connection to the nostrclient extension
</q-tooltip>
</q-btn>
</div>
</div>
<div v-if="showKeys" class="q-mt-md">
<div class="row q-mb-md">
<div class="col">
<q-btn
unelevated
color="grey"
outline
@click="hideKeys"
class="float-left"
>Hide Keys</q-btn
>
</div>
</div>
<div class="row">
<div class="col">
<key-pair
:public-key="publicKey"
:private-key="privateKey"
></key-pair>
</div>
</div>
</div>
</div>
<div class="col-12 col-md-4">
<q-card flat bordered>
<q-card-section>
<h6 class="text-subtitle1 q-my-none">Nostr Market Extension</h6>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list>
<q-expansion-item group="api" dense icon="info" label="About">
<q-card>
<q-card-section>
A decentralized marketplace powered by Nostr and Lightning
Network. Create stalls, add products, and start selling with
Bitcoin.
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
icon="link"
label="Market Client"
>
<q-card>
<q-card-section>
<a :href="marketClientUrl" target="_blank"
>Open Market Client</a
>
</q-card-section>
</q-card>
</q-expansion-item>
</q-list>
</q-card-section>
</q-card>
</div>
</div>
</div>

View file

@ -0,0 +1,309 @@
<div>
<div class="row items-center q-mb-md q-col-gutter-sm justify-end">
<div class="col-auto">
<q-input
dense
debounce="300"
v-model="filter"
placeholder="Search by name, stall..."
style="min-width: 250px"
>
<template v-slot:prepend>
<q-icon name="search"></q-icon>
</template>
</q-input>
</div>
<div class="col-auto">
<q-btn-dropdown
flat
dense
:icon="selectedStall ? 'filter_alt' : 'filter_alt_off'"
:color="selectedStall ? 'primary' : 'grey'"
>
<q-list>
<q-item clickable v-close-popup @click="selectedStall = null">
<q-item-section>
<q-item-label>All Stalls</q-item-label>
</q-item-section>
<q-item-section side v-if="!selectedStall">
<q-icon name="check" color="primary"></q-icon>
</q-item-section>
</q-item>
<q-separator></q-separator>
<q-item
v-for="stall in stallOptions"
:key="stall.value"
clickable
v-close-popup
@click="selectedStall = stall.value"
>
<q-item-section>
<q-item-label v-text="stall.label"></q-item-label>
</q-item-section>
<q-item-section side v-if="selectedStall === stall.value">
<q-icon name="check" color="primary"></q-icon>
</q-item-section>
</q-item>
</q-list>
</q-btn-dropdown>
</div>
<div class="col-auto">
<q-btn
@click="openSelectPendingProductDialog"
outline
color="primary"
icon="restore"
label="Restore Product"
:disable="!stalls.length"
class="q-px-md"
></q-btn>
</div>
<div class="col-auto">
<q-btn
@click="showNewProductDialog()"
unelevated
color="primary"
icon="add"
label="New Product"
:disable="!stalls.length"
class="q-px-md"
></q-btn>
</div>
</div>
<div v-if="!stalls.length" class="text-center q-pa-lg text-grey">
<q-icon name="info" size="md" class="q-mb-sm"></q-icon>
<div>No stalls found. Please create a stall first in the Stalls tab.</div>
</div>
<q-table
v-else
flat
dense
:rows="filteredProducts"
row-key="id"
:columns="productsTable.columns"
v-model:pagination="productsTable.pagination"
:filter="filter"
>
<template v-slot:body="props">
<q-tr :props="props">
<q-td key="name" :props="props">
<span v-text="shortLabel(props.row.name)"></span>
</q-td>
<q-td key="stall" :props="props">
<span v-text="getStallName(props.row.stall_id)"></span>
</q-td>
<q-td key="price" :props="props">
<span v-text="props.row.price"></span>
</q-td>
<q-td key="quantity" :props="props">
<span v-text="props.row.quantity"></span>
</q-td>
<q-td key="actions" :props="props">
<q-toggle
@update:model-value="toggleProductActive(props.row)"
size="xs"
checked-icon="check"
:model-value="props.row.active"
color="green"
unchecked-icon="clear"
>
<q-tooltip v-if="props.row.active"
>Product is active - click to deactivate</q-tooltip
>
<q-tooltip v-else
>Product is inactive - click to activate</q-tooltip
>
</q-toggle>
<q-btn
size="sm"
color="primary"
dense
flat
@click="editProduct(props.row)"
icon="edit"
>
<q-tooltip>Edit product</q-tooltip>
</q-btn>
<q-btn
size="sm"
color="negative"
dense
flat
@click="deleteProduct(props.row)"
icon="delete"
>
<q-tooltip>Delete product</q-tooltip>
</q-btn>
</q-td>
</q-tr>
</template>
</q-table>
<!-- Product Dialog -->
<q-dialog v-model="productDialog.showDialog" position="top">
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
<q-form @submit="sendProductFormData" class="q-gutter-md">
<q-select
v-if="!productDialog.data.id"
filled
dense
v-model="productDialog.data.stall_id"
:options="stallOptions"
label="Stall *"
emit-value
map-options
></q-select>
<q-input
filled
dense
v-model.trim="productDialog.data.name"
label="Name *"
></q-input>
<q-input
filled
dense
v-model.trim="productDialog.data.config.description"
label="Description"
></q-input>
<div class="row q-mb-sm">
<div class="col">
<q-input
filled
dense
v-model.number="productDialog.data.price"
type="number"
:label="'Price (' + getStallCurrency(productDialog.data.stall_id) + ') *'"
:step="getStallCurrency(productDialog.data.stall_id) != 'sat' ? '0.01' : '1'"
></q-input>
</div>
<div class="col q-ml-md">
<q-input
filled
dense
v-model.number="productDialog.data.quantity"
type="number"
label="Quantity *"
></q-input>
</div>
</div>
<q-expansion-item
group="advanced"
label="Categories"
caption="Add tags to products"
>
<div class="q-pl-sm q-pt-sm">
<q-select
filled
multiple
dense
emit-value
v-model.trim="productDialog.data.categories"
use-input
use-chips
hide-dropdown-icon
input-debounce="0"
new-value-mode="add-unique"
label="Categories (Hit Enter to add)"
></q-select>
</div>
</q-expansion-item>
<q-expansion-item
group="advanced"
label="Images"
caption="Add images for product"
>
<div class="q-pl-sm q-pt-sm">
<q-input
filled
dense
v-model.trim="productDialog.data.image"
@keydown.enter.prevent="addProductImage"
type="url"
label="Image URL"
>
<template v-slot:append>
<q-btn @click="addProductImage" dense flat icon="add"></q-btn>
</template>
</q-input>
<q-chip
v-for="imageUrl in productDialog.data.images"
:key="imageUrl"
removable
@remove="removeProductImage(imageUrl)"
color="primary"
text-color="white"
>
<span v-text="imageUrl.split('/').pop()"></span>
</q-chip>
</div>
</q-expansion-item>
<div class="row q-mt-lg">
<q-btn
v-if="productDialog.data.id"
type="submit"
:label="productDialog.data.pending ? 'Restore Product' : 'Update Product'"
unelevated
color="primary"
></q-btn>
<q-btn
v-else
unelevated
color="primary"
:disable="!productDialog.data.stall_id || !productDialog.data.price || !productDialog.data.name || !productDialog.data.quantity"
type="submit"
>Create Product</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
<!-- Restore Dialog -->
<q-dialog v-model="productDialog.showRestore" position="top">
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
<div v-if="pendingProducts && pendingProducts.length" class="row q-mt-lg">
<q-item
v-for="pendingProduct of pendingProducts"
:key="pendingProduct.id"
tag="label"
class="full-width"
v-ripple
>
<q-item-section>
<q-item-label
><span v-text="pendingProduct.name"></span
></q-item-label>
<q-item-label caption
><span v-text="pendingProduct.config?.description"></span
></q-item-label>
</q-item-section>
<q-item-section class="q-pl-xl float-right">
<q-btn
@click="openRestoreProductDialog(pendingProduct)"
v-close-popup
flat
color="green"
class="q-ml-auto float-right"
>Restore</q-btn
>
</q-item-section>
</q-item>
</div>
<div v-else>There are no products to be restored.</div>
<div class="row q-mt-lg">
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</div>
</q-card>
</q-dialog>
</div>

View file

@ -0,0 +1,136 @@
<div>
<div class="row items-center q-mb-md q-col-gutter-sm justify-end">
<div class="col-auto">
<q-input
dense
debounce="300"
v-model="filter"
placeholder="Search zones..."
style="min-width: 200px"
>
<template v-slot:prepend>
<q-icon name="search"></q-icon>
</template>
</q-input>
</div>
<div class="col-auto">
<q-btn
unelevated
color="primary"
icon="add"
label="New Shipping Zone"
@click="openZoneDialog()"
class="q-px-md"
></q-btn>
</div>
</div>
<q-table
flat
dense
:rows="zones"
row-key="id"
:columns="zonesTable.columns"
v-model:pagination="zonesTable.pagination"
:filter="filter"
>
<template v-slot:body="props">
<q-tr :props="props">
<q-td key="name" :props="props">
<span v-text="props.row.name || '(unnamed)'"></span>
</q-td>
<q-td key="countries" :props="props">
<span v-text="props.row.countries.join(', ')"></span>
</q-td>
<q-td key="cost" :props="props">
<span v-text="props.row.cost"></span>
</q-td>
<q-td key="currency" :props="props">
<span v-text="props.row.currency"></span>
</q-td>
<q-td key="actions" :props="props">
<q-btn
size="sm"
color="primary"
dense
flat
icon="edit"
@click="openZoneDialog(props.row)"
>
<q-tooltip>Edit zone</q-tooltip>
</q-btn>
<q-btn
size="sm"
color="negative"
dense
flat
icon="delete"
@click="confirmDeleteZone(props.row)"
>
<q-tooltip>Delete zone</q-tooltip>
</q-btn>
</q-td>
</q-tr>
</template>
</q-table>
<q-dialog v-model="zoneDialog.showDialog" position="top">
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
<q-form @submit="sendZoneFormData" class="q-gutter-md">
<q-input
filled
dense
label="Zone Name"
type="text"
v-model.trim="zoneDialog.data.name"
></q-input>
<q-select
filled
dense
multiple
:options="shippingZoneOptions"
label="Countries"
v-model="zoneDialog.data.countries"
></q-select>
<q-select
:disabled="!!zoneDialog.data.id"
:readonly="!!zoneDialog.data.id"
filled
dense
v-model="zoneDialog.data.currency"
type="text"
label="Unit"
:options="currencies"
></q-select>
<q-input
filled
dense
:label="'Cost of Shipping (' + zoneDialog.data.currency + ') *'"
fill-mask="0"
reverse-fill-mask
:step="zoneDialog.data.currency != 'sat' ? '0.01' : '1'"
type="number"
v-model.trim="zoneDialog.data.cost"
></q-input>
<div class="row q-mt-lg">
<div v-if="zoneDialog.data.id">
<q-btn unelevated color="primary" type="submit">Update</q-btn>
</div>
<div v-else>
<q-btn
unelevated
color="primary"
:disable="!zoneDialog.data.countries || !zoneDialog.data.countries.length"
type="submit"
>Create Shipping Zone</q-btn
>
</div>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
</div>

View file

@ -1,43 +1,38 @@
<div>
<div class="row items-center no-wrap q-mb-md">
<div class="col q-pr-lg">
<q-btn-dropdown
@click="openCreateStallDialog()"
outline
unelevated
split
class="float-left"
color="primary"
label="New Stall (Store)"
>
<q-item @click="openCreateStallDialog()" clickable v-close-popup>
<q-item-section>
<q-item-label>New Stall</q-item-label>
<q-item-label caption>Create a new stall</q-item-label>
</q-item-section>
</q-item>
<q-item @click="openSelectPendingStallDialog" clickable v-close-popup>
<q-item-section>
<q-item-label>Restore Stall</q-item-label>
<q-item-label caption
>Restore existing stall from Nostr</q-item-label
>
</q-item-section>
</q-item>
</q-btn-dropdown>
<div class="row items-center q-mb-md q-col-gutter-sm justify-end">
<div class="col-auto">
<q-input
borderless
dense
debounce="300"
v-model="filter"
placeholder="Search"
class="float-right"
placeholder="Search by name, currency..."
style="min-width: 250px"
>
<template v-slot:append>
<template v-slot:prepend>
<q-icon name="search"></q-icon>
</template>
</q-input>
</div>
<div class="col-auto">
<q-btn
@click="openSelectPendingStallDialog"
outline
color="primary"
icon="restore"
label="Restore Stall"
class="q-px-md"
></q-btn>
</div>
<div class="col-auto">
<q-btn
@click="openCreateStallDialog()"
unelevated
color="primary"
icon="add"
label="New Stall"
class="q-px-md"
></q-btn>
</div>
</div>
<q-table
@ -51,57 +46,67 @@
>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn
size="sm"
color="primary"
round
dense
@click="props.row.expanded= !props.row.expanded"
:icon="props.row.expanded? 'remove' : 'add'"
/>
<q-td key="name" :props="props">
<span v-text="shortLabel(props.row.name)"></span>
</q-td>
<q-td key="id" :props="props"
><span v-text="shortLabel(props.row.name)"></span
></q-td>
<q-td key="currency" :props="props"
><span v-text="props.row.currency"></span>
<q-td key="currency" :props="props">
<span v-text="props.row.currency"></span>
</q-td>
<q-td key="description" :props="props">
<span v-text="shortLabel(props.row.config.description)"></span>
</q-td>
<q-td key="shippingZones" :props="props">
<div>
<span
v-text="shortLabel(props.row.shipping_zones.filter(z => !!z.name).map(z => z.name).join(', '))"
></span>
</div>
</q-td>
</q-tr>
<q-tr v-if="props.row.expanded" :props="props">
<q-td colspan="100%">
<div class="row items-center q-mb-lg">
<div class="col-12">
<stall-details
:stall-id="props.row.id"
:adminkey="adminkey"
:inkey="inkey"
:wallet-options="walletOptions"
:zone-options="zoneOptions"
:currencies="currencies"
@stall-deleted="handleStallDeleted"
@stall-updated="handleStallUpdated"
@customer-selected-for-order="customerSelectedForOrder"
></stall-details>
</div>
</div>
<q-td key="actions" :props="props">
<q-btn
size="sm"
color="primary"
dense
flat
icon="edit"
@click="openEditStallDialog(props.row)"
>
<q-tooltip>Edit stall</q-tooltip>
</q-btn>
<q-btn
size="sm"
color="secondary"
dense
flat
icon="inventory_2"
@click="goToProducts(props.row)"
>
<q-tooltip>View products</q-tooltip>
</q-btn>
<q-btn
size="sm"
color="accent"
dense
flat
icon="receipt"
@click="goToOrders(props.row)"
>
<q-tooltip>View orders</q-tooltip>
</q-btn>
<q-btn
size="sm"
color="negative"
dense
flat
icon="delete"
@click="confirmDeleteStall(props.row)"
>
<q-tooltip>Delete stall</q-tooltip>
</q-btn>
</q-td>
</q-tr>
</template>
</q-table>
<div>
<!-- Create Stall Dialog -->
<q-dialog v-model="stallDialog.show" position="top">
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
<q-form @submit="sendStallFormData" class="q-gutter-md">
@ -164,6 +169,75 @@
</q-form>
</q-card>
</q-dialog>
<!-- Edit Stall Dialog -->
<q-dialog v-model="editDialog.show" position="top">
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
<q-form @submit="updateStall" class="q-gutter-md">
<q-input
filled
dense
readonly
disabled
v-model.trim="editDialog.data.id"
label="ID"
></q-input>
<q-input
filled
dense
v-model.trim="editDialog.data.name"
label="Name"
></q-input>
<q-input
filled
dense
v-model.trim="editDialog.data.description"
type="textarea"
rows="3"
label="Description"
></q-input>
<q-select
filled
dense
emit-value
v-model="editDialog.data.wallet"
:options="walletOptions"
label="Wallet *"
>
</q-select>
<q-select
filled
dense
v-model="editDialog.data.currency"
type="text"
label="Unit"
:options="currencies"
></q-select>
<q-select
:options="editFilteredZoneOptions"
filled
dense
multiple
v-model.trim="editDialog.data.shippingZones"
label="Shipping Zones"
></q-select>
<div class="row q-mt-lg">
<q-btn
unelevated
color="primary"
type="submit"
label="Update Stall"
></q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
<!-- Restore Stall Dialog -->
<q-dialog v-model="stallDialog.showRestore" position="top">
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
<div v-if="pendingStalls && pendingStalls.length" class="row q-mt-lg">
@ -210,5 +284,4 @@
</div>
</q-card>
</q-dialog>
</div>
</div>

View file

@ -1,83 +1,211 @@
{% 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-7 q-gutter-y-md">
<div class="col-12">
<div v-if="merchant && merchant.id">
<q-card>
<q-card-section>
<div class="row items-center q-col-gutter-sm">
<div class="col-12 col-sm-auto">
<merchant-details
<div class="row items-center no-wrap">
<q-tabs
v-model="activeTab"
class="text-grey col"
active-color="primary"
indicator-color="primary"
align="left"
>
<q-tab
name="merchant"
label="Merchant"
icon="person"
style="min-width: 120px"
></q-tab>
<q-tab
name="shipping"
label="Shipping"
icon="local_shipping"
style="min-width: 120px"
></q-tab>
<q-tab
name="stalls"
label="Stalls"
icon="store"
style="min-width: 120px"
></q-tab>
<q-tab
name="products"
label="Products"
icon="inventory_2"
style="min-width: 120px"
></q-tab>
<q-tab
name="messages"
label="Messages"
icon="chat"
style="min-width: 120px"
></q-tab>
<q-tab
name="orders"
label="Orders"
icon="receipt"
style="min-width: 120px"
></q-tab>
</q-tabs>
<div class="col-auto q-mr-md">
<q-btn-dropdown
color="primary"
label="Publish"
icon="publish"
unelevated
>
<q-list>
<q-item clickable v-close-popup @click="publishNip15">
<q-item-section avatar>
<q-icon name="store" />
</q-item-section>
<q-item-section>
<q-item-label>Publish NIP-15</q-item-label>
<q-item-label caption
>Publish stalls and products</q-item-label
>
</q-item-section>
</q-item>
<q-item disable>
<q-item-section avatar>
<q-icon name="sell" color="grey" />
</q-item-section>
<q-item-section>
<q-item-label class="text-grey"
>Publish NIP-99</q-item-label
>
<q-item-label caption
>Classified listings (coming soon)</q-item-label
>
</q-item-section>
</q-item>
<q-separator />
<q-item clickable v-close-popup @click="refreshNip15">
<q-item-section avatar>
<q-icon name="refresh" />
</q-item-section>
<q-item-section>
<q-item-label>Refresh NIP-15 from Nostr</q-item-label>
<q-item-label caption
>Sync stalls and products</q-item-label
>
</q-item-section>
</q-item>
<q-item disable>
<q-item-section avatar>
<q-icon name="refresh" color="grey" />
</q-item-section>
<q-item-section>
<q-item-label class="text-grey"
>Refresh NIP-99 from Nostr</q-item-label
>
<q-item-label caption
>Classified listings (coming soon)</q-item-label
>
</q-item-section>
</q-item>
<q-separator />
<q-item clickable v-close-popup @click="deleteNip15">
<q-item-section avatar>
<q-icon name="delete_forever" color="negative" />
</q-item-section>
<q-item-section>
<q-item-label class="text-negative"
>Delete NIP-15 from Nostr</q-item-label
>
<q-item-label caption
>Remove stalls and products</q-item-label
>
</q-item-section>
</q-item>
<q-item disable>
<q-item-section avatar>
<q-icon name="delete_forever" color="grey" />
</q-item-section>
<q-item-section>
<q-item-label class="text-grey"
>Delete NIP-99 from Nostr</q-item-label
>
<q-item-label caption
>Classified listings (coming soon)</q-item-label
>
</q-item-section>
</q-item>
</q-list>
</q-btn-dropdown>
</div>
</div>
<q-separator></q-separator>
<q-tab-panels v-model="activeTab" animated>
<!-- Merchant Tab -->
<q-tab-panel name="merchant">
<merchant-tab
:merchant-id="merchant.id"
:inkey="g.user.wallets[0].inkey"
:adminkey="g.user.wallets[0].adminkey"
:show-keys="showKeys"
@toggle-show-keys="toggleShowKeys"
@merchant-deleted="handleMerchantDeleted"
></merchant-details>
</div>
<div class="col-12 col-sm-auto q-mx-sm">
<div class="row items-center no-wrap">
<q-toggle
@update:model-value="toggleMerchantState()"
size="md"
checked-icon="check"
v-model="merchant.config.active"
color="primary"
unchecked-icon="clear"
/>
<span
class="q-ml-sm"
v-text="merchant.config.active ? 'Accepting Orders': 'Orders Paused'"
></span>
</div>
</div>
<div class="col-12 col-sm-auto q-ml-sm-auto">
<shipping-zones
:inkey="g.user.wallets[0].inkey"
:adminkey="g.user.wallets[0].adminkey"
></shipping-zones>
</div>
</div>
</q-card-section>
<q-card-section v-if="showKeys">
<div class="row q-mb-md">
<div class="col">
<q-btn
unelevated
color="grey"
outline
@click="showKeys = false"
class="float-left"
>Hide Keys</q-btn
>
</div>
</div>
<div class="row">
<div class="col">
<key-pair
:merchant-active="merchant.config.active"
:public-key="merchant.public_key"
:private-key="merchant.private_key"
></key-pair>
</div>
</div>
</q-card-section>
</q-card>
<q-card class="q-mt-lg">
<q-card-section>
:is-admin="g.user.admin"
@toggle-show-keys="toggleShowKeys"
@hide-keys="showKeys = false"
@merchant-deleted="handleMerchantDeleted"
@toggle-merchant-state="toggleMerchantState"
@restart-nostr-connection="restartNostrConnection"
></merchant-tab>
</q-tab-panel>
<!-- Shipping Tab -->
<q-tab-panel name="shipping">
<shipping-zones-list
:inkey="g.user.wallets[0].inkey"
:adminkey="g.user.wallets[0].adminkey"
></shipping-zones-list>
</q-tab-panel>
<!-- Stalls Tab -->
<q-tab-panel name="stalls">
<stall-list
:adminkey="g.user.wallets[0].adminkey"
:inkey="g.user.wallets[0].inkey"
:wallet-options="g.user.walletOptions"
@customer-selected-for-order="customerSelectedForOrder"
@go-to-products="goToProducts"
@go-to-orders="goToOrders"
></stall-list>
</q-card-section>
</q-card>
<q-card class="q-mt-lg">
<q-card-section>
<div class="row">
<div class="col-12">
</q-tab-panel>
<!-- Products Tab -->
<q-tab-panel name="products">
<product-list
:adminkey="g.user.wallets[0].adminkey"
:inkey="g.user.wallets[0].inkey"
:stall-filter="selectedStallFilter"
@clear-filter="selectedStallFilter = null"
></product-list>
</q-tab-panel>
<!-- Messages Tab -->
<q-tab-panel name="messages">
<direct-messages
ref="directMessagesRef"
:inkey="g.user.wallets[0].inkey"
:adminkey="g.user.wallets[0].adminkey"
:active-chat-customer="activeChatCustomer"
:merchant-id="merchant.id"
@customer-selected="filterOrdersForCustomer"
@order-selected="showOrderDetails"
>
</direct-messages>
</q-tab-panel>
<!-- Orders Tab -->
<q-tab-panel name="orders">
<order-list
ref="orderListRef"
:adminkey="g.user.wallets[0].adminkey"
@ -85,9 +213,8 @@
:customer-pubkey-filter="orderPubkey"
@customer-selected="customerSelectedForOrder"
></order-list>
</div>
</div>
</q-card-section>
</q-tab-panel>
</q-tab-panels>
</q-card>
</div>
<q-card v-else>
@ -265,6 +392,15 @@
<template id="merchant-details"
>{% include("nostrmarket/components/merchant-details.html") %}</template
>
<template id="merchant-tab"
>{% include("nostrmarket/components/merchant-tab.html") %}</template
>
<template id="shipping-zones-list"
>{% include("nostrmarket/components/shipping-zones-list.html") %}</template
>
<template id="product-list"
>{% include("nostrmarket/components/product-list.html") %}</template
>
<script src="{{ url_for('nostrmarket_static', path='js/nostr.bundle.js') }}"></script>
<script src="{{ url_for('nostrmarket_static', path='js/utils.js') }}"></script>
@ -277,5 +413,8 @@
<script src="{{ static_url_for('nostrmarket/static', 'components/order-list.js') }}"></script>
<script src="{{ static_url_for('nostrmarket/static', 'components/direct-messages.js') }}"></script>
<script src="{{ static_url_for('nostrmarket/static', 'components/merchant-details.js') }}"></script>
<script src="{{ static_url_for('nostrmarket/static', 'components/merchant-tab.js') }}"></script>
<script src="{{ static_url_for('nostrmarket/static', 'components/shipping-zones-list.js') }}"></script>
<script src="{{ static_url_for('nostrmarket/static', 'components/product-list.js') }}"></script>
{% endblock %}