Merge remote-tracking branch 'origin/main' into feature/nostrclient-status-indicator

This commit is contained in:
Arc 2025-12-24 03:57:27 +00:00
commit 05a23fae0b
18 changed files with 2205 additions and 426 deletions

5
.gitignore vendored
View file

@ -22,4 +22,7 @@ node_modules
*.swp *.swp
*.pyo *.pyo
*.pyc *.pyc
*.env *.env
# Claude Code config
CLAUDE.md

104
CLAUDE.md Normal file
View file

@ -0,0 +1,104 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Nostr Market is an LNbits extension implementing NIP-15 (decentralized marketplace protocol) on Nostr. It enables merchants to create webshops (stalls) and sell products with Lightning Network payments, featuring encrypted customer-merchant communication via NIP-04.
**Prerequisites:** Requires the LNbits [nostrclient](https://github.com/lnbits/nostrclient) extension to be installed and configured.
## Common Commands
All commands are in the Makefile:
```bash
make format # Run prettier, black, and ruff formatters
make check # Run mypy, pyright, black check, ruff check, prettier check
make test # Run pytest with debug mode
make all # Run format and check
```
Individual tools:
```bash
make black # Format Python files
make ruff # Check and fix Python linting
make mypy # Static type checking
make pyright # Python static type checker
make prettier # Format JS/HTML/CSS files
```
## Local Development Setup
To run checks locally, install dependencies:
```bash
# Install Python autotools dependencies (needed for secp256k1)
sudo apt-get install -y automake autoconf libtool
# Install Python dependencies
uv sync
# Install Node dependencies (for prettier)
npm install
# Run all checks
make check
```
## Architecture
### Core Layers
1. **API Layer** (`views_api.py`) - REST endpoints for merchants, stalls, products, zones, orders, direct messages
2. **Business Logic** (`services.py`) - Order processing, Nostr event signing/publishing, message routing, invoice handling
3. **Data Layer** (`crud.py`) - Async SQLite operations via LNbits db module
4. **Models** (`models.py`) - Pydantic models for all entities
### Nostr Integration (`nostr/`)
- `nostr_client.py` - WebSocket client connecting to nostrclient extension for relay communication
- `event.py` - Nostr event model, serialization, ID computation (SHA256), Schnorr signatures
### Background Tasks (`__init__.py`, `tasks.py`)
Three permanent async tasks:
- `wait_for_paid_invoices()` - Lightning payment listener
- `wait_for_nostr_events()` - Incoming Nostr message processor
- `_subscribe_to_nostr_client()` - WebSocket connection manager
### Frontend (`static/`, `templates/`)
- Merchant dashboard: `templates/nostrmarket/index.html`
- Customer marketplace: `templates/nostrmarket/market.html` with Vue.js/Quasar in `static/market/`
- Use Quasar UI components when possible: https://quasar.dev/components
### Key Data Models
- **Merchant** - Shop owner with Nostr keypair, handles event signing and DM encryption
- **Stall** - Individual shop with products and shipping zones (kind 30017)
- **Product** - Items for sale with categories, images, quantity (kind 30018)
- **Zone** - Shipping configuration by region
- **Order** - Customer purchases with Lightning invoice tracking
- **DirectMessage** - Encrypted chat (NIP-04)
- **Customer** - Buyer profile with Nostr pubkey
### Key Patterns
- **Nostrable Interface** - Base class for models convertible to Nostr events (`to_nostr_event()`, `to_nostr_delete_event()`)
- **Parameterized Replaceable Events** - Stalls (kind 30017) and Products (kind 30018) per NIP-33
- **AES-256 Encryption** - Customer-merchant DMs use shared secret from ECDH
- **JSON Meta Fields** - Complex data (zones, items, config) stored as JSON in database
### Cryptography (`helpers.py`)
- Schnorr signatures for Nostr events
- NIP-04 encryption/decryption
- Key derivation and bech32 encoding (npub/nsec)
## Workflow
- Always check GitHub Actions after pushing to verify CI passes
- Run `make check` locally before pushing to catch issues early

View file

@ -2,7 +2,7 @@
"name": "Nostr Market", "name": "Nostr Market",
"version": "1.1.0", "version": "1.1.0",
"short_description": "Nostr Webshop/market on LNbits", "short_description": "Nostr Webshop/market on LNbits",
"tile": "/nostrmarket/static/images/bitcoin-shop.png", "tile": "/nostrmarket/static/images/nostr-market.png",
"min_lnbits_version": "1.4.0", "min_lnbits_version": "1.4.0",
"contributors": [ "contributors": [
{ {

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

@ -19,7 +19,6 @@ window.app.component('shipping-zones', {
currencies: [], currencies: [],
shippingZoneOptions: [ shippingZoneOptions: [
'Free (digital)', 'Free (digital)',
'Flat rate',
'Worldwide', 'Worldwide',
'Europe', 'Europe',
'Australia', 'Australia',
@ -27,6 +26,7 @@ window.app.component('shipping-zones', {
'Belgium', 'Belgium',
'Brazil', 'Brazil',
'Canada', 'Canada',
'China',
'Denmark', 'Denmark',
'Finland', 'Finland',
'France', 'France',
@ -34,8 +34,8 @@ window.app.component('shipping-zones', {
'Greece', 'Greece',
'Hong Kong', 'Hong Kong',
'Hungary', 'Hungary',
'Ireland',
'Indonesia', 'Indonesia',
'Ireland',
'Israel', 'Israel',
'Italy', 'Italy',
'Japan', 'Japan',
@ -59,10 +59,9 @@ window.app.component('shipping-zones', {
'Thailand', 'Thailand',
'Turkey', 'Turkey',
'Ukraine', 'Ukraine',
'United Kingdom**', 'United Kingdom',
'United States***', 'United States',
'Vietnam', 'Vietnam'
'China'
] ]
} }
}, },
@ -162,22 +161,13 @@ window.app.component('shipping-zones', {
LNbits.utils.notifyApiError(error) LNbits.utils.notifyApiError(error)
} }
}, },
async getCurrencies() { getCurrencies() {
try { const currencies = window.g.allowedCurrencies || []
const {data} = await LNbits.api.request( this.currencies = ['sat', ...currencies]
'GET',
'/nostrmarket/api/v1/currencies',
this.inkey
)
this.currencies = ['sat', ...data]
} catch (error) {
LNbits.utils.notifyApiError(error)
}
} }
}, },
created: async function () { created: async function () {
await this.getZones() await this.getZones()
await this.getCurrencies() 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,21 +20,21 @@ 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: '',
align: 'left',
label: '',
field: ''
},
{
name: 'id',
align: 'left',
label: 'Name',
field: 'id'
},
{ {
name: 'currency', name: 'currency',
align: 'left', align: 'left',
@ -45,14 +45,15 @@ window.app.component('stall-list', {
name: 'description', name: 'description',
align: 'left', align: 'left',
label: 'Description', label: 'Description',
field: 'description' field: row => row.config?.description || ''
}, },
{ {
name: 'shippingZones', name: 'shippingZones',
align: 'left', align: 'left',
label: 'Shipping Zones', label: 'Shipping Zones',
field: 'shippingZones' field: row => row.shipping_zones?.map(z => z.name).join(', ') || ''
} },
{name: 'actions', align: 'right', label: 'Actions', field: ''}
], ],
pagination: { pagination: {
rowsPerPage: 10 rowsPerPage: 10
@ -65,6 +66,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 +100,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 +119,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 +128,66 @@ window.app.component('stall-list', {
LNbits.utils.notifyApiError(error) LNbits.utils.notifyApiError(error)
} }
}, },
deleteStall: async function (pendingStall) { updateStall: async function () {
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 () {
try { 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( const {data} = await LNbits.api.request(
'GET', 'PUT',
'/nostrmarket/api/v1/currencies', `/nostrmarket/api/v1/stall/${stallData.id}`,
this.inkey this.adminkey,
stallData
) )
this.editDialog.show = false
return ['sat', ...data] 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) { } catch (error) {
LNbits.utils.notifyApiError(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) { getStalls: async function (pending = false) {
try { try {
@ -170,7 +196,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 +220,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 +239,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 +278,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 +291,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

@ -0,0 +1,123 @@
#!/usr/bin/env python3
"""
Generate the Nostr Market logo.
Requires: pip install Pillow
"""
from PIL import Image, ImageDraw # type: ignore[import-not-found]
# Render at 4x size for antialiasing
scale = 4
size = 128 * scale
final_size = 128
# Consistent color scheme with Nostr Proxy
dark_purple = (80, 40, 120)
light_purple = (140, 100, 180)
white = (255, 255, 255)
margin = 4 * scale
swoosh_center = ((128 + 100) * scale, -90 * scale)
swoosh_radius = 220 * scale
# Create rounded rectangle mask
mask = Image.new("L", (size, size), 0)
mask_draw = ImageDraw.Draw(mask)
corner_radius = 20 * scale
mask_draw.rounded_rectangle(
[margin, margin, size - margin, size - margin],
radius=corner_radius,
fill=255,
)
# Create background with swoosh
bg = Image.new("RGBA", (size, size), (0, 0, 0, 0))
bg_draw = ImageDraw.Draw(bg)
bg_draw.rounded_rectangle(
[margin, margin, size - margin, size - margin],
radius=corner_radius,
fill=dark_purple,
)
bg_draw.ellipse(
[
swoosh_center[0] - swoosh_radius,
swoosh_center[1] - swoosh_radius,
swoosh_center[0] + swoosh_radius,
swoosh_center[1] + swoosh_radius,
],
fill=light_purple,
)
# Apply rounded rectangle mask
final = Image.new("RGBA", (size, size), (0, 0, 0, 0))
final.paste(bg, mask=mask)
draw = ImageDraw.Draw(final)
center_x, center_y = size // 2, size // 2
# Shop/storefront - wider and shorter for shop look
shop_width = 80 * scale
awning_height = 18 * scale
body_height = 45 * scale
total_height = awning_height + body_height
shop_left = center_x - shop_width // 2
shop_right = center_x + shop_width // 2
# Center vertically
awning_top = center_y - total_height // 2
awning_bottom = awning_top + awning_height
shop_bottom = awning_bottom + body_height
awning_extend = 5 * scale
# Draw awning background (white base)
draw.rectangle(
[shop_left - awning_extend, awning_top, shop_right + awning_extend, awning_bottom],
fill=white,
)
# Vertical stripes on awning (alternating dark purple)
stripe_count = 8
stripe_width = (shop_width + 2 * awning_extend) // stripe_count
for i in range(1, stripe_count, 2):
x_left = shop_left - awning_extend + i * stripe_width
draw.rectangle(
[x_left, awning_top, x_left + stripe_width, awning_bottom],
fill=dark_purple,
)
# Shop body (below awning)
draw.rectangle(
[shop_left, awning_bottom, shop_right, shop_bottom],
fill=white,
)
# Large display window (shop style)
window_margin = 8 * scale
window_top = awning_bottom + 6 * scale
window_bottom = shop_bottom - 6 * scale
# Left display window
draw.rectangle(
[shop_left + window_margin, window_top, center_x - 10 * scale, window_bottom],
fill=dark_purple,
)
# Right display window
draw.rectangle(
[center_x + 10 * scale, window_top, shop_right - window_margin, window_bottom],
fill=dark_purple,
)
# Door (center, dark purple cutout)
door_width = 14 * scale
door_left = center_x - door_width // 2
draw.rectangle(
[door_left, window_top, door_left + door_width, shop_bottom],
fill=dark_purple,
)
# Downscale with LANCZOS for antialiasing
final = final.resize((final_size, final_size), Image.LANCZOS)
final.save("nostr-market.png")
print("Logo saved to nostr-market.png")

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

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: '',
@ -295,6 +297,88 @@ 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

@ -1,61 +1,213 @@
<q-card> <q-expansion-item
<q-card-section> icon="help_outline"
<p> label="What is Nostr?"
Create, edit and publish products to your Nostr relays. Customers can header-class="text-weight-medium"
browse your stalls and pay with Lightning. >
</p> <q-card>
<p class="q-mb-none"> <q-card-section class="text-body2">
<small> <p>
Created by <strong>Nostr</strong> (Notes and Other Stuff Transmitted by Relays) is
a decentralized protocol for censorship-resistant communication. Unlike
traditional platforms, your identity and data aren't controlled by any
single company.
</p>
<p class="q-mb-none">
Your Nostr identity is a cryptographic key pair - a public key (npub)
that others use to find you, and a private key (nsec) that proves you
are you. Keep your nsec safe and never share it!
</p>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
icon="flag"
label="Getting Started"
header-class="text-weight-medium"
>
<q-card>
<q-card-section class="text-body2">
<p><strong>1. Generate or Import Keys</strong></p>
<p class="q-mb-md">
Create a new Nostr identity or import an existing one using your nsec.
Your keys are used to sign all marketplace events.
</p>
<p><strong>2. Create a Stall</strong></p>
<p class="q-mb-md">
A stall is your shop. Give it a name, description, and configure
shipping zones for delivery.
</p>
<p><strong>3. Add Products</strong></p>
<p class="q-mb-md">
List items for sale with images, descriptions, and prices in your
preferred currency.
</p>
<p><strong>4. Publish to Nostr</strong></p>
<p class="q-mb-none">
Your stall and products are published to Nostr relays where customers
can discover them using any compatible marketplace client.
</p>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
icon="storefront"
label="For Merchants"
header-class="text-weight-medium"
>
<q-card>
<q-card-section class="text-body2">
<p>
<strong>Decentralized Commerce</strong> - Your shop exists on Nostr
relays, not a single server. No platform fees, no deplatforming risk.
</p>
<p>
<strong>Lightning Payments</strong> - Accept instant, low-fee Bitcoin
payments via the Lightning Network.
</p>
<p>
<strong>Encrypted Messages</strong> - Communicate privately with
customers using NIP-04 encrypted direct messages.
</p>
<p>
<strong>Portable Identity</strong> - Your merchant reputation travels
with your Nostr keys across any compatible marketplace.
</p>
<p class="q-mb-none">
<strong>Global Reach</strong> - Your stalls and products are
automatically visible on any Nostr marketplace client that supports
NIP-15, including Amethyst, Plebeian Market, and others.
</p>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
icon="shopping_cart"
label="For Customers"
header-class="text-weight-medium"
>
<q-card>
<q-card-section class="text-body2">
<p>
<strong>Browse the Market</strong> - Use the Market Client to discover
stalls and products from merchants around the world.
</p>
<p>
<strong>Pay with Lightning</strong> - Fast, private payments with
minimal fees using Bitcoin's Lightning Network.
</p>
<p class="q-mb-none">
<strong>Direct Communication</strong> - Message merchants directly via
encrypted Nostr DMs for questions, custom orders, or support.
</p>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
icon="people"
label="Contributors"
header-class="text-weight-medium"
>
<q-card>
<q-card-section class="text-body2">
<p class="q-mb-sm">This extension was created by:</p>
<div class="q-gutter-sm">
<a <a
class="text-secondary"
target="_blank"
style="color: unset"
href="https://github.com/talvasconcelos" href="https://github.com/talvasconcelos"
>Tal Vasconcelos</a
>,
<a
class="text-secondary"
target="_blank" target="_blank"
style="color: unset" class="text-decoration-none"
href="https://github.com/benarc"
>Ben Arc</a
>,
<a
class="text-secondary"
target="_blank"
style="color: unset"
href="https://github.com/motorina0"
>motorina0</a
> >
</small> <q-chip clickable icon="person">Tal Vasconcelos</q-chip>
</p> </a>
</q-card-section> <a
<q-card-section class="q-pt-none"> href="https://github.com/arcbtc"
<a class="text-secondary" target="_blank" href="/nostrmarket/market"> target="_blank"
<q-icon name="storefront" class="q-mr-sm"></q-icon>Market client class="text-decoration-none"
<q-tooltip>Visit the market client</q-tooltip> >
</a> <q-chip clickable icon="person">Ben Arc</q-chip>
<br /> </a>
<a class="text-secondary" target="_blank" href="/docs#/nostrmarket"> <a
<q-icon name="code" class="q-mr-sm"></q-icon>API Documentation href="https://github.com/motorina0"
<q-tooltip>Swagger REST API Documentation</q-tooltip> target="_blank"
</a> class="text-decoration-none"
</q-card-section> >
<q-card-section class="q-pt-none"> <q-chip clickable icon="person">motorina0</q-chip>
<q-banner dense class="bg-orange-8 text-white" rounded> </a>
<template v-slot:avatar> <a
<q-icon name="info" color="white"></q-icon> href="https://github.com/BenGWeeks"
</template> target="_blank"
<strong>Requires:</strong>&nbsp; class="text-decoration-none"
<a >
href="https://github.com/lnbits/nostrclient" <q-chip clickable icon="person">Ben Weeks</q-chip>
target="_blank" </a>
class="text-white" </div>
style="text-decoration: underline" </q-card-section>
>nostrclient</a </q-card>
> </q-expansion-item>
extension to be installed and configured with relays.
</q-banner> <q-separator></q-separator>
</q-card-section>
</q-card> <q-item clickable tag="a" target="_blank" href="/nostrmarket/market">
<q-item-section avatar>
<q-icon name="storefront" color="primary"></q-icon>
</q-item-section>
<q-item-section>
<q-item-label>Market Client</q-item-label>
<q-item-label caption>Browse and shop from stalls</q-item-label>
</q-item-section>
<q-item-section side>
<q-icon name="open_in_new" size="xs"></q-icon>
</q-item-section>
</q-item>
<q-item clickable tag="a" target="_blank" href="/docs#/nostrmarket">
<q-item-section avatar>
<q-icon name="api" color="primary"></q-icon>
</q-item-section>
<q-item-section>
<q-item-label>API Documentation</q-item-label>
<q-item-label caption>Swagger REST API reference</q-item-label>
</q-item-section>
<q-item-section side>
<q-icon name="open_in_new" size="xs"></q-icon>
</q-item-section>
</q-item>
<q-item
clickable
tag="a"
target="_blank"
href="https://github.com/nostr-protocol/nips/blob/master/15.md"
>
<q-item-section avatar>
<q-icon name="description" color="secondary"></q-icon>
</q-item-section>
<q-item-section>
<q-item-label>NIP-15 Specification</q-item-label>
<q-item-label caption>Nostr Marketplace protocol</q-item-label>
</q-item-section>
<q-item-section side>
<q-icon name="open_in_new" size="xs"></q-icon>
</q-item-section>
</q-item>
<q-item
clickable
tag="a"
target="_blank"
href="https://github.com/lnbits/nostrmarket/issues"
>
<q-item-section avatar>
<q-icon name="bug_report" color="warning"></q-icon>
</q-item-section>
<q-item-section>
<q-item-label>Report Issues / Feedback</q-item-label>
<q-item-label caption>GitHub Issues</q-item-label>
</q-item-section>
<q-item-section side>
<q-icon name="open_in_new" size="xs"></q-icon>
</q-item-section>
</q-item>

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

@ -48,26 +48,36 @@
label="Countries" label="Countries"
v-model="zoneDialog.data.countries" v-model="zoneDialog.data.countries"
></q-select> ></q-select>
<q-select <div class="row items-start">
:disabled="!!zoneDialog.data.id" <div class="col q-mr-sm">
:readonly="!!zoneDialog.data.id" <q-input
filled filled
dense dense
v-model="zoneDialog.data.currency" label="Default shipping cost"
type="text" fill-mask="0"
label="Unit" reverse-fill-mask
:options="currencies" :step="zoneDialog.data.currency != 'sat' ? '0.01' : '1'"
></q-select> type="number"
<q-input v-model.trim="zoneDialog.data.cost"
filled :error="(zoneDialog.data.currency === 'sat' && zoneDialog.data.cost % 1 !== 0) || (zoneDialog.data.currency !== 'sat' && (zoneDialog.data.cost * 100) % 1 !== 0)"
dense :error-message="zoneDialog.data.currency === 'sat' ? 'Satoshis must be whole numbers' : 'Maximum 2 decimal places allowed'"
:label="'Cost of Shipping (' + zoneDialog.data.currency + ') *'" hint="Additional costs can be set per product"
fill-mask="0" ></q-input>
reverse-fill-mask </div>
:step="zoneDialog.data.currency != 'sat' ? '0.01' : '1'" <div class="col-auto">
type="number" <q-select
v-model.trim="zoneDialog.data.cost" :disabled="!!zoneDialog.data.id"
></q-input> :readonly="!!zoneDialog.data.id"
filled
dense
v-model="zoneDialog.data.currency"
type="text"
label="Currency"
:options="currencies"
style="min-width: 100px"
></q-select>
</div>
</div>
<div class="row q-mt-lg"> <div class="row q-mt-lg">
<div v-if="zoneDialog.data.id"> <div v-if="zoneDialog.data.id">
<q-btn unelevated color="primary" type="submit">Update</q-btn> <q-btn unelevated color="primary" type="submit">Update</q-btn>
@ -83,7 +93,7 @@
<q-btn <q-btn
unelevated unelevated
color="primary" color="primary"
:disable="!zoneDialog.data.countries || !zoneDialog.data.countries.length" :disable="!zoneDialog.data.name || !zoneDialog.data.countries || !zoneDialog.data.countries.length || (zoneDialog.data.currency === 'sat' && zoneDialog.data.cost % 1 !== 0) || (zoneDialog.data.currency !== 'sat' && (zoneDialog.data.cost * 100) % 1 !== 0)"
type="submit" type="submit"
>Create Shipping Zone</q-btn >Create Shipping Zone</q-btn
> >

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,164 +46,242 @@
> >
<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
<span v-text="shortLabel(props.row.shipping_zones.filter(z => !!z.name).map(z => z.name).join(', '))"
v-text="shortLabel(props.row.shipping_zones.filter(z => !!z.name).map(z => z.name).join(', '))" ></span>
></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">
<q-input <q-input
filled filled
dense dense
v-model.trim="stallDialog.data.name" v-model.trim="stallDialog.data.name"
label="Name" label="Name"
></q-input> ></q-input>
<q-input <q-input
filled filled
dense dense
v-model.trim="stallDialog.data.description" v-model.trim="stallDialog.data.description"
type="textarea" type="textarea"
rows="3" rows="3"
label="Description" label="Description"
></q-input> ></q-input>
<q-select <q-select
filled filled
dense dense
emit-value emit-value
v-model="stallDialog.data.wallet" v-model="stallDialog.data.wallet"
:options="walletOptions" :options="walletOptions"
label="Wallet *" label="Wallet *"
> >
</q-select> </q-select>
<q-select <q-select
filled filled
dense dense
v-model="stallDialog.data.currency" v-model="stallDialog.data.currency"
type="text" type="text"
label="Unit" label="Unit"
:options="currencies" :options="currencies"
></q-select> ></q-select>
<q-select <q-select
:options="filteredZoneOptions" :options="filteredZoneOptions"
filled filled
dense dense
multiple multiple
v-model.trim="stallDialog.data.shippingZones" v-model.trim="stallDialog.data.shippingZones"
label="Shipping Zones" label="Shipping Zones"
></q-select> ></q-select>
<div class="row q-mt-lg">
<q-btn
unelevated
color="primary"
:disable="!stallDialog.data.name
|| !stallDialog.data.currency
|| !stallDialog.data.wallet
|| !stallDialog.data.shippingZones
|| !stallDialog.data.shippingZones.length"
type="submit"
:label="stallDialog.data.id ? 'Restore Stall' : 'Create 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>
<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">
<q-item
v-for="pendingStall of pendingStalls"
:key="pendingStall.id"
tag="label"
class="full-width"
v-ripple
>
<q-item-section>
<q-item-label
><span v-text="pendingStall.name"></span
></q-item-label>
<q-item-label caption
><span v-text="pendingStall.config?.description"></span
></q-item-label>
</q-item-section>
<q-item-section class="q-pl-xl float-right">
<q-btn
@click="openRestoreStallDialog(pendingStall)"
v-close-popup
flat
color="green"
class="q-ml-auto float-right"
>Restore</q-btn
>
</q-item-section>
<q-item-section class="float-right">
<q-btn
@click="deleteStall(pendingStall)"
v-close-popup
color="red"
class="q-ml-auto float-right"
icon="cancel"
></q-btn>
</q-item-section>
</q-item>
</div>
<div v-else>There are no stalls to be restored.</div>
<div class="row q-mt-lg"> <div class="row q-mt-lg">
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn> <q-btn
unelevated
color="primary"
:disable="!stallDialog.data.name
|| !stallDialog.data.currency
|| !stallDialog.data.wallet
|| !stallDialog.data.shippingZones
|| !stallDialog.data.shippingZones.length"
type="submit"
:label="stallDialog.data.id ? 'Restore Stall' : 'Create Stall'"
></q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div> </div>
</q-card> </q-form>
</q-dialog> </q-card>
</div> </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">
<q-item
v-for="pendingStall of pendingStalls"
:key="pendingStall.id"
tag="label"
class="full-width"
v-ripple
>
<q-item-section>
<q-item-label
><span v-text="pendingStall.name"></span
></q-item-label>
<q-item-label caption
><span v-text="pendingStall.config?.description"></span
></q-item-label>
</q-item-section>
<q-item-section class="q-pl-xl float-right">
<q-btn
@click="openRestoreStallDialog(pendingStall)"
v-close-popup
flat
color="green"
class="q-ml-auto float-right"
>Restore</q-btn
>
</q-item-section>
<q-item-section class="float-right">
<q-btn
@click="deleteStall(pendingStall)"
v-close-popup
color="red"
class="q-ml-auto float-right"
icon="cancel"
></q-btn>
</q-item-section>
</q-item>
</div>
<div v-else>There are no stalls 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> </div>

View file

@ -1,93 +1,220 @@
{% 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"
:merchant-id="merchant.id" active-color="primary"
:inkey="g.user.wallets[0].inkey" indicator-color="primary"
:adminkey="g.user.wallets[0].adminkey" align="left"
:show-keys="showKeys" >
@toggle-show-keys="toggleShowKeys" <q-tab
@merchant-deleted="handleMerchantDeleted" name="merchant"
></merchant-details> label="Merchant"
</div> icon="person"
<div class="col-12 col-sm-auto q-mx-sm"> style="min-width: 120px"
<div class="row items-center no-wrap"> ></q-tab>
<q-toggle <q-tab
@update:model-value="toggleMerchantState()" name="shipping"
size="md" label="Shipping"
checked-icon="check" icon="local_shipping"
v-model="merchant.config.active" style="min-width: 120px"
color="primary" ></q-tab>
unchecked-icon="clear" <q-tab
/> name="stalls"
<span label="Stalls"
class="q-ml-sm" icon="store"
v-text="merchant.config.active ? 'Accepting Orders': 'Orders Paused'" style="min-width: 120px"
></span> ></q-tab>
</div> <q-tab
</div> name="products"
<div class="col-12 col-sm-auto q-ml-sm-auto"> label="Products"
<shipping-zones icon="inventory_2"
:inkey="g.user.wallets[0].inkey" style="min-width: 120px"
:adminkey="g.user.wallets[0].adminkey" ></q-tab>
></shipping-zones> <q-tab
</div> name="messages"
</div> label="Messages"
</q-card-section> icon="chat"
<q-card-section v-if="showKeys"> style="min-width: 120px"
<div class="row q-mb-md"> ></q-tab>
<div class="col"> <q-tab
<q-btn name="orders"
unelevated label="Orders"
color="grey" icon="receipt"
outline style="min-width: 120px"
@click="showKeys = false" ></q-tab>
class="float-left" </q-tabs>
>Hide Keys</q-btn <div class="col-auto q-mr-md">
> <q-btn-dropdown
</div> 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>
</div>
<div class="row"> <q-separator></q-separator>
<div class="col">
<key-pair <q-tab-panels v-model="activeTab" animated>
:public-key="merchant.public_key" <!-- Merchant Tab -->
:private-key="merchant.private_key" <q-tab-panel name="merchant">
></key-pair> <merchant-tab
</div> :merchant-id="merchant.id"
</div> :inkey="g.user.wallets[0].inkey"
</q-card-section> :adminkey="g.user.wallets[0].adminkey"
</q-card> :show-keys="showKeys"
<q-card class="q-mt-lg"> :merchant-active="merchant.config.active"
<q-card-section> :public-key="merchant.public_key"
<stall-list :private-key="merchant.private_key"
:adminkey="g.user.wallets[0].adminkey" :is-admin="g.user.admin"
:inkey="g.user.wallets[0].inkey" @toggle-show-keys="toggleShowKeys"
:wallet-options="g.user.walletOptions" @hide-keys="showKeys = false"
@customer-selected-for-order="customerSelectedForOrder" @merchant-deleted="handleMerchantDeleted"
></stall-list> @toggle-merchant-state="toggleMerchantState"
</q-card-section> @restart-nostr-connection="restartNostrConnection"
</q-card> ></merchant-tab>
<q-card class="q-mt-lg"> </q-tab-panel>
<q-card-section>
<div class="row"> <!-- Shipping Tab -->
<div class="col-12"> <q-tab-panel name="shipping">
<order-list <shipping-zones-list
ref="orderListRef" :inkey="g.user.wallets[0].inkey"
:adminkey="g.user.wallets[0].adminkey" :adminkey="g.user.wallets[0].adminkey"
:inkey="g.user.wallets[0].inkey" ></shipping-zones-list>
:customer-pubkey-filter="orderPubkey" </q-tab-panel>
@customer-selected="customerSelectedForOrder"
></order-list> <!-- Stalls Tab -->
</div> <q-tab-panel name="stalls">
</div> <stall-list
</q-card-section> :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-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"
:inkey="g.user.wallets[0].inkey"
:customer-pubkey-filter="orderPubkey"
@customer-selected="customerSelectedForOrder"
></order-list>
</q-tab-panel>
</q-tab-panels>
</q-card> </q-card>
</div> </div>
<q-card v-else> <q-card v-else>
@ -218,11 +345,21 @@
</div> </div>
<div class="col-12"> <div class="col-12">
<q-card> <q-card>
<q-img
src="/nostrmarket/static/market/images/nostr-cover.png"
:ratio="3"
fit="cover"
></q-img>
<q-card-section> <q-card-section>
<h6 class="text-subtitle1 q-my-none">Nostr Market</h6> <div class="text-h6 q-mb-sm">Nostr Market</div>
<div class="text-body2 text-grey">
A decentralized marketplace extension for LNbits implementing the
NIP-15 protocol. Create stalls, list products, and accept Lightning
payments while communicating with customers via encrypted Nostr
direct messages.
</div>
</q-card-section> </q-card-section>
<q-card-section class="q-pa-none"> <q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list> {% include "nostrmarket/_api_docs.html" %} </q-list> <q-list> {% include "nostrmarket/_api_docs.html" %} </q-list>
</q-card-section> </q-card-section>
</q-card> </q-card>
@ -307,6 +444,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>
@ -319,5 +465,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 %}