feat: restructure UI with tab-based navigation (#119)

Reorganizes the merchant dashboard into a tab-based layout:
- Tabs: Merchant | Shipping | Stalls | Products | Messages | Orders
- Publish dropdown with NIP-15 options (NIP-99 disabled/coming soon)
- Consistent UI patterns across all tabs:
  - Search/filter/buttons aligned right
  - Actions column on right side of tables
  - Equal-width tabs
- Stalls tab: popup edit dialog, navigation to Products/Orders
- Products tab: filter icon dropdown for stall filtering
- Modular component structure for each tab section
- Fixed product API calls to use correct endpoints

Closes #119

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Ben Weeks 2025-12-21 19:06:28 +00:00
parent 17d13dbe6b
commit 71f458b9b9
10 changed files with 1605 additions and 384 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,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()
}
})

View file

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

View file

@ -2,7 +2,7 @@ window.app.component('stall-list', {
name: 'stall-list', name: 'stall-list',
template: '#stall-list', template: '#stall-list',
delimiters: ['${', '}'], delimiters: ['${', '}'],
props: [`adminkey`, 'inkey', 'wallet-options'], props: ['adminkey', 'inkey', 'wallet-options'],
data: function () { data: function () {
return { return {
filter: '', filter: '',
@ -20,39 +20,25 @@ window.app.component('stall-list', {
shippingZones: [] shippingZones: []
} }
}, },
editDialog: {
show: false,
data: {
id: '',
name: '',
description: '',
wallet: null,
currency: 'sat',
shippingZones: []
}
},
zoneOptions: [], zoneOptions: [],
stallsTable: { stallsTable: {
columns: [ columns: [
{ {name: 'name', align: 'left', label: 'Name', field: 'name'},
name: '', {name: 'currency', align: 'left', label: 'Currency', field: 'currency'},
align: 'left', {name: 'description', align: 'left', label: 'Description', field: row => row.config?.description || ''},
label: '', {name: 'shippingZones', align: 'left', label: 'Shipping Zones', field: row => row.shipping_zones?.map(z => z.name).join(', ') || ''},
field: '' {name: 'actions', align: 'right', label: 'Actions', 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'
}
], ],
pagination: { pagination: {
rowsPerPage: 10 rowsPerPage: 10
@ -65,6 +51,11 @@ window.app.component('stall-list', {
return this.zoneOptions.filter( return this.zoneOptions.filter(
z => z.currency === this.stallDialog.data.currency z => z.currency === this.stallDialog.data.currency
) )
},
editFilteredZoneOptions: function () {
return this.zoneOptions.filter(
z => z.currency === this.editDialog.data.currency
)
} }
}, },
methods: { methods: {
@ -94,7 +85,6 @@ window.app.component('stall-list', {
stall stall
) )
this.stallDialog.show = false this.stallDialog.show = false
data.expanded = false
this.stalls.unshift(data) this.stalls.unshift(data)
this.$q.notify({ this.$q.notify({
type: 'positive', type: 'positive',
@ -114,7 +104,6 @@ window.app.component('stall-list', {
stallData stallData
) )
this.stallDialog.show = false this.stallDialog.show = false
data.expanded = false
this.stalls.unshift(data) this.stalls.unshift(data)
this.$q.notify({ this.$q.notify({
type: 'positive', type: 'positive',
@ -124,44 +113,66 @@ window.app.component('stall-list', {
LNbits.utils.notifyApiError(error) LNbits.utils.notifyApiError(error)
} }
}, },
deleteStall: async function (pendingStall) { updateStall: async function () {
LNbits.utils try {
.confirmDialog( const stallData = {
` id: this.editDialog.data.id,
Are you sure you want to delete this pending stall '${pendingStall.name}'? 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 { try {
await LNbits.api.request( await LNbits.api.request(
'DELETE', 'DELETE',
'/nostrmarket/api/v1/stall/' + pendingStall.id, '/nostrmarket/api/v1/stall/' + stall.id,
this.adminkey 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({ this.$q.notify({
type: 'positive', type: 'positive',
message: 'Pending Stall Deleted', message: 'Stall deleted'
timeout: 5000
}) })
} catch (error) { } catch (error) {
console.warn(error)
LNbits.utils.notifyApiError(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: async function () { getCurrencies: function () {
try { const currencies = window.g.allowedCurrencies || []
const {data} = await LNbits.api.request( return ['sat', ...currencies]
'GET',
'/nostrmarket/api/v1/currencies',
this.inkey
)
return ['sat', ...data]
} catch (error) {
LNbits.utils.notifyApiError(error)
}
return []
}, },
getStalls: async function (pending = false) { getStalls: async function (pending = false) {
try { try {
@ -170,7 +181,7 @@ window.app.component('stall-list', {
`/nostrmarket/api/v1/stall?pending=${pending}`, `/nostrmarket/api/v1/stall?pending=${pending}`,
this.inkey this.inkey
) )
return data.map(s => ({...s, expanded: false})) return data
} catch (error) { } catch (error) {
LNbits.utils.notifyApiError(error) LNbits.utils.notifyApiError(error)
} }
@ -194,20 +205,8 @@ window.app.component('stall-list', {
} }
return [] 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) { openCreateStallDialog: async function (stallData) {
this.currencies = await this.getCurrencies() this.currencies = this.getCurrencies()
this.zoneOptions = await this.getZones() this.zoneOptions = await this.getZones()
if (!this.zoneOptions || !this.zoneOptions.length) { if (!this.zoneOptions || !this.zoneOptions.length) {
this.$q.notify({ this.$q.notify({
@ -225,6 +224,24 @@ window.app.component('stall-list', {
} }
this.stallDialog.show = true 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 () { openSelectPendingStallDialog: async function () {
this.stallDialog.showRestore = true this.stallDialog.showRestore = true
this.pendingStalls = await this.getStalls(true) this.pendingStalls = await this.getStalls(true)
@ -246,8 +263,11 @@ window.app.component('stall-list', {
})) }))
}) })
}, },
customerSelectedForOrder: function (customerPubkey) { goToProducts: function (stall) {
this.$emit('customer-selected-for-order', customerPubkey) this.$emit('go-to-products', stall.id)
},
goToOrders: function (stall) {
this.$emit('go-to-orders', stall.id)
}, },
shortLabel(value = '') { shortLabel(value = '') {
if (value.length <= 64) return value if (value.length <= 64) return value
@ -256,7 +276,7 @@ window.app.component('stall-list', {
}, },
created: async function () { created: async function () {
this.stalls = await this.getStalls() this.stalls = await this.getStalls()
this.currencies = await this.getCurrencies() this.currencies = this.getCurrencies()
this.zoneOptions = await this.getZones() this.zoneOptions = await this.getZones()
} }
}) })

View file

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

View file

@ -0,0 +1,96 @@
<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,289 @@
<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,122 @@
<div>
<div class="row items-center q-mb-md q-col-gutter-sm justify-end">
<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"
>
<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="currency" :props="props">
<span v-text="props.row.currency"></span>
</q-td>
<q-td key="cost" :props="props">
<span v-text="props.row.cost"></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>
<div class="row items-center no-wrap q-mb-md"> <div class="row items-center q-mb-md q-col-gutter-sm justify-end">
<div class="col q-pr-lg"> <div class="col-auto">
<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>
<q-input <q-input
borderless
dense dense
debounce="300" debounce="300"
v-model="filter" v-model="filter"
placeholder="Search" placeholder="Search by name, currency..."
class="float-right" style="min-width: 250px"
> >
<template v-slot:append> <template v-slot:prepend>
<q-icon name="search"></q-icon> <q-icon name="search"></q-icon>
</template> </template>
</q-input> </q-input>
</div> </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> </div>
<q-table <q-table
@ -51,57 +46,65 @@
> >
<template v-slot:body="props"> <template v-slot:body="props">
<q-tr :props="props"> <q-tr :props="props">
<q-td auto-width> <q-td key="name" :props="props">
<q-btn <span v-text="shortLabel(props.row.name)"></span>
size="sm"
color="primary"
round
dense
@click="props.row.expanded= !props.row.expanded"
:icon="props.row.expanded? 'remove' : 'add'"
/>
</q-td> </q-td>
<q-td key="currency" :props="props">
<q-td key="id" :props="props" <span v-text="props.row.currency"></span>
><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> </q-td>
<q-td key="description" :props="props"> <q-td key="description" :props="props">
<span v-text="shortLabel(props.row.config.description)"></span> <span v-text="shortLabel(props.row.config.description)"></span>
</q-td> </q-td>
<q-td key="shippingZones" :props="props"> <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>
<span
v-text="shortLabel(props.row.shipping_zones.filter(z => !!z.name).map(z => z.name).join(', '))"
></span>
</div>
</q-td> </q-td>
</q-tr> <q-td key="actions" :props="props">
<q-tr v-if="props.row.expanded" :props="props"> <q-btn
<q-td colspan="100%"> size="sm"
<div class="row items-center q-mb-lg"> color="primary"
<div class="col-12"> dense
<stall-details flat
:stall-id="props.row.id" icon="edit"
:adminkey="adminkey" @click="openEditStallDialog(props.row)"
:inkey="inkey" >
:wallet-options="walletOptions" <q-tooltip>Edit stall</q-tooltip>
:zone-options="zoneOptions" </q-btn>
:currencies="currencies" <q-btn
@stall-deleted="handleStallDeleted" size="sm"
@stall-updated="handleStallUpdated" color="secondary"
@customer-selected-for-order="customerSelectedForOrder" dense
></stall-details> flat
</div> icon="inventory_2"
</div> @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-td>
</q-tr> </q-tr>
</template> </template>
</q-table> </q-table>
<div> <!-- Create Stall Dialog -->
<q-dialog v-model="stallDialog.show" position="top"> <q-dialog v-model="stallDialog.show" position="top">
<q-card class="q-pa-lg q-pt-xl" style="width: 500px"> <q-card class="q-pa-lg q-pt-xl" style="width: 500px">
<q-form @submit="sendStallFormData" class="q-gutter-md"> <q-form @submit="sendStallFormData" class="q-gutter-md">
@ -164,6 +167,75 @@
</q-form> </q-form>
</q-card> </q-card>
</q-dialog> </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-dialog v-model="stallDialog.showRestore" position="top">
<q-card class="q-pa-lg q-pt-xl" style="width: 500px"> <q-card class="q-pa-lg q-pt-xl" style="width: 500px">
<div v-if="pendingStalls && pendingStalls.length" class="row q-mt-lg"> <div v-if="pendingStalls && pendingStalls.length" class="row q-mt-lg">
@ -211,4 +283,3 @@
</q-card> </q-card>
</q-dialog> </q-dialog>
</div> </div>
</div>

View file

@ -1,83 +1,161 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context {% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block page %} %} {% block page %}
<div class="row q-col-gutter-md"> <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"> <div v-if="merchant && merchant.id">
<q-card> <q-card>
<q-card-section> <div class="row items-center no-wrap">
<div class="row items-center q-col-gutter-sm"> <q-tabs
<div class="col-12 col-sm-auto"> v-model="activeTab"
<merchant-details 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" :merchant-id="merchant.id"
:inkey="g.user.wallets[0].inkey" :inkey="g.user.wallets[0].inkey"
:adminkey="g.user.wallets[0].adminkey" :adminkey="g.user.wallets[0].adminkey"
:show-keys="showKeys" :show-keys="showKeys"
@toggle-show-keys="toggleShowKeys" :merchant-active="merchant.config.active"
@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
:public-key="merchant.public_key" :public-key="merchant.public_key"
:private-key="merchant.private_key" :private-key="merchant.private_key"
></key-pair> :is-admin="g.user.admin"
</div> @toggle-show-keys="toggleShowKeys"
</div> @hide-keys="showKeys = false"
</q-card-section> @merchant-deleted="handleMerchantDeleted"
</q-card> @toggle-merchant-state="toggleMerchantState"
<q-card class="q-mt-lg"> @restart-nostr-connection="restartNostrConnection"
<q-card-section> ></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 <stall-list
:adminkey="g.user.wallets[0].adminkey" :adminkey="g.user.wallets[0].adminkey"
:inkey="g.user.wallets[0].inkey" :inkey="g.user.wallets[0].inkey"
:wallet-options="g.user.walletOptions" :wallet-options="g.user.walletOptions"
@customer-selected-for-order="customerSelectedForOrder" @customer-selected-for-order="customerSelectedForOrder"
@go-to-products="goToProducts"
@go-to-orders="goToOrders"
></stall-list> ></stall-list>
</q-card-section> </q-tab-panel>
</q-card>
<q-card class="q-mt-lg"> <!-- Products Tab -->
<q-card-section> <q-tab-panel name="products">
<div class="row"> <product-list
<div class="col-12"> :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 <order-list
ref="orderListRef" ref="orderListRef"
:adminkey="g.user.wallets[0].adminkey" :adminkey="g.user.wallets[0].adminkey"
@ -85,9 +163,8 @@
:customer-pubkey-filter="orderPubkey" :customer-pubkey-filter="orderPubkey"
@customer-selected="customerSelectedForOrder" @customer-selected="customerSelectedForOrder"
></order-list> ></order-list>
</div> </q-tab-panel>
</div> </q-tab-panels>
</q-card-section>
</q-card> </q-card>
</div> </div>
<q-card v-else> <q-card v-else>
@ -147,50 +224,6 @@
</q-card> </q-card>
</div> </div>
<div class="col-12 col-md-5 q-gutter-y-md">
<div v-if="g.user.admin" class="col-12 q-mb-lg">
<q-card>
<q-card-section class="q-pa-md">
<q-btn
label="Restart Nostr Connection"
color="grey"
outline
@click="restartNostrConnection"
>
<q-tooltip>
Restart the connection to the nostrclient extension
</q-tooltip>
</q-btn>
</q-card-section>
</q-card>
</div>
<div class="col-12">
<q-card>
<q-card-section>
<h6 class="text-subtitle1 q-my-none">
{{SITE_TITLE}} Nostr Market Extension
</h6>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list> {% include "nostrmarket/_api_docs.html" %} </q-list>
</q-card-section>
</q-card>
</div>
<div v-if="merchant && merchant.id" class="col-12">
<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>
</div>
</div>
<div> <div>
<q-dialog v-model="importKeyDialog.show" position="top"> <q-dialog v-model="importKeyDialog.show" position="top">
<q-card class="q-pa-lg q-pt-xl" style="width: 500px"> <q-card class="q-pa-lg q-pt-xl" style="width: 500px">
@ -257,6 +290,15 @@
<template id="merchant-details" <template id="merchant-details"
>{% include("nostrmarket/components/merchant-details.html") %}</template >{% 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/nostr.bundle.js') }}"></script>
<script src="{{ url_for('nostrmarket_static', path='js/utils.js') }}"></script> <script src="{{ url_for('nostrmarket_static', path='js/utils.js') }}"></script>
@ -269,5 +311,8 @@
<script src="{{ static_url_for('nostrmarket/static', 'components/order-list.js') }}"></script> <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/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-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 %} {% endblock %}