diff --git a/.gitignore b/.gitignore index f3a8853..056489e 100644 --- a/.gitignore +++ b/.gitignore @@ -22,4 +22,7 @@ node_modules *.swp *.pyo *.pyc -*.env \ No newline at end of file +*.env + +# Claude Code config +CLAUDE.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..db2ef06 --- /dev/null +++ b/CLAUDE.md @@ -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 diff --git a/config.json b/config.json index a02b0b0..1aa34a8 100644 --- a/config.json +++ b/config.json @@ -2,7 +2,7 @@ "name": "Nostr Market", "version": "1.1.0", "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", "contributors": [ { diff --git a/static/components/merchant-tab.js b/static/components/merchant-tab.js new file mode 100644 index 0000000..c82d853 --- /dev/null +++ b/static/components/merchant-tab.js @@ -0,0 +1,37 @@ +window.app.component('merchant-tab', { + name: 'merchant-tab', + template: '#merchant-tab', + delimiters: ['${', '}'], + props: [ + 'merchant-id', + 'inkey', + 'adminkey', + 'show-keys', + 'merchant-active', + 'public-key', + 'private-key', + 'is-admin' + ], + computed: { + marketClientUrl: function () { + return '/nostrmarket/market' + } + }, + methods: { + toggleShowKeys: function () { + this.$emit('toggle-show-keys') + }, + hideKeys: function () { + this.$emit('hide-keys') + }, + handleMerchantDeleted: function () { + this.$emit('merchant-deleted') + }, + toggleMerchantState: function () { + this.$emit('toggle-merchant-state') + }, + restartNostrConnection: function () { + this.$emit('restart-nostr-connection') + } + } +}) diff --git a/static/components/product-list.js b/static/components/product-list.js new file mode 100644 index 0000000..78379ae --- /dev/null +++ b/static/components/product-list.js @@ -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() + } +}) diff --git a/static/components/shipping-zones-list.js b/static/components/shipping-zones-list.js new file mode 100644 index 0000000..d93b26c --- /dev/null +++ b/static/components/shipping-zones-list.js @@ -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() + } +}) diff --git a/static/components/shipping-zones.js b/static/components/shipping-zones.js index 742021a..9332d1e 100644 --- a/static/components/shipping-zones.js +++ b/static/components/shipping-zones.js @@ -19,7 +19,6 @@ window.app.component('shipping-zones', { currencies: [], shippingZoneOptions: [ 'Free (digital)', - 'Flat rate', 'Worldwide', 'Europe', 'Australia', @@ -27,6 +26,7 @@ window.app.component('shipping-zones', { 'Belgium', 'Brazil', 'Canada', + 'China', 'Denmark', 'Finland', 'France', @@ -34,8 +34,8 @@ window.app.component('shipping-zones', { 'Greece', 'Hong Kong', 'Hungary', - 'Ireland', 'Indonesia', + 'Ireland', 'Israel', 'Italy', 'Japan', @@ -59,10 +59,9 @@ window.app.component('shipping-zones', { 'Thailand', 'Turkey', 'Ukraine', - 'United Kingdom**', - 'United States***', - 'Vietnam', - 'China' + 'United Kingdom', + 'United States', + 'Vietnam' ] } }, @@ -162,22 +161,13 @@ window.app.component('shipping-zones', { LNbits.utils.notifyApiError(error) } }, - async getCurrencies() { - try { - const {data} = await LNbits.api.request( - 'GET', - '/nostrmarket/api/v1/currencies', - this.inkey - ) - - this.currencies = ['sat', ...data] - } catch (error) { - LNbits.utils.notifyApiError(error) - } + getCurrencies() { + const currencies = window.g.allowedCurrencies || [] + this.currencies = ['sat', ...currencies] } }, created: async function () { await this.getZones() - await this.getCurrencies() + this.getCurrencies() } }) diff --git a/static/components/stall-list.js b/static/components/stall-list.js index 1ef4d70..0cdfb04 100644 --- a/static/components/stall-list.js +++ b/static/components/stall-list.js @@ -2,7 +2,7 @@ window.app.component('stall-list', { name: 'stall-list', template: '#stall-list', delimiters: ['${', '}'], - props: [`adminkey`, 'inkey', 'wallet-options'], + props: ['adminkey', 'inkey', 'wallet-options'], data: function () { return { filter: '', @@ -20,21 +20,21 @@ window.app.component('stall-list', { shippingZones: [] } }, + editDialog: { + show: false, + data: { + id: '', + name: '', + description: '', + wallet: null, + currency: 'sat', + shippingZones: [] + } + }, zoneOptions: [], stallsTable: { columns: [ - { - name: '', - align: 'left', - label: '', - field: '' - }, - { - name: 'id', - align: 'left', - label: 'Name', - field: 'id' - }, + {name: 'name', align: 'left', label: 'Name', field: 'name'}, { name: 'currency', align: 'left', @@ -45,14 +45,15 @@ window.app.component('stall-list', { name: 'description', align: 'left', label: 'Description', - field: 'description' + field: row => row.config?.description || '' }, { name: 'shippingZones', align: 'left', label: 'Shipping Zones', - field: 'shippingZones' - } + field: row => row.shipping_zones?.map(z => z.name).join(', ') || '' + }, + {name: 'actions', align: 'right', label: 'Actions', field: ''} ], pagination: { rowsPerPage: 10 @@ -65,6 +66,11 @@ window.app.component('stall-list', { return this.zoneOptions.filter( z => z.currency === this.stallDialog.data.currency ) + }, + editFilteredZoneOptions: function () { + return this.zoneOptions.filter( + z => z.currency === this.editDialog.data.currency + ) } }, methods: { @@ -94,7 +100,6 @@ window.app.component('stall-list', { stall ) this.stallDialog.show = false - data.expanded = false this.stalls.unshift(data) this.$q.notify({ type: 'positive', @@ -114,7 +119,6 @@ window.app.component('stall-list', { stallData ) this.stallDialog.show = false - data.expanded = false this.stalls.unshift(data) this.$q.notify({ type: 'positive', @@ -124,44 +128,66 @@ window.app.component('stall-list', { LNbits.utils.notifyApiError(error) } }, - deleteStall: async function (pendingStall) { - LNbits.utils - .confirmDialog( - ` - Are you sure you want to delete this pending stall '${pendingStall.name}'? - ` - ) - .onOk(async () => { - try { - await LNbits.api.request( - 'DELETE', - '/nostrmarket/api/v1/stall/' + pendingStall.id, - this.adminkey - ) - this.$q.notify({ - type: 'positive', - message: 'Pending Stall Deleted', - timeout: 5000 - }) - } catch (error) { - console.warn(error) - LNbits.utils.notifyApiError(error) - } - }) - }, - getCurrencies: async function () { + updateStall: async function () { try { + const stallData = { + id: this.editDialog.data.id, + name: this.editDialog.data.name, + wallet: this.editDialog.data.wallet, + currency: this.editDialog.data.currency, + shipping_zones: this.editDialog.data.shippingZones, + config: { + description: this.editDialog.data.description + } + } const {data} = await LNbits.api.request( - 'GET', - '/nostrmarket/api/v1/currencies', - this.inkey + 'PUT', + `/nostrmarket/api/v1/stall/${stallData.id}`, + this.adminkey, + stallData ) - - return ['sat', ...data] + this.editDialog.show = false + const index = this.stalls.findIndex(s => s.id === data.id) + if (index !== -1) { + this.stalls.splice(index, 1, data) + } + this.$q.notify({ + type: 'positive', + message: 'Stall updated!' + }) } catch (error) { LNbits.utils.notifyApiError(error) } - return [] + }, + deleteStall: async function (stall) { + try { + await LNbits.api.request( + 'DELETE', + '/nostrmarket/api/v1/stall/' + stall.id, + this.adminkey + ) + this.stalls = this.stalls.filter(s => s.id !== stall.id) + this.pendingStalls = this.pendingStalls.filter(s => s.id !== stall.id) + this.$q.notify({ + type: 'positive', + message: 'Stall deleted' + }) + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + confirmDeleteStall: function (stall) { + LNbits.utils + .confirmDialog( + `Products and orders will be deleted also! Are you sure you want to delete stall "${stall.name}"?` + ) + .onOk(async () => { + await this.deleteStall(stall) + }) + }, + getCurrencies: function () { + const currencies = window.g.allowedCurrencies || [] + return ['sat', ...currencies] }, getStalls: async function (pending = false) { try { @@ -170,7 +196,7 @@ window.app.component('stall-list', { `/nostrmarket/api/v1/stall?pending=${pending}`, this.inkey ) - return data.map(s => ({...s, expanded: false})) + return data } catch (error) { LNbits.utils.notifyApiError(error) } @@ -194,20 +220,8 @@ window.app.component('stall-list', { } return [] }, - handleStallDeleted: function (stallId) { - this.stalls = _.reject(this.stalls, function (obj) { - return obj.id === stallId - }) - }, - handleStallUpdated: function (stall) { - const index = this.stalls.findIndex(r => r.id === stall.id) - if (index !== -1) { - stall.expanded = true - this.stalls.splice(index, 1, stall) - } - }, openCreateStallDialog: async function (stallData) { - this.currencies = await this.getCurrencies() + this.currencies = this.getCurrencies() this.zoneOptions = await this.getZones() if (!this.zoneOptions || !this.zoneOptions.length) { this.$q.notify({ @@ -225,6 +239,24 @@ window.app.component('stall-list', { } this.stallDialog.show = true }, + openEditStallDialog: async function (stall) { + this.currencies = this.getCurrencies() + this.zoneOptions = await this.getZones() + this.editDialog.data = { + id: stall.id, + name: stall.name, + description: stall.config?.description || '', + wallet: stall.wallet, + currency: stall.currency, + shippingZones: (stall.shipping_zones || []).map(z => ({ + ...z, + label: z.name + ? `${z.name} (${z.countries.join(', ')})` + : z.countries.join(', ') + })) + } + this.editDialog.show = true + }, openSelectPendingStallDialog: async function () { this.stallDialog.showRestore = true this.pendingStalls = await this.getStalls(true) @@ -246,8 +278,11 @@ window.app.component('stall-list', { })) }) }, - customerSelectedForOrder: function (customerPubkey) { - this.$emit('customer-selected-for-order', customerPubkey) + goToProducts: function (stall) { + this.$emit('go-to-products', stall.id) + }, + goToOrders: function (stall) { + this.$emit('go-to-orders', stall.id) }, shortLabel(value = '') { if (value.length <= 64) return value @@ -256,7 +291,7 @@ window.app.component('stall-list', { }, created: async function () { this.stalls = await this.getStalls() - this.currencies = await this.getCurrencies() + this.currencies = this.getCurrencies() this.zoneOptions = await this.getZones() } }) diff --git a/static/images/generate_logo.py b/static/images/generate_logo.py new file mode 100644 index 0000000..cb66c59 --- /dev/null +++ b/static/images/generate_logo.py @@ -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") diff --git a/static/images/nostr-market.png b/static/images/nostr-market.png new file mode 100644 index 0000000..3e924b5 Binary files /dev/null and b/static/images/nostr-market.png differ diff --git a/static/js/index.js b/static/js/index.js index dd41d41..e570dde 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -5,6 +5,8 @@ window.app = Vue.createApp({ mixins: [window.windowMixin], data: function () { return { + activeTab: 'merchant', + selectedStallFilter: null, merchant: {}, shippingZones: [], activeChatCustomer: '', @@ -295,6 +297,88 @@ window.app = Vue.createApp({ LNbits.utils.notifyApiError(error) } }) + }, + publishNip15: async function () { + try { + const {data: stalls} = await LNbits.api.request( + 'GET', + '/nostrmarket/api/v1/stall?pending=false', + this.g.user.wallets[0].inkey + ) + for (const stall of stalls) { + await LNbits.api.request( + 'PUT', + `/nostrmarket/api/v1/stall/${stall.id}`, + this.g.user.wallets[0].adminkey, + stall + ) + } + // Fetch products from all stalls + let productCount = 0 + for (const stall of stalls) { + const {data: products} = await LNbits.api.request( + 'GET', + `/nostrmarket/api/v1/stall/product/${stall.id}?pending=false`, + this.g.user.wallets[0].inkey + ) + for (const product of products) { + await LNbits.api.request( + 'PATCH', + `/nostrmarket/api/v1/product/${product.id}`, + this.g.user.wallets[0].adminkey, + product + ) + productCount++ + } + } + this.$q.notify({ + type: 'positive', + message: `Published ${stalls.length} stall(s) and ${productCount} product(s) to Nostr (NIP-15)` + }) + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + refreshNip15: async function () { + LNbits.utils + .confirmDialog( + 'This will sync your stalls and products from Nostr relays. Continue?' + ) + .onOk(async () => { + try { + await LNbits.api.request( + 'PUT', + '/nostrmarket/api/v1/restart', + this.g.user.wallets[0].adminkey + ) + this.$q.notify({ + type: 'positive', + message: 'Refreshing NIP-15 data from Nostr...' + }) + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }) + }, + deleteNip15: async function () { + LNbits.utils + .confirmDialog( + 'WARNING: This will delete all your stalls and products from Nostr relays. This cannot be undone! Are you sure?' + ) + .onOk(async () => { + this.$q.notify({ + type: 'info', + message: 'Delete NIP-15 from Nostr not yet implemented' + }) + }) + }, + goToProducts: function (stallId) { + this.selectedStallFilter = stallId + this.activeTab = 'products' + }, + goToOrders: function (stallId) { + this.selectedStallFilter = stallId + this.activeTab = 'orders' } }, created: async function () { diff --git a/templates/nostrmarket/_api_docs.html b/templates/nostrmarket/_api_docs.html index d801649..664d6ba 100644 --- a/templates/nostrmarket/_api_docs.html +++ b/templates/nostrmarket/_api_docs.html @@ -1,61 +1,213 @@ - - -

- Create, edit and publish products to your Nostr relays. Customers can - browse your stalls and pay with Lightning. -

-

- - Created by + + + +

+ Nostr (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. +

+

+ 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! +

+
+
+ + + + + +

1. Generate or Import Keys

+

+ Create a new Nostr identity or import an existing one using your nsec. + Your keys are used to sign all marketplace events. +

+

2. Create a Stall

+

+ A stall is your shop. Give it a name, description, and configure + shipping zones for delivery. +

+

3. Add Products

+

+ List items for sale with images, descriptions, and prices in your + preferred currency. +

+

4. Publish to Nostr

+

+ Your stall and products are published to Nostr relays where customers + can discover them using any compatible marketplace client. +

+
+
+
+ + + + +

+ Decentralized Commerce - Your shop exists on Nostr + relays, not a single server. No platform fees, no deplatforming risk. +

+

+ Lightning Payments - Accept instant, low-fee Bitcoin + payments via the Lightning Network. +

+

+ Encrypted Messages - Communicate privately with + customers using NIP-04 encrypted direct messages. +

+

+ Portable Identity - Your merchant reputation travels + with your Nostr keys across any compatible marketplace. +

+

+ Global Reach - Your stalls and products are + automatically visible on any Nostr marketplace client that supports + NIP-15, including Amethyst, Plebeian Market, and others. +

+
+
+
+ + + + +

+ Browse the Market - Use the Market Client to discover + stalls and products from merchants around the world. +

+

+ Pay with Lightning - Fast, private payments with + minimal fees using Bitcoin's Lightning Network. +

+

+ Direct Communication - Message merchants directly via + encrypted Nostr DMs for questions, custom orders, or support. +

+
+
+
+ + + + +

This extension was created by:

+
Tal Vasconcelos, - Ben Arc, - motorina0 - -

- - - - Market client - Visit the market client - -
- - API Documentation - Swagger REST API Documentation - -
- - - - Requires:  - nostrclient - extension to be installed and configured with relays. - - - + Tal Vasconcelos + + + Ben Arc + + + motorina0 + + + Ben Weeks + +
+ + + + + + + + + + + + Market Client + Browse and shop from stalls + + + + + + + + + + + + API Documentation + Swagger REST API reference + + + + + + + + + + + + NIP-15 Specification + Nostr Marketplace protocol + + + + + + + + + + + + Report Issues / Feedback + GitHub Issues + + + + + diff --git a/templates/nostrmarket/components/merchant-tab.html b/templates/nostrmarket/components/merchant-tab.html new file mode 100644 index 0000000..0684c62 --- /dev/null +++ b/templates/nostrmarket/components/merchant-tab.html @@ -0,0 +1,104 @@ +
+
+
+
+
+ +
+
+
+ + +
+
+
+ + + Restart the connection to the nostrclient extension + + +
+
+
+
+
+ Hide Keys +
+
+
+
+ +
+
+
+
+
+ + +
Nostr Market Extension
+
+ + + + + + + A decentralized marketplace powered by Nostr and Lightning + Network. Create stalls, add products, and start selling with + Bitcoin. + + + + + + + Open Market Client + + + + + +
+
+
+
diff --git a/templates/nostrmarket/components/product-list.html b/templates/nostrmarket/components/product-list.html new file mode 100644 index 0000000..6d24b84 --- /dev/null +++ b/templates/nostrmarket/components/product-list.html @@ -0,0 +1,309 @@ +
+
+
+ + + +
+
+ + + + + All Stalls + + + + + + + + + + + + + + + + +
+
+ +
+
+ +
+
+ +
+ +
No stalls found. Please create a stall first in the Stalls tab.
+
+ + + + + + + + + + + + + + + +
+
+ +
+
+ +
+
+ + +
+ +
+
+ + +
+ + + + + + +
+
+ +
+ + Create Product + Cancel +
+
+
+
+ + + + +
+ + + + + + + Restore + + +
+
There are no products to be restored.
+
+ Close +
+
+
+
diff --git a/templates/nostrmarket/components/shipping-zones-list.html b/templates/nostrmarket/components/shipping-zones-list.html new file mode 100644 index 0000000..111ba90 --- /dev/null +++ b/templates/nostrmarket/components/shipping-zones-list.html @@ -0,0 +1,136 @@ +
+
+
+ + + +
+
+ +
+
+ + + + + + + + + + + + +
+
+ Update +
+
+ Create Shipping Zone +
+ + Cancel +
+
+
+
+
diff --git a/templates/nostrmarket/components/shipping-zones.html b/templates/nostrmarket/components/shipping-zones.html index 3f0fd08..b0dbc34 100644 --- a/templates/nostrmarket/components/shipping-zones.html +++ b/templates/nostrmarket/components/shipping-zones.html @@ -48,26 +48,36 @@ label="Countries" v-model="zoneDialog.data.countries" > - - +
+
+ +
+
+ +
+
Update @@ -83,7 +93,7 @@ Create Shipping Zone diff --git a/templates/nostrmarket/components/stall-list.html b/templates/nostrmarket/components/stall-list.html index 673e8a7..a680a50 100644 --- a/templates/nostrmarket/components/stall-list.html +++ b/templates/nostrmarket/components/stall-list.html @@ -1,43 +1,38 @@
-
-
- - - - New Stall - Create a new stall - - - - - Restore Stall - Restore existing stall from Nostr - - - +
+
-