Compare commits
No commits in common. "main" and "v0.7.1" have entirely different histories.
10
.github/workflows/ci.yml
vendored
|
|
@ -1,10 +0,0 @@
|
||||||
name: CI
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
pull_request:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
lint:
|
|
||||||
uses: lnbits/lnbits/.github/workflows/lint.yml@dev
|
|
||||||
6
.gitignore
vendored
|
|
@ -16,13 +16,9 @@ __pycache__
|
||||||
htmlcov
|
htmlcov
|
||||||
test-reports
|
test-reports
|
||||||
tests/data/*.sqlite3
|
tests/data/*.sqlite3
|
||||||
node_modules
|
|
||||||
|
|
||||||
*.swo
|
*.swo
|
||||||
*.swp
|
*.swp
|
||||||
*.pyo
|
*.pyo
|
||||||
*.pyc
|
*.pyc
|
||||||
*.env
|
*.env
|
||||||
|
|
||||||
# Claude Code config
|
|
||||||
CLAUDE.md
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
**/.git
|
|
||||||
**/.svn
|
|
||||||
**/.hg
|
|
||||||
**/node_modules
|
|
||||||
|
|
||||||
*.yml
|
|
||||||
|
|
||||||
**/static/market/*
|
|
||||||
**/static/js/nostr.bundle.js*
|
|
||||||
|
|
||||||
|
|
||||||
flake.lock
|
|
||||||
|
|
||||||
.venv
|
|
||||||
12
.prettierrc
|
|
@ -1,12 +0,0 @@
|
||||||
{
|
|
||||||
"semi": false,
|
|
||||||
"arrowParens": "avoid",
|
|
||||||
"insertPragma": false,
|
|
||||||
"printWidth": 80,
|
|
||||||
"proseWrap": "preserve",
|
|
||||||
"singleQuote": true,
|
|
||||||
"trailingComma": "none",
|
|
||||||
"useTabs": false,
|
|
||||||
"bracketSameLine": false,
|
|
||||||
"bracketSpacing": false
|
|
||||||
}
|
|
||||||
104
CLAUDE.md
|
|
@ -1,104 +0,0 @@
|
||||||
# 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
|
|
||||||
47
Makefile
|
|
@ -1,47 +0,0 @@
|
||||||
all: format check
|
|
||||||
|
|
||||||
format: prettier black ruff
|
|
||||||
|
|
||||||
check: mypy pyright checkblack checkruff checkprettier
|
|
||||||
|
|
||||||
prettier:
|
|
||||||
uv run ./node_modules/.bin/prettier --write .
|
|
||||||
pyright:
|
|
||||||
uv run ./node_modules/.bin/pyright
|
|
||||||
|
|
||||||
mypy:
|
|
||||||
uv run mypy .
|
|
||||||
|
|
||||||
black:
|
|
||||||
uv run black .
|
|
||||||
|
|
||||||
ruff:
|
|
||||||
uv run ruff check . --fix
|
|
||||||
|
|
||||||
checkruff:
|
|
||||||
uv run ruff check .
|
|
||||||
|
|
||||||
checkprettier:
|
|
||||||
uv run ./node_modules/.bin/prettier --check .
|
|
||||||
|
|
||||||
checkblack:
|
|
||||||
uv run black --check .
|
|
||||||
|
|
||||||
checkeditorconfig:
|
|
||||||
editorconfig-checker
|
|
||||||
|
|
||||||
test:
|
|
||||||
PYTHONUNBUFFERED=1 \
|
|
||||||
DEBUG=true \
|
|
||||||
uv run pytest
|
|
||||||
install-pre-commit-hook:
|
|
||||||
@echo "Installing pre-commit hook to git"
|
|
||||||
@echo "Uninstall the hook with uv run pre-commit uninstall"
|
|
||||||
uv run pre-commit install
|
|
||||||
|
|
||||||
pre-commit:
|
|
||||||
uv run pre-commit run --all-files
|
|
||||||
|
|
||||||
|
|
||||||
checkbundle:
|
|
||||||
@echo "skipping checkbundle"
|
|
||||||
39
README.md
|
|
@ -1,32 +1,22 @@
|
||||||
<a href="https://lnbits.com" target="_blank" rel="noopener noreferrer">
|
|
||||||
<picture>
|
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png">
|
|
||||||
<img src="https://i.imgur.com/fyKPgVT.png" alt="LNbits" style="width:280px">
|
|
||||||
</picture>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
[](./LICENSE)
|
|
||||||
[](https://github.com/lnbits/lnbits)
|
|
||||||
|
|
||||||
# Nostr Market ([NIP-15](https://github.com/nostr-protocol/nips/blob/master/15.md)) - <small>[LNbits](https://github.com/lnbits/lnbits) extension</small>
|
# Nostr Market ([NIP-15](https://github.com/nostr-protocol/nips/blob/master/15.md)) - <small>[LNbits](https://github.com/lnbits/lnbits) extension</small>
|
||||||
|
|
||||||
<small>For more about LNBits extension check [this tutorial](https://github.com/lnbits/lnbits/wiki/LNbits-Extensions).</small>
|
<small>For more about LNBits extension check [this tutorial](https://github.com/lnbits/lnbits/wiki/LNbits-Extensions).</small>
|
||||||
|
|
||||||
|
|
||||||
**Demo at Nostrica <a href="https://www.youtube.com/live/2NueacYJovA?feature=share&t=6846">here</a>**.
|
**Demo at Nostrica <a href="https://www.youtube.com/live/2NueacYJovA?feature=share&t=6846">here</a>**.
|
||||||
|
|
||||||
**Original protocol for [Diagon Alley](https://github.com/lnbits/Diagon-Alley) (resilient marketplaces)**
|
**Original protocol for [Diagon Alley](https://github.com/lnbits/Diagon-Alley) (resilient marketplaces)**
|
||||||
|
|
||||||
> The concepts around resilience in Diagon Alley helped influence the creation of the NOSTR protocol, now we get to build Diagon Alley on NOSTR!
|
> The concepts around resilience in Diagon Alley helped influence the creation of the NOSTR protocol, now we get to build Diagon Alley on NOSTR!
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
This extension uses the LNbits [nostrclient](https://github.com/lnbits/nostrclient) extension, an extension that makes _nostrfying_ other extensions easy.
|
This extension uses the LNbits [nostrclient](https://github.com/lnbits/nostrclient) extension, an extension that makes _nostrfying_ other extensions easy.
|
||||||

|

|
||||||
|
|
||||||
- before you continue, please make sure that [nostrclient](https://github.com/lnbits/nostrclient) extension is installed, activated and correctly configured.
|
- before you continue, please make sure that [nostrclient](https://github.com/lnbits/nostrclient) extension is installed, activated and correctly configured.
|
||||||
- [nostrclient](https://github.com/lnbits/nostrclient) is usually installed as admin-only extension, so if you do not have admin access please ask an admin to confirm that [nostrclient](https://github.com/lnbits/nostrclient) is OK.
|
- [nostrclient](https://github.com/lnbits/nostrclient) is usually installed as admin-only extension, so if you do not have admin access please ask an admin to confirm that [nostrclient](https://github.com/lnbits/nostrclient) is OK.
|
||||||
- see the [Troubleshoot](https://github.com/lnbits/nostrclient#troubleshoot) section for more details on how to check the health of `nostrclient` extension
|
- see the [Troubleshoot](https://github.com/lnbits/nostrclient#troubleshoot) section for more details on how to check the health of `nostrclient` extension
|
||||||
|
|
||||||
|
|
||||||
## Create, or import, a merchant account
|
## Create, or import, a merchant account
|
||||||
|
|
||||||
As a merchant you need to provide a Nostr key pair, or the extension can generate one for you.
|
As a merchant you need to provide a Nostr key pair, or the extension can generate one for you.
|
||||||
|
|
@ -107,39 +97,35 @@ Make sure to add your `merchant` public key to the list:
|
||||||

|

|
||||||
|
|
||||||
### Styling
|
### Styling
|
||||||
|
|
||||||
In order to create a customized Marketplace, we use `naddr` as defined in [NIP-19](https://github.com/nostr-protocol/nips/blob/master/19.md#shareable-identifiers-with-extra-metadata). You must create an event (kind: `30019`) that has all the custom properties, including merchants and relays, of your marketplace. Start by going to the marketplace page:
|
In order to create a customized Marketplace, we use `naddr` as defined in [NIP-19](https://github.com/nostr-protocol/nips/blob/master/19.md#shareable-identifiers-with-extra-metadata). You must create an event (kind: `30019`) that has all the custom properties, including merchants and relays, of your marketplace. Start by going to the marketplace page:
|
||||||

|

|
||||||
|
|
||||||
You'll need to Login, and head over to _Marketplace Info_. Optionally import some merchants and relays, that will be included in the event. Click on _Edit_ and fill out your marketplace custom info:
|
You'll need to Login, and head over to *Marketplace Info*. Optionally import some merchants and relays, that will be included in the event. Click on *Edit* and fill out your marketplace custom info:
|
||||||

|

|
||||||
|
|
||||||
Fill in the optional fields:
|
Fill in the optional fields:
|
||||||
|
|
||||||
- Add a name to the Marketplace
|
- Add a name to the Marketplace
|
||||||
- Add a small description
|
- Add a small description
|
||||||
- Add a logo image URL
|
- Add a logo image URL
|
||||||
- Add a banner image URL (max height: 250px)
|
- Add a banner image URL (max height: 250px)
|
||||||
- Choose a theme
|
- Choose a theme
|
||||||
|
|
||||||
By clicking _Publish_, a `kind: 30019` event will be sent to the defined relays containing all the information about your custom Marketplace. On the left drawer, a button with _Copy Naddr_ will show up.
|
By clicking *Publish*, a `kind: 30019` event will be sent to the defined relays containing all the information about your custom Marketplace. On the left drawer, a button with *Copy Naddr* will show up.
|
||||||

|

|
||||||
|
|
||||||
You can then share your Marketplace, with the merchants and relays, banner, and style by using that Nostr identifier. The URL for the marketplace will be for example: `https://legend.lnbits.com/nostrmarket/market?naddr=naddr1qqfy6ctjddjhgurvv93k....`, you need to include the URL parameter `naddr=<your naddr>`. When a user visits that URL, the client will get the `30019` event and configure the Marketplace to what you defined. In the example bellow, a couple of merchants, relays, `autumn` theme, name (_Veggies Market_) and a header banner:
|
You can then share your Marketplace, with the merchants and relays, banner, and style by using that Nostr identifier. The URL for the marketplace will be for example: `https://legend.lnbits.com/nostrmarket/market?naddr=naddr1qqfy6ctjddjhgurvv93k....`, you need to include the URL parameter `naddr=<your naddr>`. When a user visits that URL, the client will get the `30019` event and configure the Marketplace to what you defined. In the example bellow, a couple of merchants, relays, `autumn` theme, name (*Veggies Market*) and a header banner:
|
||||||

|

|
||||||
|
|
||||||
The nostr event is a replaceable event, so you can change it to what you like and publish a new one to replace a previous one. For example adding a new merchant, or remove, change theme, add more relays,e tc...
|
The nostr event is a replaceable event, so you can change it to what you like and publish a new one to replace a previous one. For example adding a new merchant, or remove, change theme, add more relays,e tc...
|
||||||
|
|
||||||
|
|
||||||
## Troubleshoot
|
## Troubleshoot
|
||||||
|
|
||||||
### Check communication with Nostr
|
### Check communication with Nostr
|
||||||
|
|
||||||
In order to test that the integration with Nostr is working fine, one can add an `npub` to the chat box and check that DMs are working as expected:
|
In order to test that the integration with Nostr is working fine, one can add an `npub` to the chat box and check that DMs are working as expected:
|
||||||
|
|
||||||
https://user-images.githubusercontent.com/2951406/236777983-259f81d8-136f-48b3-bb73-80749819b5f9.mov
|
https://user-images.githubusercontent.com/2951406/236777983-259f81d8-136f-48b3-bb73-80749819b5f9.mov
|
||||||
|
|
||||||
### Restart connection to Nostr
|
### Restart connection to Nostr
|
||||||
|
|
||||||
If the communication with Nostr is not working then an admin user can `Restart` the Nostr connection.
|
If the communication with Nostr is not working then an admin user can `Restart` the Nostr connection.
|
||||||
|
|
||||||
Merchants can afterwards re-publish their products.
|
Merchants can afterwards re-publish their products.
|
||||||
|
|
@ -147,9 +133,9 @@ Merchants can afterwards re-publish their products.
|
||||||
https://user-images.githubusercontent.com/2951406/236778651-7ada9f6d-07a1-491c-ac9c-55530326c32a.mp4
|
https://user-images.githubusercontent.com/2951406/236778651-7ada9f6d-07a1-491c-ac9c-55530326c32a.mp4
|
||||||
|
|
||||||
### Check Nostrclient extension
|
### Check Nostrclient extension
|
||||||
|
|
||||||
- see the [Troubleshoot](https://github.com/lnbits/nostrclient#troubleshoot) section for more details on how to check the health of `nostrclient` extension
|
- see the [Troubleshoot](https://github.com/lnbits/nostrclient#troubleshoot) section for more details on how to check the health of `nostrclient` extension
|
||||||
|
|
||||||
|
|
||||||
## Aditional info
|
## Aditional info
|
||||||
|
|
||||||
Stall and product are _Parameterized Replaceable Events_ according to [NIP-33](https://github.com/nostr-protocol/nips/blob/master/33.md) and use kind `30017` and `30018` respectivelly. See [NIP-15](https://github.com/nostr-protocol/nips/blob/master/15.md) for more details.
|
Stall and product are _Parameterized Replaceable Events_ according to [NIP-33](https://github.com/nostr-protocol/nips/blob/master/33.md) and use kind `30017` and `30018` respectivelly. See [NIP-15](https://github.com/nostr-protocol/nips/blob/master/15.md) for more details.
|
||||||
|
|
@ -157,10 +143,3 @@ Stall and product are _Parameterized Replaceable Events_ according to [NIP-33](h
|
||||||
Order placing, invoicing, payment details and order statuses are handled over Nostr using [NIP-04](https://github.com/nostr-protocol/nips/blob/master/04.md).
|
Order placing, invoicing, payment details and order statuses are handled over Nostr using [NIP-04](https://github.com/nostr-protocol/nips/blob/master/04.md).
|
||||||
|
|
||||||
Customer support is handled over whatever communication method was specified. If communicationg via nostr, [NIP-04](https://github.com/nostr-protocol/nips/blob/master/04.md) is used.
|
Customer support is handled over whatever communication method was specified. If communicationg via nostr, [NIP-04](https://github.com/nostr-protocol/nips/blob/master/04.md) is used.
|
||||||
|
|
||||||
## Powered by LNbits
|
|
||||||
|
|
||||||
[LNbits](https://lnbits.com) is a free and open-source lightning accounts system.
|
|
||||||
|
|
||||||
[](https://shop.lnbits.com/)
|
|
||||||
[](https://my.lnbits.com/login)
|
|
||||||
|
|
|
||||||
34
__init__.py
|
|
@ -1,10 +1,11 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
from lnbits.db import Database
|
from lnbits.db import Database
|
||||||
from lnbits.helpers import template_renderer
|
from lnbits.helpers import template_renderer
|
||||||
from lnbits.tasks import create_permanent_unique_task
|
from lnbits.tasks import create_permanent_unique_task
|
||||||
from loguru import logger
|
|
||||||
|
|
||||||
from .nostr.nostr_client import NostrClient
|
from .nostr.nostr_client import NostrClient
|
||||||
|
|
||||||
|
|
@ -23,18 +24,14 @@ nostrmarket_static_files = [
|
||||||
def nostrmarket_renderer():
|
def nostrmarket_renderer():
|
||||||
return template_renderer(["nostrmarket/templates"])
|
return template_renderer(["nostrmarket/templates"])
|
||||||
|
|
||||||
|
|
||||||
nostr_client: NostrClient = NostrClient()
|
nostr_client: NostrClient = NostrClient()
|
||||||
|
|
||||||
|
|
||||||
from .tasks import ( # noqa
|
from .tasks import wait_for_nostr_events, wait_for_paid_invoices
|
||||||
subscription_health_monitor,
|
|
||||||
wait_for_nostr_events,
|
|
||||||
wait_for_paid_invoices,
|
|
||||||
)
|
|
||||||
from .views import * # noqa
|
from .views import * # noqa
|
||||||
from .views_api import * # noqa
|
from .views_api import * # noqa
|
||||||
|
|
||||||
|
|
||||||
scheduled_tasks: list[asyncio.Task] = []
|
scheduled_tasks: list[asyncio.Task] = []
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -60,22 +57,7 @@ def nostrmarket_start():
|
||||||
await asyncio.sleep(15)
|
await asyncio.sleep(15)
|
||||||
await wait_for_nostr_events(nostr_client)
|
await wait_for_nostr_events(nostr_client)
|
||||||
|
|
||||||
task1 = create_permanent_unique_task(
|
task1 = create_permanent_unique_task("ext_nostrmarket_paid_invoices", wait_for_paid_invoices)
|
||||||
"ext_nostrmarket_paid_invoices", wait_for_paid_invoices
|
task2 = create_permanent_unique_task("ext_nostrmarket_subscribe_to_nostr_client", _subscribe_to_nostr_client)
|
||||||
)
|
task3 = create_permanent_unique_task("ext_nostrmarket_wait_for_events", _wait_for_nostr_events)
|
||||||
task2 = create_permanent_unique_task(
|
scheduled_tasks.extend([task1, task2, task3])
|
||||||
"ext_nostrmarket_subscribe_to_nostr_client", _subscribe_to_nostr_client
|
|
||||||
)
|
|
||||||
task3 = create_permanent_unique_task(
|
|
||||||
"ext_nostrmarket_wait_for_events", _wait_for_nostr_events
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _health_monitor():
|
|
||||||
# start after the subscription is active
|
|
||||||
await asyncio.sleep(20)
|
|
||||||
await subscription_health_monitor(nostr_client)
|
|
||||||
|
|
||||||
task4 = create_permanent_unique_task(
|
|
||||||
"ext_nostrmarket_health_monitor", _health_monitor
|
|
||||||
)
|
|
||||||
scheduled_tasks.extend([task1, task2, task3, task4])
|
|
||||||
|
|
|
||||||
57
config.json
|
|
@ -1,60 +1,7 @@
|
||||||
{
|
{
|
||||||
"id": "nostrmarket",
|
|
||||||
"version": "1.1.0",
|
|
||||||
"name": "Nostr Market",
|
"name": "Nostr Market",
|
||||||
"repo": "https://github.com/lnbits/nostrmarket",
|
|
||||||
"short_description": "Nostr Webshop/market on LNbits",
|
"short_description": "Nostr Webshop/market on LNbits",
|
||||||
"description": "",
|
|
||||||
"tile": "/nostrmarket/static/images/bitcoin-shop.png",
|
"tile": "/nostrmarket/static/images/bitcoin-shop.png",
|
||||||
"min_lnbits_version": "1.4.0",
|
"contributors": [],
|
||||||
"contributors": [
|
"min_lnbits_version": "0.12.6"
|
||||||
{
|
|
||||||
"name": "Vlad Stan",
|
|
||||||
"uri": "https://github.com/motorina0",
|
|
||||||
"role": "Contributor"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "benarc",
|
|
||||||
"uri": "https://github.com/benarc",
|
|
||||||
"role": "Developer"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "talvasconcelos",
|
|
||||||
"uri": "https://github.com/talvasconcelos",
|
|
||||||
"role": "Developer"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "BenGWeeks",
|
|
||||||
"uri": "https://github.com/BenGWeeks",
|
|
||||||
"role": "Developer"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"images": [
|
|
||||||
{
|
|
||||||
"uri": "https://raw.githubusercontent.com/lnbits/nostrmarket/main/static/images/1.png"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"uri": "https://raw.githubusercontent.com/lnbits/nostrmarket/main/static/images/2.png"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"uri": "https://raw.githubusercontent.com/lnbits/nostrmarket/main/static/images/3.png"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"uri": "https://raw.githubusercontent.com/lnbits/nostrmarket/main/static/images/4.png"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"uri": "https://raw.githubusercontent.com/lnbits/nostrmarket/main/static/images/5.png"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"uri": "https://raw.githubusercontent.com/lnbits/nostrmarket/main/static/images/6.jpg",
|
|
||||||
"link": "https://www.youtube.com/embed/t9Z2tEsrNIU?si=rOQvwCUSWhwPPmYW"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description_md": "https://raw.githubusercontent.com/lnbits/nostrmarket/main/description.md",
|
|
||||||
"terms_and_conditions_md": "https://raw.githubusercontent.com/lnbits/nostrmarket/main/toc.md",
|
|
||||||
"license": "MIT",
|
|
||||||
"paid_features": "",
|
|
||||||
"tags": ["Nostr", "Marketplace"],
|
|
||||||
"donate": "",
|
|
||||||
"hidden": false
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
Buy and sell products over Nostr using the NIP-15 marketplace protocol.
|
|
||||||
|
|
||||||
Its functions include:
|
|
||||||
|
|
||||||
- Managing products, sales, and customer communication as a merchant
|
|
||||||
- Browsing and ordering products as a customer
|
|
||||||
- Tracking order status and delivery
|
|
||||||
- Communicating via NIP-17 private direct messages (NIP-44 encryption + NIP-59 gift wrapping)
|
|
||||||
|
|
||||||
A decentralized commerce solution for merchants and buyers who want to trade goods and services over Nostr with end-to-end encrypted communication.
|
|
||||||
79
helpers.py
|
|
@ -1,9 +1,78 @@
|
||||||
from bech32 import bech32_decode, convertbits
|
import base64
|
||||||
|
import json
|
||||||
|
import secrets
|
||||||
|
from typing import Any, Optional, Tuple
|
||||||
|
|
||||||
# NOTE (aiolabs/nostrmarket#5): `sign_message_hash` is gone. All merchant
|
import secp256k1
|
||||||
# signing routes through the lnbits `NostrSigner` ABC via
|
from bech32 import bech32_decode, convertbits
|
||||||
# `services._resolve_merchant_signer(merchant)`. The nsec lives in the
|
from cffi import FFI
|
||||||
# bunker, never in this process.
|
from cryptography.hazmat.primitives import padding
|
||||||
|
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||||
|
|
||||||
|
|
||||||
|
def get_shared_secret(privkey: str, pubkey: str):
|
||||||
|
point = secp256k1.PublicKey(bytes.fromhex("02" + pubkey), True)
|
||||||
|
return point.ecdh(bytes.fromhex(privkey), hashfn=copy_x)
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt_message(encoded_message: str, encryption_key) -> str:
|
||||||
|
encoded_data = encoded_message.split("?iv=")
|
||||||
|
if len(encoded_data) == 1:
|
||||||
|
return encoded_data[0]
|
||||||
|
encoded_content, encoded_iv = encoded_data[0], encoded_data[1]
|
||||||
|
|
||||||
|
iv = base64.b64decode(encoded_iv)
|
||||||
|
cipher = Cipher(algorithms.AES(encryption_key), modes.CBC(iv))
|
||||||
|
encrypted_content = base64.b64decode(encoded_content)
|
||||||
|
|
||||||
|
decryptor = cipher.decryptor()
|
||||||
|
decrypted_message = decryptor.update(encrypted_content) + decryptor.finalize()
|
||||||
|
|
||||||
|
unpadder = padding.PKCS7(128).unpadder()
|
||||||
|
unpadded_data = unpadder.update(decrypted_message) + unpadder.finalize()
|
||||||
|
|
||||||
|
return unpadded_data.decode()
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt_message(message: str, encryption_key, iv: Optional[bytes] = None) -> str:
|
||||||
|
padder = padding.PKCS7(128).padder()
|
||||||
|
padded_data = padder.update(message.encode()) + padder.finalize()
|
||||||
|
|
||||||
|
iv = iv if iv else secrets.token_bytes(16)
|
||||||
|
cipher = Cipher(algorithms.AES(encryption_key), modes.CBC(iv))
|
||||||
|
|
||||||
|
encryptor = cipher.encryptor()
|
||||||
|
encrypted_message = encryptor.update(padded_data) + encryptor.finalize()
|
||||||
|
|
||||||
|
return f"{base64.b64encode(encrypted_message).decode()}?iv={base64.b64encode(iv).decode()}"
|
||||||
|
|
||||||
|
|
||||||
|
def sign_message_hash(private_key: str, hash: bytes) -> str:
|
||||||
|
privkey = secp256k1.PrivateKey(bytes.fromhex(private_key))
|
||||||
|
sig = privkey.schnorr_sign(hash, None, raw=True)
|
||||||
|
return sig.hex()
|
||||||
|
|
||||||
|
|
||||||
|
def test_decrypt_encrypt(encoded_message: str, encryption_key):
|
||||||
|
msg = decrypt_message(encoded_message, encryption_key)
|
||||||
|
|
||||||
|
# ecrypt using the same initialisation vector
|
||||||
|
iv = base64.b64decode(encoded_message.split("?iv=")[1])
|
||||||
|
ecrypted_msg = encrypt_message(msg, encryption_key, iv)
|
||||||
|
assert (
|
||||||
|
encoded_message == ecrypted_msg
|
||||||
|
), f"expected '{encoded_message}', but got '{ecrypted_msg}'"
|
||||||
|
|
||||||
|
|
||||||
|
ffi = FFI()
|
||||||
|
|
||||||
|
|
||||||
|
@ffi.callback(
|
||||||
|
"int (unsigned char *, const unsigned char *, const unsigned char *, void *)"
|
||||||
|
)
|
||||||
|
def copy_x(output, x32, y32, data):
|
||||||
|
ffi.memmove(output, x32, 32)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
def normalize_public_key(pubkey: str) -> str:
|
def normalize_public_key(pubkey: str) -> str:
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
async def m001_initial(db):
|
async def m001_initial(db):
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Initial merchants table.
|
Initial merchants table.
|
||||||
"""
|
"""
|
||||||
|
|
@ -120,10 +121,7 @@ async def m001_initial(db):
|
||||||
Create indexes for message fetching
|
Create indexes for message fetching
|
||||||
"""
|
"""
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"""
|
"CREATE INDEX idx_messages_timestamp ON nostrmarket.direct_messages (time DESC)"
|
||||||
CREATE INDEX idx_messages_timestamp
|
|
||||||
ON nostrmarket.direct_messages (time DESC)
|
|
||||||
"""
|
|
||||||
)
|
)
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"CREATE INDEX idx_event_id ON nostrmarket.direct_messages (event_id)"
|
"CREATE INDEX idx_event_id ON nostrmarket.direct_messages (event_id)"
|
||||||
|
|
@ -144,26 +142,23 @@ async def m001_initial(db):
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def m002_update_stall_and_product(db):
|
async def m002_update_stall_and_product(db):
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"""
|
"ALTER TABLE nostrmarket.stalls ADD COLUMN pending BOOLEAN NOT NULL DEFAULT false;"
|
||||||
ALTER TABLE nostrmarket.stalls
|
)
|
||||||
ADD COLUMN pending BOOLEAN NOT NULL DEFAULT false;
|
await db.execute(
|
||||||
"""
|
"ALTER TABLE nostrmarket.stalls ADD COLUMN event_id TEXT;"
|
||||||
)
|
)
|
||||||
await db.execute("ALTER TABLE nostrmarket.stalls ADD COLUMN event_id TEXT;")
|
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"ALTER TABLE nostrmarket.stalls ADD COLUMN event_created_at INTEGER;"
|
"ALTER TABLE nostrmarket.stalls ADD COLUMN event_created_at INTEGER;"
|
||||||
)
|
)
|
||||||
|
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"""
|
"ALTER TABLE nostrmarket.products ADD COLUMN pending BOOLEAN NOT NULL DEFAULT false;"
|
||||||
ALTER TABLE nostrmarket.products
|
)
|
||||||
ADD COLUMN pending BOOLEAN NOT NULL DEFAULT false;
|
await db.execute(
|
||||||
"""
|
"ALTER TABLE nostrmarket.products ADD COLUMN event_id TEXT;"
|
||||||
)
|
)
|
||||||
await db.execute("ALTER TABLE nostrmarket.products ADD COLUMN event_id TEXT;")
|
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"ALTER TABLE nostrmarket.products ADD COLUMN event_created_at INTEGER;"
|
"ALTER TABLE nostrmarket.products ADD COLUMN event_created_at INTEGER;"
|
||||||
)
|
)
|
||||||
|
|
@ -171,21 +166,10 @@ async def m002_update_stall_and_product(db):
|
||||||
|
|
||||||
async def m003_update_direct_message_type(db):
|
async def m003_update_direct_message_type(db):
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"""
|
"ALTER TABLE nostrmarket.direct_messages ADD COLUMN type INTEGER NOT NULL DEFAULT -1;"
|
||||||
ALTER TABLE nostrmarket.direct_messages
|
|
||||||
ADD COLUMN type INTEGER NOT NULL DEFAULT -1;
|
|
||||||
"""
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def m004_add_merchant_timestamp(db):
|
async def m004_add_merchant_timestamp(db):
|
||||||
await db.execute("ALTER TABLE nostrmarket.merchants ADD COLUMN time TIMESTAMP;")
|
|
||||||
|
|
||||||
|
|
||||||
async def m005_update_product_activation(db):
|
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"""
|
f"ALTER TABLE nostrmarket.merchants ADD COLUMN time TIMESTAMP;"
|
||||||
ALTER TABLE nostrmarket.products
|
)
|
||||||
ADD COLUMN active BOOLEAN NOT NULL DEFAULT true;
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
"""
|
|
||||||
aiolabs fork-migrations for nostrmarket (companion to upstream
|
|
||||||
`migrations.py`).
|
|
||||||
|
|
||||||
Per the aiolabs/lnbits#8 fork-migrations pattern: every aio-only
|
|
||||||
schema delta lives in this single squashed function so we never
|
|
||||||
introduce conflicts in `migrations.py` (which stays byte-identical to
|
|
||||||
upstream and rebases cleanly).
|
|
||||||
|
|
||||||
The function is loaded by lnbits's patched `migrate_extension_database()`
|
|
||||||
under the `nostrmarket_fork` namespace in core `dbversions`, with the
|
|
||||||
following invariants:
|
|
||||||
- Every ALTER must be idempotent (use `_alter_drop_column_safe`-style
|
|
||||||
wrappers or `IF NOT EXISTS` / `IF EXISTS` predicates) so re-runs
|
|
||||||
are no-ops on already-migrated installs.
|
|
||||||
- Schema changes here MUST NOT depend on the version of upstream's
|
|
||||||
`migrations.py` they're running against — upstream rebases must
|
|
||||||
not require this file to be edited.
|
|
||||||
|
|
||||||
See `aiolabs/lnbits#8` for the pattern + `aiolabs/lnbits/core/services/
|
|
||||||
signer_migration.py` for the prior art on `_alter_*_safe` helpers.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from loguru import logger
|
|
||||||
|
|
||||||
|
|
||||||
async def _drop_column_safe(db, table: str, column: str) -> None:
|
|
||||||
"""SQLite-safe drop-column. Newer SQLite (3.35+) supports
|
|
||||||
`ALTER TABLE … DROP COLUMN`; older versions need the classic
|
|
||||||
create-new-table + copy + swap dance. Postgres handles
|
|
||||||
`ALTER TABLE … DROP COLUMN IF EXISTS` natively.
|
|
||||||
|
|
||||||
Idempotent: catches "no such column" + "column does not exist"
|
|
||||||
so re-runs are no-ops.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Postgres path (supports IF EXISTS natively); also works on
|
|
||||||
# SQLite ≥ 3.35.
|
|
||||||
await db.execute(f"ALTER TABLE {table} DROP COLUMN IF EXISTS {column};")
|
|
||||||
return
|
|
||||||
except Exception as exc:
|
|
||||||
# SQLite < 3.35 doesn't support IF EXISTS; fall through to the
|
|
||||||
# bare DROP COLUMN attempt + swallow the not-found case.
|
|
||||||
msg = str(exc).lower()
|
|
||||||
if "syntax" not in msg and "if exists" not in msg:
|
|
||||||
# Something other than the IF-EXISTS unsupported case; surface.
|
|
||||||
raise
|
|
||||||
|
|
||||||
try:
|
|
||||||
await db.execute(f"ALTER TABLE {table} DROP COLUMN {column};")
|
|
||||||
except Exception as exc:
|
|
||||||
msg = str(exc).lower()
|
|
||||||
if "no such column" in msg or "does not exist" in msg:
|
|
||||||
# Already dropped; idempotent skip.
|
|
||||||
return
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
async def m001_aio_drop_merchant_private_key(db):
|
|
||||||
"""Drop the legacy `nostrmarket.merchants.private_key` column.
|
|
||||||
|
|
||||||
Per aiolabs/nostrmarket#5, the merchant's signing identity is owned
|
|
||||||
by the lnbits-side account: signing routes through
|
|
||||||
`resolve_signer(account).sign_event(...)` (which dispatches to
|
|
||||||
`RemoteBunkerSigner` post-aiolabs/lnbits#18 phase 2.x). The nsec
|
|
||||||
never lives in this extension's storage. Dropping the column makes
|
|
||||||
that contract enforced at the schema level rather than relying on
|
|
||||||
"nobody writes to it anymore."
|
|
||||||
|
|
||||||
Idempotent: re-runs no-op via `_drop_column_safe`.
|
|
||||||
"""
|
|
||||||
logger.info(
|
|
||||||
"[NOSTRMARKET fork] m001: dropping merchants.private_key "
|
|
||||||
"(aiolabs/nostrmarket#5 — signing routes through lnbits NostrSigner)"
|
|
||||||
)
|
|
||||||
await _drop_column_safe(db, "nostrmarket.merchants", "private_key")
|
|
||||||
logger.info("[NOSTRMARKET fork] m001: done")
|
|
||||||
|
|
@ -1,320 +0,0 @@
|
||||||
# Nostrmarket Order Discovery Analysis
|
|
||||||
|
|
||||||
## Executive Summary
|
|
||||||
|
|
||||||
This document analyzes the order discovery mechanism in the Nostrmarket extension and identifies why merchants must manually refresh to see new orders instead of receiving them automatically through persistent subscriptions.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Current Architecture
|
|
||||||
|
|
||||||
### Two Subscription Systems
|
|
||||||
|
|
||||||
The Nostrmarket extension implements two distinct subscription mechanisms for receiving Nostr events:
|
|
||||||
|
|
||||||
#### 1. **Persistent Subscriptions (Background Task)**
|
|
||||||
|
|
||||||
**Purpose**: Continuous monitoring for new orders, products, and merchant events
|
|
||||||
|
|
||||||
**Implementation**:
|
|
||||||
|
|
||||||
- Runs via `wait_for_nostr_events()` background task
|
|
||||||
- Initiated on extension startup (15-second delay)
|
|
||||||
- Creates subscription ID: `nostrmarket-{hash}`
|
|
||||||
- Monitors all merchant public keys continuously
|
|
||||||
|
|
||||||
**Code Location**: `/nostrmarket/tasks.py:37-49`
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def wait_for_nostr_events(nostr_client: NostrClient):
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
await subscribe_to_all_merchants()
|
|
||||||
while True:
|
|
||||||
message = await nostr_client.get_event()
|
|
||||||
await process_nostr_message(message)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Subscription Filters**:
|
|
||||||
|
|
||||||
- Direct messages (kind 4) - for orders
|
|
||||||
- Stall events (kind 30017)
|
|
||||||
- Product events (kind 30018)
|
|
||||||
- Profile updates (kind 0)
|
|
||||||
|
|
||||||
#### 2. **Temporary Subscriptions (Manual Refresh)**
|
|
||||||
|
|
||||||
**Purpose**: Catch up on missed events when merchant clicks "Refresh from Nostr"
|
|
||||||
|
|
||||||
**Implementation**:
|
|
||||||
|
|
||||||
- Duration: 10 seconds only
|
|
||||||
- Triggered by user action
|
|
||||||
- Creates subscription ID: `merchant-{hash}`
|
|
||||||
- Fetches ALL events from time=0
|
|
||||||
|
|
||||||
**Code Location**: `/nostrmarket/nostr/nostr_client.py:100-120`
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def merchant_temp_subscription(self, pk, duration=10):
|
|
||||||
dm_filters = self._filters_for_direct_messages([pk], 0)
|
|
||||||
# ... creates filters with time=0 (all history)
|
|
||||||
await self.send_req_queue.put(["REQ", subscription_id] + merchant_filters)
|
|
||||||
asyncio.create_task(unsubscribe_with_delay(subscription_id, duration))
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Problem Identification
|
|
||||||
|
|
||||||
### Why Manual Refresh is Required
|
|
||||||
|
|
||||||
#### **Issue 1: Timing Window Problem**
|
|
||||||
|
|
||||||
The persistent subscription uses timestamps from the last database update:
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def subscribe_to_all_merchants():
|
|
||||||
last_dm_time = await get_last_direct_messages_created_at()
|
|
||||||
last_stall_time = await get_last_stall_update_time()
|
|
||||||
last_prod_time = await get_last_product_update_time()
|
|
||||||
|
|
||||||
await nostr_client.subscribe_merchants(
|
|
||||||
public_keys, last_dm_time, last_stall_time, last_prod_time, 0
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Problem**: Events that occur between:
|
|
||||||
|
|
||||||
- The last database update time
|
|
||||||
- When the subscription becomes active
|
|
||||||
...are potentially missed
|
|
||||||
|
|
||||||
#### **Issue 2: Connection Stability**
|
|
||||||
|
|
||||||
The WebSocket connection between components may be unstable:
|
|
||||||
|
|
||||||
```
|
|
||||||
[Nostrmarket] <--WebSocket--> [Nostrclient] <--WebSocket--> [Nostr Relays]
|
|
||||||
Extension Extension (Global)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Potential failure points**:
|
|
||||||
|
|
||||||
1. Connection drops between nostrmarket → nostrclient
|
|
||||||
2. Connection drops between nostrclient → relays
|
|
||||||
3. Reconnection doesn't re-establish subscriptions
|
|
||||||
|
|
||||||
#### **Issue 3: Subscription State Management**
|
|
||||||
|
|
||||||
**Current behavior**:
|
|
||||||
|
|
||||||
- Single persistent subscription per merchant
|
|
||||||
- No automatic resubscription on failure
|
|
||||||
- No heartbeat/keepalive mechanism
|
|
||||||
- No verification that subscription is active
|
|
||||||
|
|
||||||
#### **Issue 4: Event Processing Delays**
|
|
||||||
|
|
||||||
The startup sequence has intentional delays:
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def _subscribe_to_nostr_client():
|
|
||||||
await asyncio.sleep(10) # Wait for nostrclient
|
|
||||||
await nostr_client.run_forever()
|
|
||||||
|
|
||||||
async def _wait_for_nostr_events():
|
|
||||||
await asyncio.sleep(15) # Wait for extension init
|
|
||||||
await wait_for_nostr_events(nostr_client)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Problem**: Orders arriving during initialization are missed
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Why Manual Refresh Works
|
|
||||||
|
|
||||||
The temporary subscription succeeds because:
|
|
||||||
|
|
||||||
1. **Fetches from time=0**: Gets ALL historical events
|
|
||||||
2. **Fresh connection**: Creates new subscription request
|
|
||||||
3. **Immediate processing**: No startup delays
|
|
||||||
4. **Direct feedback**: User sees results immediately
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Temporary subscription uses time=0 (all events)
|
|
||||||
dm_filters = self._filters_for_direct_messages([pk], 0) # ← 0 means all time
|
|
||||||
|
|
||||||
# Persistent subscription uses last update time
|
|
||||||
dm_filters = self._filters_for_direct_messages(public_keys, dm_time) # ← can miss events
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Impact Analysis
|
|
||||||
|
|
||||||
### User Experience Issues
|
|
||||||
|
|
||||||
1. **Merchants miss orders** without manual refresh
|
|
||||||
2. **No real-time notifications** for new orders
|
|
||||||
3. **Uncertainty** about order status
|
|
||||||
4. **Extra manual steps** required
|
|
||||||
5. **Delayed order fulfillment**
|
|
||||||
|
|
||||||
### Technical Implications
|
|
||||||
|
|
||||||
1. **Not truly decentralized** - requires active monitoring
|
|
||||||
2. **Scalability concerns** - manual refresh doesn't scale
|
|
||||||
3. **Reliability issues** - depends on user action
|
|
||||||
4. **Performance overhead** - fetching all events repeatedly
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Recommended Solutions
|
|
||||||
|
|
||||||
### Solution A: Enhanced Persistent Subscriptions
|
|
||||||
|
|
||||||
**Implement redundant subscription mechanisms:**
|
|
||||||
|
|
||||||
```python
|
|
||||||
class EnhancedSubscriptionManager:
|
|
||||||
def __init__(self):
|
|
||||||
self.last_heartbeat = time.time()
|
|
||||||
self.subscription_active = False
|
|
||||||
|
|
||||||
async def maintain_subscription(self):
|
|
||||||
while True:
|
|
||||||
if not self.subscription_active or \
|
|
||||||
time.time() - self.last_heartbeat > 30:
|
|
||||||
await self.resubscribe_with_overlap()
|
|
||||||
await asyncio.sleep(10)
|
|
||||||
|
|
||||||
async def resubscribe_with_overlap(self):
|
|
||||||
# Use timestamp with 5-minute overlap
|
|
||||||
overlap_time = int(time.time()) - 300
|
|
||||||
await subscribe_to_all_merchants(since=overlap_time)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Solution B: Periodic Auto-Refresh
|
|
||||||
|
|
||||||
**Add automatic temporary subscriptions:**
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def auto_refresh_loop():
|
|
||||||
while True:
|
|
||||||
await asyncio.sleep(60) # Every minute
|
|
||||||
merchants = await get_all_active_merchants()
|
|
||||||
for merchant in merchants:
|
|
||||||
await merchant_temp_subscription(merchant.pubkey, duration=5)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Solution C: WebSocket Health Monitoring
|
|
||||||
|
|
||||||
**Implement connection health checks:**
|
|
||||||
|
|
||||||
```python
|
|
||||||
class WebSocketHealthMonitor:
|
|
||||||
async def check_connection_health(self):
|
|
||||||
try:
|
|
||||||
# Send ping to nostrclient
|
|
||||||
response = await nostr_client.ping(timeout=5)
|
|
||||||
if not response:
|
|
||||||
await self.reconnect_and_resubscribe()
|
|
||||||
except Exception:
|
|
||||||
await self.reconnect_and_resubscribe()
|
|
||||||
```
|
|
||||||
|
|
||||||
### Solution D: Event Gap Detection
|
|
||||||
|
|
||||||
**Detect and fill gaps in event sequence:**
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def detect_event_gaps():
|
|
||||||
# Check for gaps in event timestamps
|
|
||||||
last_known = await get_last_event_time()
|
|
||||||
current_time = int(time.time())
|
|
||||||
|
|
||||||
if current_time - last_known > 60: # 1 minute gap
|
|
||||||
# Perform temporary subscription to fill gap
|
|
||||||
await fetch_missing_events(since=last_known)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Implementation Priority
|
|
||||||
|
|
||||||
### Phase 1: Quick Fixes (1-2 days)
|
|
||||||
|
|
||||||
1. [DONE] Increase temp subscription duration (10s → 30s)
|
|
||||||
2. [DONE] Add connection health logging
|
|
||||||
3. [DONE] Reduce startup delays
|
|
||||||
|
|
||||||
### Phase 2: Reliability (3-5 days)
|
|
||||||
|
|
||||||
1. [TODO] Implement subscription heartbeat
|
|
||||||
2. [TODO] Add automatic resubscription on failure
|
|
||||||
3. [TODO] Create event gap detection
|
|
||||||
|
|
||||||
### Phase 3: Full Solution (1-2 weeks)
|
|
||||||
|
|
||||||
1. [TODO] WebSocket connection monitoring
|
|
||||||
2. [TODO] Redundant subscription system
|
|
||||||
3. [TODO] Real-time order notifications
|
|
||||||
4. [TODO] Event deduplication logic
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Testing Recommendations
|
|
||||||
|
|
||||||
### Test Scenarios
|
|
||||||
|
|
||||||
1. **Order during startup**: Send order within 15 seconds of server start
|
|
||||||
2. **Long-running test**: Keep server running for 24 hours, send periodic orders
|
|
||||||
3. **Connection interruption**: Disconnect nostrclient, send order, reconnect
|
|
||||||
4. **High volume**: Send 100 orders rapidly
|
|
||||||
5. **Network latency**: Add artificial delay between components
|
|
||||||
|
|
||||||
### Monitoring Metrics
|
|
||||||
|
|
||||||
- Time between order sent → order discovered
|
|
||||||
- Percentage of orders requiring manual refresh
|
|
||||||
- WebSocket connection uptime
|
|
||||||
- Subscription success rate
|
|
||||||
- Event processing latency
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Conclusion
|
|
||||||
|
|
||||||
The current order discovery system relies on manual refresh due to:
|
|
||||||
|
|
||||||
1. **Timing gaps** in persistent subscriptions
|
|
||||||
2. **Connection stability** issues
|
|
||||||
3. **Lack of redundancy** in subscription management
|
|
||||||
4. **No automatic recovery** mechanisms
|
|
||||||
|
|
||||||
While the temporary subscription (manual refresh) provides a workaround, a proper solution requires implementing connection monitoring, subscription health checks, and automatic gap-filling mechanisms to ensure reliable real-time order discovery.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Appendix: Code References
|
|
||||||
|
|
||||||
### Key Files
|
|
||||||
|
|
||||||
- `/nostrmarket/tasks.py` - Background task management
|
|
||||||
- `/nostrmarket/nostr/nostr_client.py` - Nostr client implementation
|
|
||||||
- `/nostrmarket/services.py` - Order processing logic
|
|
||||||
- `/nostrmarket/views_api.py` - API endpoints for refresh
|
|
||||||
|
|
||||||
### Relevant Functions
|
|
||||||
|
|
||||||
- `wait_for_nostr_events()` - Main event loop
|
|
||||||
- `subscribe_to_all_merchants()` - Persistent subscription
|
|
||||||
- `merchant_temp_subscription()` - Manual refresh
|
|
||||||
- `process_nostr_message()` - Event processing
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
_Document prepared: January 2025_
|
|
||||||
_Analysis based on: Nostrmarket v1.0_
|
|
||||||
_Status: Active Investigation_
|
|
||||||
291
models.py
|
|
@ -2,11 +2,19 @@ import json
|
||||||
import time
|
import time
|
||||||
from abc import abstractmethod
|
from abc import abstractmethod
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
from sqlite3 import Row
|
||||||
from typing import Any, List, Optional, Tuple
|
from typing import Any, List, Optional, Tuple
|
||||||
|
|
||||||
from lnbits.utils.exchange_rates import btc_price, fiat_amount_as_satoshis
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from lnbits.utils.exchange_rates import btc_price, fiat_amount_as_satoshis
|
||||||
|
|
||||||
|
from .helpers import (
|
||||||
|
decrypt_message,
|
||||||
|
encrypt_message,
|
||||||
|
get_shared_secret,
|
||||||
|
sign_message_hash,
|
||||||
|
)
|
||||||
from .nostr.event import NostrEvent
|
from .nostr.event import NostrEvent
|
||||||
|
|
||||||
######################################## NOSTR ########################################
|
######################################## NOSTR ########################################
|
||||||
|
|
@ -22,76 +30,69 @@ class Nostrable:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
######################################## MERCHANT ######################################
|
######################################## MERCHANT ########################################
|
||||||
|
|
||||||
|
|
||||||
class MerchantProfile(BaseModel):
|
class MerchantProfile(BaseModel):
|
||||||
name: Optional[str] = None
|
name: Optional[str]
|
||||||
display_name: Optional[str] = None
|
about: Optional[str]
|
||||||
about: Optional[str] = None
|
picture: Optional[str]
|
||||||
picture: Optional[str] = None
|
|
||||||
banner: Optional[str] = None
|
|
||||||
website: Optional[str] = None
|
|
||||||
nip05: Optional[str] = None
|
|
||||||
lud16: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class MerchantConfig(MerchantProfile):
|
class MerchantConfig(MerchantProfile):
|
||||||
event_id: Optional[str] = None
|
event_id: Optional[str]
|
||||||
sync_from_nostr: bool = False
|
sync_from_nostr = False
|
||||||
# TODO: switched to True for AIO demo; determine if we leave this as True
|
active: bool = False
|
||||||
active: bool = True
|
|
||||||
restore_in_progress: Optional[bool] = False
|
restore_in_progress: Optional[bool] = False
|
||||||
# Set at runtime (not persisted) when account keypair != merchant keypair
|
|
||||||
key_mismatch: Optional[bool] = False
|
|
||||||
|
|
||||||
|
|
||||||
class CreateMerchantRequest(BaseModel):
|
|
||||||
config: MerchantConfig = MerchantConfig()
|
|
||||||
|
|
||||||
|
|
||||||
class PartialMerchant(BaseModel):
|
class PartialMerchant(BaseModel):
|
||||||
|
private_key: str
|
||||||
public_key: str
|
public_key: str
|
||||||
config: MerchantConfig = MerchantConfig()
|
config: MerchantConfig = MerchantConfig()
|
||||||
|
|
||||||
|
|
||||||
class Merchant(PartialMerchant, Nostrable):
|
class Merchant(PartialMerchant, Nostrable):
|
||||||
id: str
|
id: str
|
||||||
user_id: str
|
|
||||||
time: Optional[int] = 0
|
time: Optional[int] = 0
|
||||||
|
|
||||||
# NOTE (aiolabs/nostrmarket#5): no `sign_hash` / `encrypt_message` /
|
def sign_hash(self, hash: bytes) -> str:
|
||||||
# `decrypt_message` methods on Merchant anymore. Signing + NIP-44 crypto
|
return sign_message_hash(self.private_key, hash)
|
||||||
# for a merchant goes through the lnbits `NostrSigner` abstraction
|
|
||||||
# (`resolve_signer(account)`); merchant is now pure metadata pointing
|
def decrypt_message(self, encrypted_message: str, public_key: str) -> str:
|
||||||
# at its owning account via `user_id`. The bunker (`RemoteBunkerSigner`)
|
encryption_key = get_shared_secret(self.private_key, public_key)
|
||||||
# holds the merchant's nsec — lnbits never has it server-side.
|
return decrypt_message(encrypted_message, encryption_key)
|
||||||
# See `services._resolve_merchant_signer()` for the resolution helper.
|
|
||||||
|
def encrypt_message(self, clear_text_message: str, public_key: str) -> str:
|
||||||
|
encryption_key = get_shared_secret(self.private_key, public_key)
|
||||||
|
return encrypt_message(clear_text_message, encryption_key)
|
||||||
|
|
||||||
|
def build_dm_event(self, message: str, to_pubkey: str) -> NostrEvent:
|
||||||
|
content = self.encrypt_message(message, to_pubkey)
|
||||||
|
event = NostrEvent(
|
||||||
|
pubkey=self.public_key,
|
||||||
|
created_at=round(time.time()),
|
||||||
|
kind=4,
|
||||||
|
tags=[["p", to_pubkey]],
|
||||||
|
content=content,
|
||||||
|
)
|
||||||
|
event.id = event.event_id
|
||||||
|
event.sig = self.sign_hash(bytes.fromhex(event.id))
|
||||||
|
|
||||||
|
return event
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_row(cls, row: dict) -> "Merchant":
|
def from_row(cls, row: Row) -> "Merchant":
|
||||||
merchant = cls(**row)
|
merchant = cls(**dict(row))
|
||||||
merchant.config = MerchantConfig(**json.loads(row["meta"]))
|
merchant.config = MerchantConfig(**json.loads(row["meta"]))
|
||||||
return merchant
|
return merchant
|
||||||
|
|
||||||
def to_nostr_event(self, pubkey: str) -> NostrEvent:
|
def to_nostr_event(self, pubkey: str) -> NostrEvent:
|
||||||
content: dict[str, str] = {}
|
content = {
|
||||||
if self.config.name:
|
"name": self.config.name,
|
||||||
content["name"] = self.config.name
|
"about": self.config.about,
|
||||||
if self.config.display_name:
|
"picture": self.config.picture,
|
||||||
content["display_name"] = self.config.display_name
|
}
|
||||||
if self.config.about:
|
|
||||||
content["about"] = self.config.about
|
|
||||||
if self.config.picture:
|
|
||||||
content["picture"] = self.config.picture
|
|
||||||
if self.config.banner:
|
|
||||||
content["banner"] = self.config.banner
|
|
||||||
if self.config.website:
|
|
||||||
content["website"] = self.config.website
|
|
||||||
if self.config.nip05:
|
|
||||||
content["nip05"] = self.config.nip05
|
|
||||||
if self.config.lud16:
|
|
||||||
content["lud16"] = self.config.lud16
|
|
||||||
event = NostrEvent(
|
event = NostrEvent(
|
||||||
pubkey=pubkey,
|
pubkey=pubkey,
|
||||||
created_at=round(time.time()),
|
created_at=round(time.time()),
|
||||||
|
|
@ -122,16 +123,20 @@ class Merchant(PartialMerchant, Nostrable):
|
||||||
|
|
||||||
|
|
||||||
######################################## ZONES ########################################
|
######################################## ZONES ########################################
|
||||||
class Zone(BaseModel):
|
class PartialZone(BaseModel):
|
||||||
id: Optional[str] = None
|
id: Optional[str]
|
||||||
name: Optional[str] = None
|
name: Optional[str]
|
||||||
currency: str
|
currency: str
|
||||||
cost: float
|
cost: float
|
||||||
countries: List[str] = []
|
countries: List[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
class Zone(PartialZone):
|
||||||
|
id: str
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_row(cls, row: dict) -> "Zone":
|
def from_row(cls, row: Row) -> "Zone":
|
||||||
zone = cls(**row)
|
zone = cls(**dict(row))
|
||||||
zone.countries = json.loads(row["regions"])
|
zone.countries = json.loads(row["regions"])
|
||||||
return zone
|
return zone
|
||||||
|
|
||||||
|
|
@ -140,12 +145,12 @@ class Zone(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class StallConfig(BaseModel):
|
class StallConfig(BaseModel):
|
||||||
image_url: Optional[str] = None
|
image_url: Optional[str]
|
||||||
description: Optional[str] = None
|
description: Optional[str]
|
||||||
|
|
||||||
|
|
||||||
class Stall(BaseModel, Nostrable):
|
class PartialStall(BaseModel):
|
||||||
id: Optional[str] = None
|
id: Optional[str]
|
||||||
wallet: str
|
wallet: str
|
||||||
name: str
|
name: str
|
||||||
currency: str = "sat"
|
currency: str = "sat"
|
||||||
|
|
@ -154,8 +159,8 @@ class Stall(BaseModel, Nostrable):
|
||||||
pending: bool = False
|
pending: bool = False
|
||||||
|
|
||||||
"""Last published nostr event for this Stall"""
|
"""Last published nostr event for this Stall"""
|
||||||
event_id: Optional[str] = None
|
event_id: Optional[str]
|
||||||
event_created_at: Optional[int] = None
|
event_created_at: Optional[int]
|
||||||
|
|
||||||
def validate_stall(self):
|
def validate_stall(self):
|
||||||
for z in self.shipping_zones:
|
for z in self.shipping_zones:
|
||||||
|
|
@ -164,6 +169,10 @@ class Stall(BaseModel, Nostrable):
|
||||||
f"Sipping zone '{z.name}' has different currency than stall."
|
f"Sipping zone '{z.name}' has different currency than stall."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Stall(PartialStall, Nostrable):
|
||||||
|
id: str
|
||||||
|
|
||||||
def to_nostr_event(self, pubkey: str) -> NostrEvent:
|
def to_nostr_event(self, pubkey: str) -> NostrEvent:
|
||||||
content = {
|
content = {
|
||||||
"id": self.id,
|
"id": self.id,
|
||||||
|
|
@ -172,7 +181,6 @@ class Stall(BaseModel, Nostrable):
|
||||||
"currency": self.currency,
|
"currency": self.currency,
|
||||||
"shipping": [dict(z) for z in self.shipping_zones],
|
"shipping": [dict(z) for z in self.shipping_zones],
|
||||||
}
|
}
|
||||||
assert self.id
|
|
||||||
event = NostrEvent(
|
event = NostrEvent(
|
||||||
pubkey=pubkey,
|
pubkey=pubkey,
|
||||||
created_at=round(time.time()),
|
created_at=round(time.time()),
|
||||||
|
|
@ -189,7 +197,7 @@ class Stall(BaseModel, Nostrable):
|
||||||
pubkey=pubkey,
|
pubkey=pubkey,
|
||||||
created_at=round(time.time()),
|
created_at=round(time.time()),
|
||||||
kind=5,
|
kind=5,
|
||||||
tags=[["e", self.event_id or ""]],
|
tags=[["e", self.event_id]],
|
||||||
content=f"Stall '{self.name}' deleted",
|
content=f"Stall '{self.name}' deleted",
|
||||||
)
|
)
|
||||||
delete_event.id = delete_event.event_id
|
delete_event.id = delete_event.event_id
|
||||||
|
|
@ -197,14 +205,14 @@ class Stall(BaseModel, Nostrable):
|
||||||
return delete_event
|
return delete_event
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_row(cls, row: dict) -> "Stall":
|
def from_row(cls, row: Row) -> "Stall":
|
||||||
stall = cls(**row)
|
stall = cls(**dict(row))
|
||||||
stall.config = StallConfig(**json.loads(row["meta"]))
|
stall.config = StallConfig(**json.loads(row["meta"]))
|
||||||
stall.shipping_zones = [Zone(**z) for z in json.loads(row["zones"])]
|
stall.shipping_zones = [Zone(**z) for z in json.loads(row["zones"])]
|
||||||
return stall
|
return stall
|
||||||
|
|
||||||
|
|
||||||
######################################## PRODUCTS ######################################
|
######################################## PRODUCTS ########################################
|
||||||
|
|
||||||
|
|
||||||
class ProductShippingCost(BaseModel):
|
class ProductShippingCost(BaseModel):
|
||||||
|
|
@ -213,28 +221,31 @@ class ProductShippingCost(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class ProductConfig(BaseModel):
|
class ProductConfig(BaseModel):
|
||||||
description: Optional[str] = None
|
description: Optional[str]
|
||||||
currency: Optional[str] = None
|
currency: Optional[str]
|
||||||
use_autoreply: Optional[bool] = False
|
use_autoreply: Optional[bool] = False
|
||||||
autoreply_message: Optional[str] = None
|
autoreply_message: Optional[str]
|
||||||
shipping: List[ProductShippingCost] = []
|
shipping: Optional[List[ProductShippingCost]] = []
|
||||||
|
|
||||||
|
|
||||||
class Product(BaseModel, Nostrable):
|
class PartialProduct(BaseModel):
|
||||||
id: Optional[str] = None
|
id: Optional[str]
|
||||||
stall_id: str
|
stall_id: str
|
||||||
name: str
|
name: str
|
||||||
categories: List[str] = []
|
categories: List[str] = []
|
||||||
images: List[str] = []
|
images: List[str] = []
|
||||||
price: float
|
price: float
|
||||||
quantity: int
|
quantity: int
|
||||||
active: bool = True
|
|
||||||
pending: bool = False
|
pending: bool = False
|
||||||
config: ProductConfig = ProductConfig()
|
config: ProductConfig = ProductConfig()
|
||||||
|
|
||||||
"""Last published nostr event for this Product"""
|
"""Last published nostr event for this Product"""
|
||||||
event_id: Optional[str] = None
|
event_id: Optional[str]
|
||||||
event_created_at: Optional[int] = None
|
event_created_at: Optional[int]
|
||||||
|
|
||||||
|
|
||||||
|
class Product(PartialProduct, Nostrable):
|
||||||
|
id: str
|
||||||
|
|
||||||
def to_nostr_event(self, pubkey: str) -> NostrEvent:
|
def to_nostr_event(self, pubkey: str) -> NostrEvent:
|
||||||
content = {
|
content = {
|
||||||
|
|
@ -246,32 +257,27 @@ class Product(BaseModel, Nostrable):
|
||||||
"currency": self.config.currency,
|
"currency": self.config.currency,
|
||||||
"price": self.price,
|
"price": self.price,
|
||||||
"quantity": self.quantity,
|
"quantity": self.quantity,
|
||||||
"active": self.active,
|
"shipping": [dict(s) for s in self.config.shipping or []]
|
||||||
"shipping": [dict(s) for s in self.config.shipping or []],
|
|
||||||
}
|
}
|
||||||
categories = [["t", tag] for tag in self.categories]
|
categories = [["t", tag] for tag in self.categories]
|
||||||
|
|
||||||
assert self.id
|
event = NostrEvent(
|
||||||
if self.active:
|
pubkey=pubkey,
|
||||||
event = NostrEvent(
|
created_at=round(time.time()),
|
||||||
pubkey=pubkey,
|
kind=30018,
|
||||||
created_at=round(time.time()),
|
tags=[["d", self.id]] + categories,
|
||||||
kind=30018,
|
content=json.dumps(content, separators=(",", ":"), ensure_ascii=False),
|
||||||
tags=[["d", self.id], *categories],
|
)
|
||||||
content=json.dumps(content, separators=(",", ":"), ensure_ascii=False),
|
event.id = event.event_id
|
||||||
)
|
|
||||||
event.id = event.event_id
|
|
||||||
|
|
||||||
return event
|
return event
|
||||||
else:
|
|
||||||
return self.to_nostr_delete_event(pubkey)
|
|
||||||
|
|
||||||
def to_nostr_delete_event(self, pubkey: str) -> NostrEvent:
|
def to_nostr_delete_event(self, pubkey: str) -> NostrEvent:
|
||||||
delete_event = NostrEvent(
|
delete_event = NostrEvent(
|
||||||
pubkey=pubkey,
|
pubkey=pubkey,
|
||||||
created_at=round(time.time()),
|
created_at=round(time.time()),
|
||||||
kind=5,
|
kind=5,
|
||||||
tags=[["e", self.event_id or ""]],
|
tags=[["e", self.event_id]],
|
||||||
content=f"Product '{self.name}' deleted",
|
content=f"Product '{self.name}' deleted",
|
||||||
)
|
)
|
||||||
delete_event.id = delete_event.event_id
|
delete_event.id = delete_event.event_id
|
||||||
|
|
@ -279,8 +285,8 @@ class Product(BaseModel, Nostrable):
|
||||||
return delete_event
|
return delete_event
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_row(cls, row: dict) -> "Product":
|
def from_row(cls, row: Row) -> "Product":
|
||||||
product = cls(**row)
|
product = cls(**dict(row))
|
||||||
product.config = ProductConfig(**json.loads(row["meta"]))
|
product.config = ProductConfig(**json.loads(row["meta"]))
|
||||||
product.images = json.loads(row["image_urls"]) if "image_urls" in row else []
|
product.images = json.loads(row["image_urls"]) if "image_urls" in row else []
|
||||||
product.categories = json.loads(row["category_list"])
|
product.categories = json.loads(row["category_list"])
|
||||||
|
|
@ -291,12 +297,6 @@ class ProductOverview(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
name: str
|
name: str
|
||||||
price: float
|
price: float
|
||||||
product_shipping_cost: Optional[float] = None
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_product(cls, p: Product) -> "ProductOverview":
|
|
||||||
assert p.id
|
|
||||||
return ProductOverview(id=p.id, name=p.name, price=p.price)
|
|
||||||
|
|
||||||
|
|
||||||
######################################## ORDERS ########################################
|
######################################## ORDERS ########################################
|
||||||
|
|
@ -308,9 +308,9 @@ class OrderItem(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class OrderContact(BaseModel):
|
class OrderContact(BaseModel):
|
||||||
nostr: Optional[str] = None
|
nostr: Optional[str]
|
||||||
phone: Optional[str] = None
|
phone: Optional[str]
|
||||||
email: Optional[str] = None
|
email: Optional[str]
|
||||||
|
|
||||||
|
|
||||||
class OrderExtra(BaseModel):
|
class OrderExtra(BaseModel):
|
||||||
|
|
@ -319,33 +319,27 @@ class OrderExtra(BaseModel):
|
||||||
btc_price: str
|
btc_price: str
|
||||||
shipping_cost: float = 0
|
shipping_cost: float = 0
|
||||||
shipping_cost_sat: float = 0
|
shipping_cost_sat: float = 0
|
||||||
fail_message: Optional[str] = None
|
fail_message: Optional[str]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def from_products(cls, products: List[Product]):
|
async def from_products(cls, products: List[Product]):
|
||||||
currency = products[0].config.currency if len(products) else "sat"
|
currency = products[0].config.currency if len(products) else "sat"
|
||||||
exchange_rate = (
|
exchange_rate = (
|
||||||
await btc_price(currency) if currency and currency != "sat" else 1
|
(await btc_price(currency)) if currency and currency != "sat" else 1
|
||||||
)
|
|
||||||
|
|
||||||
products_overview = [ProductOverview.from_product(p) for p in products]
|
|
||||||
return OrderExtra(
|
|
||||||
products=products_overview,
|
|
||||||
currency=currency or "sat",
|
|
||||||
btc_price=str(exchange_rate),
|
|
||||||
)
|
)
|
||||||
|
return OrderExtra(products=products, currency=currency, btc_price=exchange_rate)
|
||||||
|
|
||||||
|
|
||||||
class PartialOrder(BaseModel):
|
class PartialOrder(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
event_id: Optional[str] = None
|
event_id: Optional[str]
|
||||||
event_created_at: Optional[int] = None
|
event_created_at: Optional[int]
|
||||||
public_key: str
|
public_key: str
|
||||||
merchant_public_key: str
|
merchant_public_key: str
|
||||||
shipping_id: str
|
shipping_id: str
|
||||||
items: List[OrderItem]
|
items: List[OrderItem]
|
||||||
contact: Optional[OrderContact] = None
|
contact: Optional[OrderContact]
|
||||||
address: Optional[str] = None
|
address: Optional[str]
|
||||||
|
|
||||||
def validate_order(self):
|
def validate_order(self):
|
||||||
assert len(self.items) != 0, f"Order has no items. Order: '{self.id}'"
|
assert len(self.items) != 0, f"Order has no items. Order: '{self.id}'"
|
||||||
|
|
@ -384,11 +378,10 @@ class PartialOrder(BaseModel):
|
||||||
}
|
}
|
||||||
|
|
||||||
product_cost: float = 0 # todo
|
product_cost: float = 0 # todo
|
||||||
currency = "sat"
|
|
||||||
for item in self.items:
|
for item in self.items:
|
||||||
assert item.quantity > 0, "Quantity cannot be negative"
|
assert item.quantity > 0, "Quantity cannot be negative"
|
||||||
price = float(str(product_prices[item.product_id]["price"]))
|
price = product_prices[item.product_id]["price"]
|
||||||
currency = str(product_prices[item.product_id]["currency"])
|
currency = product_prices[item.product_id]["currency"]
|
||||||
if currency != "sat":
|
if currency != "sat":
|
||||||
price = await fiat_amount_as_satoshis(price, currency)
|
price = await fiat_amount_as_satoshis(price, currency)
|
||||||
product_cost += item.quantity * price
|
product_cost += item.quantity * price
|
||||||
|
|
@ -406,39 +399,30 @@ class PartialOrder(BaseModel):
|
||||||
if len(products) == 0:
|
if len(products) == 0:
|
||||||
return "[No Products]"
|
return "[No Products]"
|
||||||
receipt = ""
|
receipt = ""
|
||||||
product_prices: dict[str, ProductOverview] = {}
|
product_prices = {}
|
||||||
for p in products:
|
for p in products:
|
||||||
product_shipping_cost = next(
|
product_shipping_cost = next(
|
||||||
(s.cost for s in p.config.shipping if s.id == shipping_id), 0
|
(s.cost for s in p.config.shipping if s.id == shipping_id), 0
|
||||||
)
|
)
|
||||||
assert p.id
|
product_prices[p.id] = {
|
||||||
product_prices[p.id] = ProductOverview(
|
"name": p.name,
|
||||||
id=p.id,
|
"price": p.price,
|
||||||
name=p.name,
|
"product_shipping_cost": product_shipping_cost
|
||||||
price=p.price,
|
}
|
||||||
product_shipping_cost=product_shipping_cost,
|
|
||||||
)
|
|
||||||
|
|
||||||
currency = products[0].config.currency or "sat"
|
currency = products[0].config.currency or "sat"
|
||||||
products_cost: float = 0 # todo
|
products_cost: float = 0 # todo
|
||||||
items_receipts = []
|
items_receipts = []
|
||||||
for item in self.items:
|
for item in self.items:
|
||||||
prod = product_prices[item.product_id]
|
prod = product_prices[item.product_id]
|
||||||
price = prod.price + (prod.product_shipping_cost or 0)
|
price = prod["price"] + prod["product_shipping_cost"]
|
||||||
|
|
||||||
products_cost += item.quantity * price
|
products_cost += item.quantity * price
|
||||||
|
|
||||||
items_receipts.append(
|
items_receipts.append(f"""[{prod["name"]}: {item.quantity} x ({prod["price"]} + {prod["product_shipping_cost"]}) = {item.quantity * price} {currency}] """)
|
||||||
f"""[{prod.name}: {item.quantity} x ({prod.price}"""
|
|
||||||
f""" + {prod.product_shipping_cost})"""
|
|
||||||
f""" = {item.quantity * price} {currency}] """
|
|
||||||
)
|
|
||||||
|
|
||||||
receipt = "; ".join(items_receipts)
|
receipt = "; ".join(items_receipts)
|
||||||
receipt += (
|
receipt += f"[Products cost: {products_cost} {currency}] [Stall shipping cost: {stall_shipping_cost} {currency}]; "
|
||||||
f"[Products cost: {products_cost} {currency}] "
|
|
||||||
f"[Stall shipping cost: {stall_shipping_cost} {currency}]; "
|
|
||||||
)
|
|
||||||
receipt += f"[Total: {products_cost + stall_shipping_cost} {currency}]"
|
receipt += f"[Total: {products_cost + stall_shipping_cost} {currency}]"
|
||||||
|
|
||||||
return receipt
|
return receipt
|
||||||
|
|
@ -450,23 +434,23 @@ class Order(PartialOrder):
|
||||||
total: float
|
total: float
|
||||||
paid: bool = False
|
paid: bool = False
|
||||||
shipped: bool = False
|
shipped: bool = False
|
||||||
time: Optional[int] = None
|
time: Optional[int]
|
||||||
extra: OrderExtra
|
extra: OrderExtra
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_row(cls, row: dict) -> "Order":
|
def from_row(cls, row: Row) -> "Order":
|
||||||
contact = OrderContact(**json.loads(row["contact_data"]))
|
contact = OrderContact(**json.loads(row["contact_data"]))
|
||||||
extra = OrderExtra(**json.loads(row["extra_data"]))
|
extra = OrderExtra(**json.loads(row["extra_data"]))
|
||||||
items = [OrderItem(**z) for z in json.loads(row["order_items"])]
|
items = [OrderItem(**z) for z in json.loads(row["order_items"])]
|
||||||
order = cls(**row, contact=contact, items=items, extra=extra)
|
order = cls(**dict(row), contact=contact, items=items, extra=extra)
|
||||||
return order
|
return order
|
||||||
|
|
||||||
|
|
||||||
class OrderStatusUpdate(BaseModel):
|
class OrderStatusUpdate(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
message: Optional[str] = None
|
message: Optional[str]
|
||||||
paid: Optional[bool] = False
|
paid: Optional[bool]
|
||||||
shipped: Optional[bool] = None
|
shipped: Optional[bool]
|
||||||
|
|
||||||
|
|
||||||
class OrderReissue(BaseModel):
|
class OrderReissue(BaseModel):
|
||||||
|
|
@ -481,11 +465,11 @@ class PaymentOption(BaseModel):
|
||||||
|
|
||||||
class PaymentRequest(BaseModel):
|
class PaymentRequest(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
message: Optional[str] = None
|
message: Optional[str]
|
||||||
payment_options: List[PaymentOption]
|
payment_options: List[PaymentOption]
|
||||||
|
|
||||||
|
|
||||||
######################################## MESSAGE #######################################
|
######################################## MESSAGE ########################################
|
||||||
|
|
||||||
|
|
||||||
class DirectMessageType(Enum):
|
class DirectMessageType(Enum):
|
||||||
|
|
@ -498,13 +482,13 @@ class DirectMessageType(Enum):
|
||||||
|
|
||||||
|
|
||||||
class PartialDirectMessage(BaseModel):
|
class PartialDirectMessage(BaseModel):
|
||||||
event_id: Optional[str] = None
|
event_id: Optional[str]
|
||||||
event_created_at: Optional[int] = None
|
event_created_at: Optional[int]
|
||||||
message: str
|
message: str
|
||||||
public_key: str
|
public_key: str
|
||||||
type: int = DirectMessageType.PLAIN_TEXT.value
|
type: int = DirectMessageType.PLAIN_TEXT.value
|
||||||
incoming: bool = False
|
incoming: bool = False
|
||||||
time: Optional[int] = None
|
time: Optional[int]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def parse_message(cls, msg) -> Tuple[DirectMessageType, Optional[Any]]:
|
def parse_message(cls, msg) -> Tuple[DirectMessageType, Optional[Any]]:
|
||||||
|
|
@ -522,28 +506,29 @@ class DirectMessage(PartialDirectMessage):
|
||||||
id: str
|
id: str
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_row(cls, row: dict) -> "DirectMessage":
|
def from_row(cls, row: Row) -> "DirectMessage":
|
||||||
return cls(**row)
|
dm = cls(**dict(row))
|
||||||
|
return dm
|
||||||
|
|
||||||
|
|
||||||
######################################## CUSTOMERS #####################################
|
######################################## CUSTOMERS ########################################
|
||||||
|
|
||||||
|
|
||||||
class CustomerProfile(BaseModel):
|
class CustomerProfile(BaseModel):
|
||||||
name: Optional[str] = None
|
name: Optional[str]
|
||||||
about: Optional[str] = None
|
about: Optional[str]
|
||||||
|
|
||||||
|
|
||||||
class Customer(BaseModel):
|
class Customer(BaseModel):
|
||||||
merchant_id: str
|
merchant_id: str
|
||||||
public_key: str
|
public_key: str
|
||||||
event_created_at: Optional[int] = None
|
event_created_at: Optional[int]
|
||||||
profile: Optional[CustomerProfile] = None
|
profile: Optional[CustomerProfile]
|
||||||
unread_messages: int = 0
|
unread_messages: int = 0
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_row(cls, row: dict) -> "Customer":
|
def from_row(cls, row: Row) -> "Customer":
|
||||||
customer = cls(**row)
|
customer = cls(**dict(row))
|
||||||
customer.profile = (
|
customer.profile = (
|
||||||
CustomerProfile(**json.loads(row["meta"])) if "meta" in row else None
|
CustomerProfile(**json.loads(row["meta"])) if "meta" in row else None
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@ import hashlib
|
||||||
import json
|
import json
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
from coincurve import PublicKeyXOnly
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
from secp256k1 import PublicKey
|
||||||
|
|
||||||
|
|
||||||
class NostrEvent(BaseModel):
|
class NostrEvent(BaseModel):
|
||||||
|
|
@ -13,7 +13,7 @@ class NostrEvent(BaseModel):
|
||||||
kind: int
|
kind: int
|
||||||
tags: List[List[str]] = []
|
tags: List[List[str]] = []
|
||||||
content: str = ""
|
content: str = ""
|
||||||
sig: Optional[str] = None
|
sig: Optional[str]
|
||||||
|
|
||||||
def serialize(self) -> List:
|
def serialize(self) -> List:
|
||||||
return [0, self.pubkey, self.created_at, self.kind, self.tags, self.content]
|
return [0, self.pubkey, self.created_at, self.kind, self.tags, self.content]
|
||||||
|
|
@ -35,14 +35,14 @@ class NostrEvent(BaseModel):
|
||||||
f"Invalid event id. Expected: '{event_id}' got '{self.id}'"
|
f"Invalid event id. Expected: '{event_id}' got '{self.id}'"
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
pub_key = PublicKeyXOnly(bytes.fromhex(self.pubkey))
|
pub_key = PublicKey(bytes.fromhex("02" + self.pubkey), True)
|
||||||
except Exception as exc:
|
except Exception:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Invalid public key: '{self.pubkey}' for event '{self.id}'"
|
f"Invalid public key: '{self.pubkey}' for event '{self.id}'"
|
||||||
) from exc
|
)
|
||||||
|
|
||||||
valid_signature = self.sig and pub_key.verify(
|
valid_signature = pub_key.schnorr_verify(
|
||||||
bytes.fromhex(self.sig), bytes.fromhex(event_id)
|
bytes.fromhex(event_id), bytes.fromhex(self.sig), None, raw=True
|
||||||
)
|
)
|
||||||
if not valid_signature:
|
if not valid_signature:
|
||||||
raise ValueError(f"Invalid signature: '{self.sig}' for event '{self.id}'")
|
raise ValueError(f"Invalid signature: '{self.sig}' for event '{self.id}'")
|
||||||
|
|
|
||||||
180
nostr/nip44.py
|
|
@ -1,180 +0,0 @@
|
||||||
"""
|
|
||||||
NIP-44 v2: Encrypted Payloads (Versioned)
|
|
||||||
|
|
||||||
secp256k1 ECDH, HKDF, padding, ChaCha20, HMAC-SHA256, base64
|
|
||||||
|
|
||||||
Reference: https://github.com/nostr-protocol/nips/blob/master/44.md
|
|
||||||
"""
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import hashlib
|
|
||||||
import hmac
|
|
||||||
import math
|
|
||||||
import secrets
|
|
||||||
import struct
|
|
||||||
|
|
||||||
import coincurve
|
|
||||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms
|
|
||||||
from cryptography.hazmat.primitives.kdf.hkdf import HKDFExpand
|
|
||||||
from cryptography.hazmat.primitives import hashes
|
|
||||||
|
|
||||||
VERSION = 2
|
|
||||||
MIN_PLAINTEXT_SIZE = 1
|
|
||||||
MAX_PLAINTEXT_SIZE = 65535
|
|
||||||
|
|
||||||
|
|
||||||
def get_conversation_key(private_key_hex: str, public_key_hex: str) -> bytes:
|
|
||||||
"""
|
|
||||||
Calculate long-term conversation key between two users via ECDH + HKDF-extract.
|
|
||||||
Symmetric: get_conversation_key(a, B) == get_conversation_key(b, A)
|
|
||||||
"""
|
|
||||||
pk = coincurve.PublicKey(bytes.fromhex("02" + public_key_hex))
|
|
||||||
sk = coincurve.PrivateKey(bytes.fromhex(private_key_hex))
|
|
||||||
shared_point = pk.multiply(sk.secret)
|
|
||||||
shared_x = shared_point.format(compressed=False)[1:33]
|
|
||||||
|
|
||||||
# HKDF-extract only (not expand) with salt='nip44-v2'
|
|
||||||
conversation_key = hmac.new(b"nip44-v2", shared_x, hashlib.sha256).digest()
|
|
||||||
return conversation_key
|
|
||||||
|
|
||||||
|
|
||||||
def get_message_keys(
|
|
||||||
conversation_key: bytes, nonce: bytes
|
|
||||||
) -> tuple[bytes, bytes, bytes]:
|
|
||||||
"""
|
|
||||||
Derive per-message keys from conversation_key and nonce using HKDF-expand.
|
|
||||||
Returns (chacha_key, chacha_nonce, hmac_key).
|
|
||||||
"""
|
|
||||||
if len(conversation_key) != 32:
|
|
||||||
raise ValueError("invalid conversation_key length")
|
|
||||||
if len(nonce) != 32:
|
|
||||||
raise ValueError("invalid nonce length")
|
|
||||||
|
|
||||||
keys = HKDFExpand(
|
|
||||||
algorithm=hashes.SHA256(),
|
|
||||||
length=76,
|
|
||||||
info=nonce,
|
|
||||||
).derive(conversation_key)
|
|
||||||
|
|
||||||
chacha_key = keys[0:32]
|
|
||||||
chacha_nonce = keys[32:44]
|
|
||||||
hmac_key = keys[44:76]
|
|
||||||
return chacha_key, chacha_nonce, hmac_key
|
|
||||||
|
|
||||||
|
|
||||||
def calc_padded_len(unpadded_len: int) -> int:
|
|
||||||
"""Calculate padded length using power-of-two chunking."""
|
|
||||||
if unpadded_len <= 0:
|
|
||||||
raise ValueError("invalid plaintext length")
|
|
||||||
if unpadded_len <= 32:
|
|
||||||
return 32
|
|
||||||
next_power = 1 << (math.floor(math.log2(unpadded_len - 1)) + 1)
|
|
||||||
if next_power <= 256:
|
|
||||||
chunk = 32
|
|
||||||
else:
|
|
||||||
chunk = next_power // 8
|
|
||||||
return chunk * (math.floor((unpadded_len - 1) / chunk) + 1)
|
|
||||||
|
|
||||||
|
|
||||||
def _pad(plaintext: str) -> bytes:
|
|
||||||
"""Convert plaintext string to padded byte array."""
|
|
||||||
unpadded = plaintext.encode("utf-8")
|
|
||||||
unpadded_len = len(unpadded)
|
|
||||||
if unpadded_len < MIN_PLAINTEXT_SIZE or unpadded_len > MAX_PLAINTEXT_SIZE:
|
|
||||||
raise ValueError(
|
|
||||||
f"invalid plaintext length: {unpadded_len} "
|
|
||||||
f"(must be {MIN_PLAINTEXT_SIZE}..{MAX_PLAINTEXT_SIZE})"
|
|
||||||
)
|
|
||||||
prefix = struct.pack(">H", unpadded_len)
|
|
||||||
padded_len = calc_padded_len(unpadded_len)
|
|
||||||
suffix = b"\x00" * (padded_len - unpadded_len)
|
|
||||||
return prefix + unpadded + suffix
|
|
||||||
|
|
||||||
|
|
||||||
def _unpad(padded: bytes) -> str:
|
|
||||||
"""Convert padded byte array back to plaintext string."""
|
|
||||||
unpadded_len = struct.unpack(">H", padded[0:2])[0]
|
|
||||||
unpadded = padded[2 : 2 + unpadded_len]
|
|
||||||
if (
|
|
||||||
unpadded_len == 0
|
|
||||||
or len(unpadded) != unpadded_len
|
|
||||||
or len(padded) != 2 + calc_padded_len(unpadded_len)
|
|
||||||
):
|
|
||||||
raise ValueError("invalid padding")
|
|
||||||
return unpadded.decode("utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def _hmac_aad(key: bytes, message: bytes, aad: bytes) -> bytes:
|
|
||||||
"""HMAC-SHA256 with AAD: hmac(key, aad || message)."""
|
|
||||||
if len(aad) != 32:
|
|
||||||
raise ValueError("AAD associated data must be 32 bytes")
|
|
||||||
return hmac.new(key, aad + message, hashlib.sha256).digest()
|
|
||||||
|
|
||||||
|
|
||||||
def _chacha20(key: bytes, nonce: bytes, data: bytes) -> bytes:
|
|
||||||
"""ChaCha20 encrypt/decrypt with initial counter = 0."""
|
|
||||||
# cryptography's ChaCha20 takes a 16-byte nonce: 4-byte counter (LE) + 12-byte nonce
|
|
||||||
full_nonce = b"\x00\x00\x00\x00" + nonce
|
|
||||||
cipher = Cipher(algorithms.ChaCha20(key, full_nonce), mode=None)
|
|
||||||
encryptor = cipher.encryptor()
|
|
||||||
return encryptor.update(data) + encryptor.finalize()
|
|
||||||
|
|
||||||
|
|
||||||
def _decode_payload(payload: str) -> tuple[bytes, bytes, bytes]:
|
|
||||||
"""Decode base64 payload into (nonce, ciphertext, mac)."""
|
|
||||||
plen = len(payload)
|
|
||||||
if plen == 0 or payload[0] == "#":
|
|
||||||
raise ValueError("unknown version")
|
|
||||||
if plen < 132 or plen > 87472:
|
|
||||||
raise ValueError("invalid payload size")
|
|
||||||
|
|
||||||
data = base64.b64decode(payload)
|
|
||||||
dlen = len(data)
|
|
||||||
if dlen < 99 or dlen > 65603:
|
|
||||||
raise ValueError("invalid data size")
|
|
||||||
|
|
||||||
vers = data[0]
|
|
||||||
if vers != VERSION:
|
|
||||||
raise ValueError(f"unknown version {vers}")
|
|
||||||
|
|
||||||
nonce = data[1:33]
|
|
||||||
ciphertext = data[33 : dlen - 32]
|
|
||||||
mac = data[dlen - 32 : dlen]
|
|
||||||
return nonce, ciphertext, mac
|
|
||||||
|
|
||||||
|
|
||||||
def encrypt(
|
|
||||||
plaintext: str,
|
|
||||||
conversation_key: bytes,
|
|
||||||
nonce: bytes | None = None,
|
|
||||||
) -> str:
|
|
||||||
"""
|
|
||||||
Encrypt plaintext using NIP-44 v2.
|
|
||||||
Returns base64-encoded payload.
|
|
||||||
"""
|
|
||||||
if nonce is None:
|
|
||||||
nonce = secrets.token_bytes(32)
|
|
||||||
if len(nonce) != 32:
|
|
||||||
raise ValueError("invalid nonce length")
|
|
||||||
|
|
||||||
chacha_key, chacha_nonce, hmac_key = get_message_keys(conversation_key, nonce)
|
|
||||||
padded = _pad(plaintext)
|
|
||||||
ciphertext = _chacha20(chacha_key, chacha_nonce, padded)
|
|
||||||
mac = _hmac_aad(hmac_key, ciphertext, nonce)
|
|
||||||
return base64.b64encode(
|
|
||||||
struct.pack("B", VERSION) + nonce + ciphertext + mac
|
|
||||||
).decode("ascii")
|
|
||||||
|
|
||||||
|
|
||||||
def decrypt(payload: str, conversation_key: bytes) -> str:
|
|
||||||
"""
|
|
||||||
Decrypt a NIP-44 v2 base64 payload.
|
|
||||||
Returns plaintext string.
|
|
||||||
"""
|
|
||||||
nonce, ciphertext, mac = _decode_payload(payload)
|
|
||||||
chacha_key, chacha_nonce, hmac_key = get_message_keys(conversation_key, nonce)
|
|
||||||
calculated_mac = _hmac_aad(hmac_key, ciphertext, nonce)
|
|
||||||
if not hmac.compare_digest(calculated_mac, mac):
|
|
||||||
raise ValueError("invalid MAC")
|
|
||||||
padded_plaintext = _chacha20(chacha_key, chacha_nonce, ciphertext)
|
|
||||||
return _unpad(padded_plaintext)
|
|
||||||
231
nostr/nip59.py
|
|
@ -1,231 +0,0 @@
|
||||||
"""
|
|
||||||
NIP-59: Gift Wrap
|
|
||||||
|
|
||||||
Three-layer protocol for metadata-protected messaging:
|
|
||||||
1. Rumor (unsigned event) — carries content, deniable if leaked
|
|
||||||
2. Seal (kind 13) — encrypts rumor, signed by author, no recipient metadata
|
|
||||||
3. Gift Wrap (kind 1059) — encrypts seal with ephemeral key, has recipient p-tag
|
|
||||||
|
|
||||||
Reference: https://github.com/nostr-protocol/nips/blob/master/59.md
|
|
||||||
|
|
||||||
## Bunker integration (aiolabs/nostrmarket#5)
|
|
||||||
|
|
||||||
Merchant-identity layers (rumor's sender-pubkey + seal's encryption +
|
|
||||||
seal's signature) route through the lnbits `NostrSigner` abstraction
|
|
||||||
so the merchant's nsec stays in the bunker — never reaches this
|
|
||||||
process. Specifically:
|
|
||||||
|
|
||||||
- `create_seal` is async; takes a `sender_signer` instead of a
|
|
||||||
plaintext nsec. The seal's NIP-44 encrypt + Schnorr sign happen
|
|
||||||
via `await sender_signer.nip44_encrypt(...)` +
|
|
||||||
`await sender_signer.sign_event(...)` over the NIP-46 channel.
|
|
||||||
- `unwrap_gift_wrap` + `unseal` are async; take a `recipient_signer`
|
|
||||||
and call `await recipient_signer.nip44_decrypt(...)` for each layer.
|
|
||||||
|
|
||||||
The **ephemeral keypair layer** (`create_gift_wrap`) stays synchronous
|
|
||||||
+ local: the ephemeral nsec exists for the lifetime of one wrap and
|
|
||||||
provides no merchant-identity capability, so there's no reason to
|
|
||||||
involve the bunker. Generating it locally avoids one round-trip per
|
|
||||||
DM.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import secrets
|
|
||||||
import time
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
import coincurve
|
|
||||||
|
|
||||||
from .event import NostrEvent
|
|
||||||
from .nip44 import decrypt as nip44_decrypt
|
|
||||||
from .nip44 import encrypt as nip44_encrypt
|
|
||||||
from .nip44 import get_conversation_key
|
|
||||||
|
|
||||||
TWO_DAYS = 2 * 24 * 60 * 60
|
|
||||||
|
|
||||||
|
|
||||||
def _random_past_timestamp() -> int:
|
|
||||||
"""Generate a timestamp randomly in the past 0-2 days for metadata protection."""
|
|
||||||
return int(time.time()) - secrets.randbelow(TWO_DAYS)
|
|
||||||
|
|
||||||
|
|
||||||
def _sign_event_local(event: NostrEvent, private_key_hex: str) -> NostrEvent:
|
|
||||||
"""Compute event id and sign it locally with a privkey held in this
|
|
||||||
process. Used only for the ephemeral-keypair layer (gift wrap outer);
|
|
||||||
merchant-identity sign goes through the signer ABC instead."""
|
|
||||||
event.id = event.event_id
|
|
||||||
sk = coincurve.PrivateKey(bytes.fromhex(private_key_hex))
|
|
||||||
event.sig = sk.sign_schnorr(bytes.fromhex(event.id)).hex()
|
|
||||||
return event
|
|
||||||
|
|
||||||
|
|
||||||
def _pubkey_from_privkey(private_key_hex: str) -> str:
|
|
||||||
"""Derive x-only public key hex from private key hex."""
|
|
||||||
sk = coincurve.PrivateKey(bytes.fromhex(private_key_hex))
|
|
||||||
return sk.public_key.format(compressed=True)[1:].hex()
|
|
||||||
|
|
||||||
|
|
||||||
def create_rumor(
|
|
||||||
pubkey: str,
|
|
||||||
content: str,
|
|
||||||
kind: int = 14,
|
|
||||||
tags: Optional[list[list[str]]] = None,
|
|
||||||
created_at: Optional[int] = None,
|
|
||||||
) -> NostrEvent:
|
|
||||||
"""
|
|
||||||
Create an unsigned rumor event.
|
|
||||||
The event has an id but no signature, making it deniable.
|
|
||||||
"""
|
|
||||||
event = NostrEvent(
|
|
||||||
pubkey=pubkey,
|
|
||||||
created_at=created_at or int(time.time()),
|
|
||||||
kind=kind,
|
|
||||||
tags=tags or [],
|
|
||||||
content=content,
|
|
||||||
)
|
|
||||||
event.id = event.event_id
|
|
||||||
# sig intentionally left as None (unsigned)
|
|
||||||
return event
|
|
||||||
|
|
||||||
|
|
||||||
async def create_seal(
|
|
||||||
rumor: NostrEvent,
|
|
||||||
sender_signer,
|
|
||||||
recipient_pubkey: str,
|
|
||||||
) -> NostrEvent:
|
|
||||||
"""
|
|
||||||
Create a kind 13 seal: encrypts the rumor for the recipient.
|
|
||||||
Signed by the sender. Tags are always empty.
|
|
||||||
|
|
||||||
Both crypto operations (NIP-44 encrypt + Schnorr sign) route
|
|
||||||
through the sender's `NostrSigner` (`sender_signer`) — the
|
|
||||||
plaintext nsec is never observable in this process.
|
|
||||||
"""
|
|
||||||
encrypted_rumor = await sender_signer.nip44_encrypt(
|
|
||||||
rumor.stringify(), recipient_pubkey
|
|
||||||
)
|
|
||||||
|
|
||||||
seal = NostrEvent(
|
|
||||||
pubkey=sender_signer.pubkey,
|
|
||||||
created_at=_random_past_timestamp(),
|
|
||||||
kind=13,
|
|
||||||
tags=[],
|
|
||||||
content=encrypted_rumor,
|
|
||||||
)
|
|
||||||
# The signer fills id + sig (computed bunker-side).
|
|
||||||
signed = await sender_signer.sign_event(
|
|
||||||
{
|
|
||||||
"pubkey": seal.pubkey,
|
|
||||||
"created_at": seal.created_at,
|
|
||||||
"kind": seal.kind,
|
|
||||||
"tags": seal.tags,
|
|
||||||
"content": seal.content,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
seal.id = signed["id"]
|
|
||||||
seal.sig = signed["sig"]
|
|
||||||
return seal
|
|
||||||
|
|
||||||
|
|
||||||
def create_gift_wrap(
|
|
||||||
seal: NostrEvent,
|
|
||||||
recipient_pubkey: str,
|
|
||||||
) -> NostrEvent:
|
|
||||||
"""
|
|
||||||
Create a kind 1059 gift wrap: encrypts the seal with an ephemeral key.
|
|
||||||
The only public metadata is the recipient's p-tag.
|
|
||||||
|
|
||||||
Stays synchronous + local: the ephemeral nsec exists only for the
|
|
||||||
lifetime of one wrap and provides no merchant-identity capability,
|
|
||||||
so there's no point routing through the bunker (would add one NIP-46
|
|
||||||
round-trip per DM with zero security benefit).
|
|
||||||
"""
|
|
||||||
ephemeral_privkey = secrets.token_bytes(32).hex()
|
|
||||||
ephemeral_pubkey = _pubkey_from_privkey(ephemeral_privkey)
|
|
||||||
|
|
||||||
conv_key = get_conversation_key(ephemeral_privkey, recipient_pubkey)
|
|
||||||
encrypted_seal = nip44_encrypt(seal.stringify(), conv_key)
|
|
||||||
|
|
||||||
wrap = NostrEvent(
|
|
||||||
pubkey=ephemeral_pubkey,
|
|
||||||
created_at=_random_past_timestamp(),
|
|
||||||
kind=1059,
|
|
||||||
tags=[["p", recipient_pubkey]],
|
|
||||||
content=encrypted_seal,
|
|
||||||
)
|
|
||||||
return _sign_event_local(wrap, ephemeral_privkey)
|
|
||||||
|
|
||||||
|
|
||||||
async def unwrap_gift_wrap(
|
|
||||||
gift_wrap: NostrEvent,
|
|
||||||
recipient_signer,
|
|
||||||
) -> NostrEvent:
|
|
||||||
"""
|
|
||||||
Decrypt a kind 1059 gift wrap to reveal the inner seal.
|
|
||||||
Routes NIP-44 decrypt through the recipient's signer abstraction
|
|
||||||
so the recipient's nsec stays in the bunker.
|
|
||||||
"""
|
|
||||||
seal_json = await recipient_signer.nip44_decrypt(
|
|
||||||
gift_wrap.content, gift_wrap.pubkey
|
|
||||||
)
|
|
||||||
return NostrEvent(**json.loads(seal_json))
|
|
||||||
|
|
||||||
|
|
||||||
async def unseal(
|
|
||||||
seal: NostrEvent,
|
|
||||||
recipient_signer,
|
|
||||||
) -> NostrEvent:
|
|
||||||
"""
|
|
||||||
Decrypt a kind 13 seal to reveal the inner rumor.
|
|
||||||
Uses the recipient signer (their nsec stays in the bunker) and the
|
|
||||||
seal's pubkey (the sender). Validates that the rumor's pubkey
|
|
||||||
matches the seal's pubkey.
|
|
||||||
"""
|
|
||||||
rumor_json = await recipient_signer.nip44_decrypt(seal.content, seal.pubkey)
|
|
||||||
rumor = NostrEvent(**json.loads(rumor_json))
|
|
||||||
|
|
||||||
if rumor.pubkey != seal.pubkey:
|
|
||||||
raise ValueError(
|
|
||||||
f"rumor pubkey ({rumor.pubkey}) does not match "
|
|
||||||
f"seal pubkey ({seal.pubkey})"
|
|
||||||
)
|
|
||||||
return rumor
|
|
||||||
|
|
||||||
|
|
||||||
# --- Convenience functions ---
|
|
||||||
|
|
||||||
|
|
||||||
async def wrap_message(
|
|
||||||
content: str,
|
|
||||||
sender_signer,
|
|
||||||
recipient_pubkey: str,
|
|
||||||
kind: int = 14,
|
|
||||||
tags: Optional[list[list[str]]] = None,
|
|
||||||
) -> NostrEvent:
|
|
||||||
"""
|
|
||||||
Full wrap pipeline: create rumor → seal → gift wrap.
|
|
||||||
Returns the gift wrap event ready to publish.
|
|
||||||
|
|
||||||
`sender_signer` is the sender merchant's `NostrSigner` (post-#5:
|
|
||||||
always a `RemoteBunkerSigner`). The merchant's nsec never leaves
|
|
||||||
the bunker.
|
|
||||||
"""
|
|
||||||
rumor = create_rumor(sender_signer.pubkey, content, kind=kind, tags=tags)
|
|
||||||
seal = await create_seal(rumor, sender_signer, recipient_pubkey)
|
|
||||||
return create_gift_wrap(seal, recipient_pubkey)
|
|
||||||
|
|
||||||
|
|
||||||
async def unwrap_message(
|
|
||||||
gift_wrap: NostrEvent,
|
|
||||||
recipient_signer,
|
|
||||||
) -> NostrEvent:
|
|
||||||
"""
|
|
||||||
Full unwrap pipeline: gift wrap → seal → rumor.
|
|
||||||
Returns the rumor with sender pubkey and plaintext content.
|
|
||||||
|
|
||||||
`recipient_signer` is the recipient merchant's `NostrSigner`. Both
|
|
||||||
NIP-44 decrypt layers (gift wrap → seal, seal → rumor) route
|
|
||||||
through the signer abstraction.
|
|
||||||
"""
|
|
||||||
seal = await unwrap_gift_wrap(gift_wrap, recipient_signer)
|
|
||||||
return await unseal(seal, recipient_signer)
|
|
||||||
|
|
@ -1,31 +1,25 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import time
|
|
||||||
from asyncio import Queue
|
from asyncio import Queue
|
||||||
from collections import OrderedDict
|
|
||||||
from threading import Thread
|
from threading import Thread
|
||||||
from typing import Callable, List, Optional
|
from typing import Callable, List
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from websocket import WebSocketApp
|
from websocket import WebSocketApp
|
||||||
|
|
||||||
from lnbits.settings import settings
|
from lnbits.app import settings
|
||||||
from lnbits.helpers import encrypt_internal_message, urlsafe_short_hash
|
from lnbits.helpers import encrypt_internal_message, urlsafe_short_hash
|
||||||
|
|
||||||
from .event import NostrEvent
|
from .event import NostrEvent
|
||||||
|
|
||||||
MAX_SEEN_EVENTS = 1000
|
|
||||||
|
|
||||||
|
|
||||||
class NostrClient:
|
class NostrClient:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.recieve_event_queue: Queue = Queue()
|
self.recieve_event_queue: Queue = Queue()
|
||||||
self.send_req_queue: Queue = Queue()
|
self.send_req_queue: Queue = Queue()
|
||||||
self.ws: Optional[WebSocketApp] = None
|
self.ws: WebSocketApp = None
|
||||||
self.subscription_id = "nostrmarket-" + urlsafe_short_hash()[:32]
|
self.subscription_id = "nostrmarket-" + urlsafe_short_hash()[:32]
|
||||||
self.running = False
|
self.running = False
|
||||||
self._seen_events: OrderedDict[str, None] = OrderedDict()
|
|
||||||
self.last_event_at: float = 0
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_websocket_connected(self):
|
def is_websocket_connected(self):
|
||||||
|
|
@ -36,12 +30,11 @@ class NostrClient:
|
||||||
async def connect_to_nostrclient_ws(self) -> WebSocketApp:
|
async def connect_to_nostrclient_ws(self) -> WebSocketApp:
|
||||||
logger.debug(f"Connecting to websockets for 'nostrclient' extension...")
|
logger.debug(f"Connecting to websockets for 'nostrclient' extension...")
|
||||||
|
|
||||||
relay_endpoint = encrypt_internal_message("relay", urlsafe=True)
|
|
||||||
ws_url = f"ws://localhost:{settings.port}/nostrclient/api/v1/{relay_endpoint}"
|
relay_endpoint = encrypt_internal_message("relay")
|
||||||
|
|
||||||
on_open, on_message, on_error, on_close = self._ws_handlers()
|
on_open, on_message, on_error, on_close = self._ws_handlers()
|
||||||
ws = WebSocketApp(
|
ws = WebSocketApp(
|
||||||
ws_url,
|
f"ws://localhost:{settings.port}/nostrclient/api/v1/{relay_endpoint}",
|
||||||
on_message=on_message,
|
on_message=on_message,
|
||||||
on_open=on_open,
|
on_open=on_open,
|
||||||
on_close=on_close,
|
on_close=on_close,
|
||||||
|
|
@ -64,29 +57,19 @@ class NostrClient:
|
||||||
await asyncio.sleep(5)
|
await asyncio.sleep(5)
|
||||||
|
|
||||||
req = await self.send_req_queue.get()
|
req = await self.send_req_queue.get()
|
||||||
assert self.ws
|
|
||||||
self.ws.send(json.dumps(req))
|
self.ws.send(json.dumps(req))
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
logger.warning(ex)
|
logger.warning(ex)
|
||||||
await asyncio.sleep(60)
|
await asyncio.sleep(60)
|
||||||
|
|
||||||
def is_duplicate_event(self, event_id: str) -> bool:
|
|
||||||
"""Check if an event has been seen recently. Returns True if duplicate."""
|
|
||||||
if event_id in self._seen_events:
|
|
||||||
return True
|
|
||||||
self._seen_events[event_id] = None
|
|
||||||
if len(self._seen_events) > MAX_SEEN_EVENTS:
|
|
||||||
self._seen_events.popitem(last=False)
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def get_event(self):
|
async def get_event(self):
|
||||||
value = await self.recieve_event_queue.get()
|
value = await self.recieve_event_queue.get()
|
||||||
if isinstance(value, ValueError):
|
if isinstance(value, ValueError):
|
||||||
logger.error(f"[NOSTRMARKET] ❌ Queue returned error: {value}")
|
|
||||||
raise value
|
raise value
|
||||||
self.last_event_at = time.time()
|
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
async def publish_nostr_event(self, e: NostrEvent):
|
async def publish_nostr_event(self, e: NostrEvent):
|
||||||
await self.send_req_queue.put(["EVENT", e.dict()])
|
await self.send_req_queue.put(["EVENT", e.dict()])
|
||||||
|
|
||||||
|
|
@ -110,6 +93,10 @@ class NostrClient:
|
||||||
self.subscription_id = "nostrmarket-" + urlsafe_short_hash()[:32]
|
self.subscription_id = "nostrmarket-" + urlsafe_short_hash()[:32]
|
||||||
await self.send_req_queue.put(["REQ", self.subscription_id] + merchant_filters)
|
await self.send_req_queue.put(["REQ", self.subscription_id] + merchant_filters)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"Subscribing to events for: {len(public_keys)} keys. New subscription id: {self.subscription_id}"
|
||||||
|
)
|
||||||
|
|
||||||
async def merchant_temp_subscription(self, pk, duration=10):
|
async def merchant_temp_subscription(self, pk, duration=10):
|
||||||
dm_filters = self._filters_for_direct_messages([pk], 0)
|
dm_filters = self._filters_for_direct_messages([pk], 0)
|
||||||
stall_filters = self._filters_for_stall_events([pk], 0)
|
stall_filters = self._filters_for_stall_events([pk], 0)
|
||||||
|
|
@ -132,7 +119,7 @@ class NostrClient:
|
||||||
|
|
||||||
asyncio.create_task(unsubscribe_with_delay(subscription_id, duration))
|
asyncio.create_task(unsubscribe_with_delay(subscription_id, duration))
|
||||||
|
|
||||||
async def user_profile_temp_subscribe(self, public_key: str, duration=5):
|
async def user_profile_temp_subscribe(self, public_key: str, duration=5) -> List:
|
||||||
try:
|
try:
|
||||||
profile_filter = [{"kinds": [0], "authors": [public_key]}]
|
profile_filter = [{"kinds": [0], "authors": [public_key]}]
|
||||||
subscription_id = "profile-" + urlsafe_short_hash()[:32]
|
subscription_id = "profile-" + urlsafe_short_hash()[:32]
|
||||||
|
|
@ -150,16 +137,13 @@ class NostrClient:
|
||||||
logger.debug(ex)
|
logger.debug(ex)
|
||||||
|
|
||||||
def _filters_for_direct_messages(self, public_keys: List[str], since: int) -> List:
|
def _filters_for_direct_messages(self, public_keys: List[str], since: int) -> List:
|
||||||
# NIP-17/NIP-59: subscribe to kind 1059 gift wraps addressed to our merchants.
|
in_messages_filter = {"kinds": [4], "#p": public_keys}
|
||||||
# With gift wrapping, outgoing messages are self-wrapped (same p-tag filter).
|
out_messages_filter = {"kinds": [4], "authors": public_keys}
|
||||||
#
|
if since and since != 0:
|
||||||
# Do NOT apply `since` here. Per NIP-59, gift wraps use randomized past
|
in_messages_filter["since"] = since
|
||||||
# timestamps (up to 2 days back) to defeat metadata correlation, so a
|
out_messages_filter["since"] = since
|
||||||
# `since` derived from the latest DM in our DB will reject fresh wraps
|
|
||||||
# whose randomized created_at is older than that window. Server-side
|
return [in_messages_filter, out_messages_filter]
|
||||||
# dedup + the client's is_duplicate_event() guard handle replays.
|
|
||||||
gift_wrap_filter: dict = {"kinds": [1059], "#p": public_keys}
|
|
||||||
return [gift_wrap_filter]
|
|
||||||
|
|
||||||
def _filters_for_stall_events(self, public_keys: List[str], since: int) -> List:
|
def _filters_for_stall_events(self, public_keys: List[str], since: int) -> List:
|
||||||
stall_filter = {"kinds": [30017], "authors": public_keys}
|
stall_filter = {"kinds": [30017], "authors": public_keys}
|
||||||
|
|
@ -193,21 +177,16 @@ class NostrClient:
|
||||||
|
|
||||||
def _ws_handlers(self):
|
def _ws_handlers(self):
|
||||||
def on_open(_):
|
def on_open(_):
|
||||||
logger.debug("[NOSTRMARKET DEBUG] ✅ Connected to 'nostrclient' websocket successfully")
|
logger.info("Connected to 'nostrclient' websocket")
|
||||||
|
|
||||||
def on_message(_, message):
|
def on_message(_, message):
|
||||||
logger.debug(f"[NOSTRMARKET DEBUG] 📨 Received websocket message: {message[:200]}...")
|
self.recieve_event_queue.put_nowait(message)
|
||||||
try:
|
|
||||||
self.recieve_event_queue.put_nowait(message)
|
|
||||||
logger.debug(f"[NOSTRMARKET DEBUG] 📤 Message queued successfully")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"[NOSTRMARKET] ❌ Failed to queue message: {e}")
|
|
||||||
|
|
||||||
def on_error(_, error):
|
def on_error(_, error):
|
||||||
logger.warning(f"[NOSTRMARKET] ❌ Websocket error: {error}")
|
logger.warning(error)
|
||||||
|
|
||||||
def on_close(x, status_code, message):
|
def on_close(x, status_code, message):
|
||||||
logger.warning(f"[NOSTRMARKET] 🔌 Websocket closed: {x}: '{status_code}' '{message}'")
|
logger.warning(f"Websocket closed: {x}: '{status_code}' '{message}'")
|
||||||
# force re-subscribe
|
# force re-subscribe
|
||||||
self.recieve_event_queue.put_nowait(ValueError("Websocket close."))
|
self.recieve_event_queue.put_nowait(ValueError("Websocket close."))
|
||||||
|
|
||||||
|
|
|
||||||
15
package.json
|
|
@ -1,15 +0,0 @@
|
||||||
{
|
|
||||||
"name": "nostrmarket",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "",
|
|
||||||
"main": "index.js",
|
|
||||||
"scripts": {
|
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
|
||||||
},
|
|
||||||
"author": "",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"prettier": "^3.2.5",
|
|
||||||
"pyright": "^1.1.358"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
[project]
|
|
||||||
name = "nostrmarket"
|
|
||||||
version = "1.1.0"
|
|
||||||
requires-python = ">=3.10,<3.13"
|
|
||||||
description = "LNbits, free and open-source Lightning wallet and accounts system."
|
|
||||||
authors = [{ name = "Alan Bits", email = "alan@lnbits.com" }]
|
|
||||||
urls = { Homepage = "https://lnbits.com", Repository = "https://github.com/lnbits/nostrmarket" }
|
|
||||||
dependencies = [ "lnbits>1" ]
|
|
||||||
|
|
||||||
[tool.poetry]
|
|
||||||
package-mode = false
|
|
||||||
|
|
||||||
[dependency-groups]
|
|
||||||
dev = [
|
|
||||||
"black",
|
|
||||||
"pytest-asyncio",
|
|
||||||
"pytest",
|
|
||||||
"mypy==1.17.1",
|
|
||||||
"pre-commit",
|
|
||||||
"ruff",
|
|
||||||
"pytest-md",
|
|
||||||
"types-cffi",
|
|
||||||
]
|
|
||||||
|
|
||||||
[tool.mypy]
|
|
||||||
exclude = "(nostr/*)"
|
|
||||||
plugins = ["pydantic.mypy"]
|
|
||||||
|
|
||||||
[[tool.mypy.overrides]]
|
|
||||||
module = [
|
|
||||||
"nostr.*",
|
|
||||||
]
|
|
||||||
ignore_missing_imports = "True"
|
|
||||||
|
|
||||||
|
|
||||||
[tool.pydantic-mypy]
|
|
||||||
init_forbid_extra = true
|
|
||||||
init_typed = true
|
|
||||||
warn_required_dynamic_aliases = true
|
|
||||||
warn_untyped_fields = true
|
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
|
||||||
log_cli = false
|
|
||||||
testpaths = [
|
|
||||||
"tests"
|
|
||||||
]
|
|
||||||
|
|
||||||
[tool.black]
|
|
||||||
line-length = 88
|
|
||||||
|
|
||||||
[tool.ruff]
|
|
||||||
# Same as Black. + 10% rule of black
|
|
||||||
line-length = 88
|
|
||||||
exclude = [
|
|
||||||
"nostr",
|
|
||||||
]
|
|
||||||
|
|
||||||
[tool.ruff.lint]
|
|
||||||
# Enable:
|
|
||||||
# F - pyflakes
|
|
||||||
# E - pycodestyle errors
|
|
||||||
# W - pycodestyle warnings
|
|
||||||
# I - isort
|
|
||||||
# A - flake8-builtins
|
|
||||||
# C - mccabe
|
|
||||||
# N - naming
|
|
||||||
# UP - pyupgrade
|
|
||||||
# RUF - ruff
|
|
||||||
# B - bugbear
|
|
||||||
select = ["F", "E", "W", "I", "A", "C", "N", "UP", "RUF", "B"]
|
|
||||||
ignore = ["C901"]
|
|
||||||
|
|
||||||
# Allow autofix for all enabled rules (when `--fix`) is provided.
|
|
||||||
fixable = ["ALL"]
|
|
||||||
unfixable = []
|
|
||||||
|
|
||||||
# Allow unused variables when underscore-prefixed.
|
|
||||||
dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
|
|
||||||
|
|
||||||
# needed for pydantic
|
|
||||||
[tool.ruff.lint.pep8-naming]
|
|
||||||
classmethod-decorators = [
|
|
||||||
"root_validator",
|
|
||||||
]
|
|
||||||
|
|
||||||
# Ignore unused imports in __init__.py files.
|
|
||||||
# [tool.ruff.lint.extend-per-file-ignores]
|
|
||||||
# "__init__.py" = ["F401", "F403"]
|
|
||||||
|
|
||||||
# [tool.ruff.lint.mccabe]
|
|
||||||
# max-complexity = 10
|
|
||||||
|
|
||||||
[tool.ruff.lint.flake8-bugbear]
|
|
||||||
# Allow default arguments like, e.g., `data: List[str] = fastapi.Query(None)`.
|
|
||||||
extend-immutable-calls = [
|
|
||||||
"fastapi.Depends",
|
|
||||||
"fastapi.Query",
|
|
||||||
]
|
|
||||||
433
services.py
|
|
@ -2,23 +2,19 @@ import asyncio
|
||||||
import json
|
import json
|
||||||
from typing import List, Optional, Tuple
|
from typing import List, Optional, Tuple
|
||||||
|
|
||||||
from lnbits.bolt11 import decode
|
|
||||||
from lnbits.core.crud import get_account, get_wallet
|
|
||||||
from lnbits.core.services import create_invoice, websocket_updater
|
|
||||||
from lnbits.core.signers import resolve_signer
|
|
||||||
from lnbits.core.signers.base import NostrSigner, SignerError
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
from lnbits.bolt11 import decode
|
||||||
|
from lnbits.core.services import websocket_updater, create_invoice, get_wallet
|
||||||
|
|
||||||
from . import nostr_client
|
from . import nostr_client
|
||||||
from .crud import (
|
from .crud import (
|
||||||
CustomerProfile,
|
CustomerProfile,
|
||||||
create_customer,
|
create_customer,
|
||||||
create_direct_message,
|
create_direct_message,
|
||||||
create_merchant,
|
|
||||||
create_order,
|
create_order,
|
||||||
create_product,
|
create_product,
|
||||||
create_stall,
|
create_stall,
|
||||||
create_zone,
|
|
||||||
get_customer,
|
get_customer,
|
||||||
get_last_direct_messages_created_at,
|
get_last_direct_messages_created_at,
|
||||||
get_last_product_update_time,
|
get_last_product_update_time,
|
||||||
|
|
@ -46,7 +42,6 @@ from .models import (
|
||||||
DirectMessage,
|
DirectMessage,
|
||||||
DirectMessageType,
|
DirectMessageType,
|
||||||
Merchant,
|
Merchant,
|
||||||
MerchantConfig,
|
|
||||||
Nostrable,
|
Nostrable,
|
||||||
Order,
|
Order,
|
||||||
OrderContact,
|
OrderContact,
|
||||||
|
|
@ -54,16 +49,13 @@ from .models import (
|
||||||
OrderItem,
|
OrderItem,
|
||||||
OrderStatusUpdate,
|
OrderStatusUpdate,
|
||||||
PartialDirectMessage,
|
PartialDirectMessage,
|
||||||
PartialMerchant,
|
|
||||||
PartialOrder,
|
PartialOrder,
|
||||||
PaymentOption,
|
PaymentOption,
|
||||||
PaymentRequest,
|
PaymentRequest,
|
||||||
Product,
|
Product,
|
||||||
Stall,
|
Stall,
|
||||||
Zone,
|
|
||||||
)
|
)
|
||||||
from .nostr.event import NostrEvent
|
from .nostr.event import NostrEvent
|
||||||
from .nostr.nip59 import unwrap_message, wrap_message
|
|
||||||
|
|
||||||
|
|
||||||
async def create_new_order(
|
async def create_new_order(
|
||||||
|
|
@ -72,8 +64,7 @@ async def create_new_order(
|
||||||
merchant = await get_merchant_by_pubkey(merchant_public_key)
|
merchant = await get_merchant_by_pubkey(merchant_public_key)
|
||||||
assert merchant, "Cannot find merchant for order!"
|
assert merchant, "Cannot find merchant for order!"
|
||||||
|
|
||||||
existing_order = await get_order(merchant.id, data.id)
|
if await get_order(merchant.id, data.id):
|
||||||
if existing_order:
|
|
||||||
return None
|
return None
|
||||||
if data.event_id and await get_order_by_event_id(merchant.id, data.event_id):
|
if data.event_id and await get_order_by_event_id(merchant.id, data.event_id):
|
||||||
return None
|
return None
|
||||||
|
|
@ -83,33 +74,27 @@ async def create_new_order(
|
||||||
)
|
)
|
||||||
await create_order(merchant.id, order)
|
await create_order(merchant.id, order)
|
||||||
|
|
||||||
payment_request = PaymentRequest(
|
return PaymentRequest(
|
||||||
id=data.id,
|
id=data.id, payment_options=[PaymentOption(type="ln", link=invoice)], message=receipt
|
||||||
payment_options=[PaymentOption(type="ln", link=invoice)],
|
|
||||||
message=receipt,
|
|
||||||
)
|
)
|
||||||
return payment_request
|
|
||||||
|
|
||||||
|
|
||||||
async def build_order_with_payment(
|
async def build_order_with_payment(
|
||||||
merchant_id: str, merchant_public_key: str, data: PartialOrder
|
merchant_id: str, merchant_public_key: str, data: PartialOrder
|
||||||
):
|
):
|
||||||
|
|
||||||
products = await get_products_by_ids(
|
products = await get_products_by_ids(
|
||||||
merchant_id, [p.product_id for p in data.items]
|
merchant_id, [p.product_id for p in data.items]
|
||||||
)
|
)
|
||||||
|
|
||||||
data.validate_order_items(products)
|
data.validate_order_items(products)
|
||||||
|
|
||||||
shipping_zone = await get_zone(merchant_id, data.shipping_id)
|
shipping_zone = await get_zone(merchant_id, data.shipping_id)
|
||||||
assert shipping_zone, f"Shipping zone not found for order '{data.id}'"
|
assert shipping_zone, f"Shipping zone not found for order '{data.id}'"
|
||||||
|
|
||||||
assert shipping_zone.id
|
|
||||||
product_cost_sat, shipping_cost_sat = await data.costs_in_sats(
|
product_cost_sat, shipping_cost_sat = await data.costs_in_sats(
|
||||||
products, shipping_zone.id, shipping_zone.cost
|
products, shipping_zone.id, shipping_zone.cost
|
||||||
)
|
)
|
||||||
|
receipt = data.receipt(
|
||||||
receipt = data.receipt(products, shipping_zone.id, shipping_zone.cost)
|
products, shipping_zone.id, shipping_zone.cost
|
||||||
|
)
|
||||||
|
|
||||||
wallet_id = await get_wallet_for_product(data.items[0].product_id)
|
wallet_id = await get_wallet_for_product(data.items[0].product_id)
|
||||||
assert wallet_id, "Missing wallet for order `{data.id}`"
|
assert wallet_id, "Missing wallet for order `{data.id}`"
|
||||||
|
|
@ -119,13 +104,11 @@ async def build_order_with_payment(
|
||||||
merchant_id, product_ids, data.items
|
merchant_id, product_ids, data.items
|
||||||
)
|
)
|
||||||
if not success:
|
if not success:
|
||||||
logger.error(f"[NOSTRMARKET] ❌ Product quantity check failed: {message}")
|
|
||||||
raise ValueError(message)
|
raise ValueError(message)
|
||||||
|
|
||||||
total_amount_sat = round(product_cost_sat + shipping_cost_sat)
|
payment_hash, invoice = await create_invoice(
|
||||||
payment = await create_invoice(
|
|
||||||
wallet_id=wallet_id,
|
wallet_id=wallet_id,
|
||||||
amount=total_amount_sat,
|
amount=round(product_cost_sat + shipping_cost_sat),
|
||||||
memo=f"Order '{data.id}' for pubkey '{data.public_key}'",
|
memo=f"Order '{data.id}' for pubkey '{data.public_key}'",
|
||||||
extra={
|
extra={
|
||||||
"tag": "nostrmarket",
|
"tag": "nostrmarket",
|
||||||
|
|
@ -141,21 +124,19 @@ async def build_order_with_payment(
|
||||||
order = Order(
|
order = Order(
|
||||||
**data.dict(),
|
**data.dict(),
|
||||||
stall_id=products[0].stall_id,
|
stall_id=products[0].stall_id,
|
||||||
invoice_id=payment.payment_hash,
|
invoice_id=payment_hash,
|
||||||
total=product_cost_sat + shipping_cost_sat,
|
total=product_cost_sat + shipping_cost_sat,
|
||||||
extra=extra,
|
extra=extra,
|
||||||
)
|
)
|
||||||
|
|
||||||
return order, payment.bolt11, receipt
|
return order, invoice, receipt
|
||||||
|
|
||||||
|
|
||||||
async def update_merchant_to_nostr(
|
async def update_merchant_to_nostr(
|
||||||
merchant: Merchant, delete_merchant=False
|
merchant: Merchant, delete_merchant=False
|
||||||
) -> Merchant:
|
) -> Merchant:
|
||||||
stalls = await get_stalls(merchant.id)
|
stalls = await get_stalls(merchant.id)
|
||||||
event: Optional[NostrEvent] = None
|
|
||||||
for stall in stalls:
|
for stall in stalls:
|
||||||
assert stall.id
|
|
||||||
products = await get_products(merchant.id, stall.id)
|
products = await get_products(merchant.id, stall.id)
|
||||||
for product in products:
|
for product in products:
|
||||||
event = await sign_and_send_to_nostr(merchant, product, delete_merchant)
|
event = await sign_and_send_to_nostr(merchant, product, delete_merchant)
|
||||||
|
|
@ -166,180 +147,27 @@ async def update_merchant_to_nostr(
|
||||||
stall.event_id = event.id
|
stall.event_id = event.id
|
||||||
stall.event_created_at = event.created_at
|
stall.event_created_at = event.created_at
|
||||||
await update_stall(merchant.id, stall)
|
await update_stall(merchant.id, stall)
|
||||||
# Always publish merchant profile (kind 0)
|
if delete_merchant:
|
||||||
event = await sign_and_send_to_nostr(merchant, merchant, delete_merchant)
|
# merchant profile updates not supported yet
|
||||||
assert event
|
event = await sign_and_send_to_nostr(merchant, merchant, delete_merchant)
|
||||||
merchant.config.event_id = event.id
|
merchant.config.event_id = event.id
|
||||||
return merchant
|
return merchant
|
||||||
|
|
||||||
|
|
||||||
async def _resolve_merchant_signer(merchant: Merchant) -> NostrSigner:
|
|
||||||
"""Resolve the lnbits NostrSigner for a merchant's owning account.
|
|
||||||
|
|
||||||
Post-#5 (aiolabs/nostrmarket#5), the merchant's nsec lives in the
|
|
||||||
bunker via the account's `signer_config`. No fast-path or caching
|
|
||||||
today — per-call lookup is fine for v1 throughput; if the events
|
|
||||||
extension or DM hot path becomes contended, revisit with a
|
|
||||||
process-local cache keyed on `merchant.user_id`.
|
|
||||||
|
|
||||||
Raises `SignerError` if the account can't be found or its signer
|
|
||||||
can't be resolved — callers should propagate, not silently skip,
|
|
||||||
so misconfigured rows surface loudly.
|
|
||||||
"""
|
|
||||||
account = await get_account(merchant.user_id)
|
|
||||||
if account is None:
|
|
||||||
raise SignerError(
|
|
||||||
f"merchant {merchant.id[:8]} references missing account "
|
|
||||||
f"{merchant.user_id[:8]} — can't resolve signer"
|
|
||||||
)
|
|
||||||
return resolve_signer(account)
|
|
||||||
|
|
||||||
|
|
||||||
async def sign_and_send_to_nostr(
|
async def sign_and_send_to_nostr(
|
||||||
merchant: Merchant, n: Nostrable, delete=False
|
merchant: Merchant, n: Nostrable, delete=False
|
||||||
) -> NostrEvent:
|
) -> NostrEvent:
|
||||||
"""Sign + publish a Nostrable as the merchant's identity.
|
|
||||||
|
|
||||||
Signing routes through the merchant's account `NostrSigner` (post-#5).
|
|
||||||
The signer fills `id` + `sig` server-side (bunker for the
|
|
||||||
`RemoteBunkerSigner` case) — this function builds the unsigned dict
|
|
||||||
shape, hands it to the signer, and copies the result back onto the
|
|
||||||
`NostrEvent` instance for the publisher.
|
|
||||||
"""
|
|
||||||
event = (
|
event = (
|
||||||
n.to_nostr_delete_event(merchant.public_key)
|
n.to_nostr_delete_event(merchant.public_key)
|
||||||
if delete
|
if delete
|
||||||
else n.to_nostr_event(merchant.public_key)
|
else n.to_nostr_event(merchant.public_key)
|
||||||
)
|
)
|
||||||
|
event.sig = merchant.sign_hash(bytes.fromhex(event.id))
|
||||||
signer = await _resolve_merchant_signer(merchant)
|
|
||||||
signed = await signer.sign_event(
|
|
||||||
{
|
|
||||||
"pubkey": event.pubkey,
|
|
||||||
"created_at": event.created_at,
|
|
||||||
"kind": event.kind,
|
|
||||||
"tags": event.tags,
|
|
||||||
"content": event.content,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
event.id = signed["id"]
|
|
||||||
event.sig = signed["sig"]
|
|
||||||
await nostr_client.publish_nostr_event(event)
|
await nostr_client.publish_nostr_event(event)
|
||||||
|
|
||||||
return event
|
return event
|
||||||
|
|
||||||
|
|
||||||
async def provision_merchant(
|
|
||||||
user_id: str,
|
|
||||||
wallet_id: str,
|
|
||||||
public_key: str,
|
|
||||||
display_name: Optional[str] = None,
|
|
||||||
config: Optional[MerchantConfig] = None,
|
|
||||||
) -> Merchant:
|
|
||||||
"""
|
|
||||||
Provision a merchant with a default shipping zone and default stall,
|
|
||||||
and publish the stall to Nostr relays.
|
|
||||||
|
|
||||||
Post-aiolabs/nostrmarket#5: no `private_key` argument. The merchant
|
|
||||||
identity IS the lnbits account's identity (`public_key` parameter
|
|
||||||
must equal `account.pubkey` for the same `user_id`); signing routes
|
|
||||||
through the account's `NostrSigner` (`RemoteBunkerSigner` in the
|
|
||||||
target deployment). The merchant nsec lives in the bunker, never
|
|
||||||
server-side.
|
|
||||||
|
|
||||||
Single source of truth used by:
|
|
||||||
- LNbits user-creation hook (eager, on signup) — see
|
|
||||||
lnbits/core/services/users.py:_create_default_merchant
|
|
||||||
- nostrmarket views_api._auto_create_merchant (lazy, on first GET
|
|
||||||
/api/v1/merchant when a merchant is missing).
|
|
||||||
|
|
||||||
Idempotent on the merchant: if a merchant with this pubkey already
|
|
||||||
exists, returns it without recreating zone/stall.
|
|
||||||
"""
|
|
||||||
existing = await get_merchant_by_pubkey(public_key)
|
|
||||||
if existing:
|
|
||||||
return existing
|
|
||||||
|
|
||||||
partial_merchant = PartialMerchant(
|
|
||||||
public_key=public_key,
|
|
||||||
config=config or MerchantConfig(),
|
|
||||||
)
|
|
||||||
merchant = await create_merchant(user_id, partial_merchant)
|
|
||||||
|
|
||||||
online_zone = Zone(
|
|
||||||
id=f"online-{merchant.public_key}",
|
|
||||||
name="Online",
|
|
||||||
currency="sat",
|
|
||||||
cost=0,
|
|
||||||
countries=["Free (digital)"],
|
|
||||||
)
|
|
||||||
await create_zone(merchant.id, online_zone)
|
|
||||||
|
|
||||||
raw_owner_name = display_name or "My"
|
|
||||||
owner_name = raw_owner_name[:1].upper() + raw_owner_name[1:]
|
|
||||||
default_stall = Stall(
|
|
||||||
wallet=wallet_id,
|
|
||||||
name=f"{owner_name}'s Store",
|
|
||||||
currency="sat",
|
|
||||||
shipping_zones=[online_zone],
|
|
||||||
)
|
|
||||||
default_stall = await create_stall(merchant.id, default_stall)
|
|
||||||
|
|
||||||
# Publish the kind 30017 stall event so customers' clients can resolve
|
|
||||||
# the stall name when they fetch products. Non-fatal on failure: a
|
|
||||||
# later product publish (or webapp self-heal) will retry.
|
|
||||||
#
|
|
||||||
# Fire-and-forget: `nostr_client.publish_nostr_event` has no per-relay
|
|
||||||
# deadline and will block indefinitely if every configured relay is
|
|
||||||
# unreachable (cf. aiolabs/nostrmarket#7). When `provision_merchant`
|
|
||||||
# is called from the eager signup hook (lnbits/core/services/users.py
|
|
||||||
# ::_create_default_merchant, aiolabs/lnbits#46), inline-awaiting that
|
|
||||||
# publish hangs the uvicorn worker on `POST /auth/register` forever.
|
|
||||||
# The DB rows we just wrote are sufficient to serve the wallet UI;
|
|
||||||
# the stall event_id gets backfilled when the publish completes (or
|
|
||||||
# stays NULL until a later resubscribe-driven republish lands it).
|
|
||||||
asyncio.create_task(
|
|
||||||
_publish_default_stall_background(merchant.id, merchant, default_stall)
|
|
||||||
)
|
|
||||||
|
|
||||||
return merchant
|
|
||||||
|
|
||||||
|
|
||||||
# Generous bound: signing through the bunker can take 1–2 s on a cold
|
|
||||||
# session, plus the relay publish itself. 30 s is well over both, and
|
|
||||||
# the cap matters only when the relay set is unreachable.
|
|
||||||
STALL_PUBLISH_TIMEOUT_S = 30.0
|
|
||||||
|
|
||||||
|
|
||||||
async def _publish_default_stall_background(
|
|
||||||
merchant_id: str, merchant: Merchant, default_stall: Stall
|
|
||||||
) -> None:
|
|
||||||
"""Background helper for `provision_merchant`'s default-stall publish.
|
|
||||||
|
|
||||||
Bounded by `STALL_PUBLISH_TIMEOUT_S` so even a permanently-unreachable
|
|
||||||
relay set doesn't pin an asyncio task forever. Errors and timeouts are
|
|
||||||
logged at warning — never raised, since the caller scheduled-and-forgot.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
stall_event = await asyncio.wait_for(
|
|
||||||
sign_and_send_to_nostr(merchant, default_stall),
|
|
||||||
timeout=STALL_PUBLISH_TIMEOUT_S,
|
|
||||||
)
|
|
||||||
default_stall.event_id = stall_event.id
|
|
||||||
await update_stall(merchant_id, default_stall)
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
logger.warning(
|
|
||||||
f"[NOSTRMARKET] Default stall publish for merchant "
|
|
||||||
f"{merchant_id} timed out after {STALL_PUBLISH_TIMEOUT_S}s; "
|
|
||||||
f"event_id stays NULL until a later republish lands it"
|
|
||||||
)
|
|
||||||
except Exception as ex:
|
|
||||||
logger.warning(
|
|
||||||
f"[NOSTRMARKET] Failed to publish default stall for "
|
|
||||||
f"merchant {merchant_id}: {ex}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def handle_order_paid(order_id: str, merchant_pubkey: str):
|
async def handle_order_paid(order_id: str, merchant_pubkey: str):
|
||||||
try:
|
try:
|
||||||
order = await update_order_paid_status(order_id, True)
|
order = await update_order_paid_status(order_id, True)
|
||||||
|
|
@ -399,29 +227,25 @@ async def update_products_for_order(
|
||||||
return success, message
|
return success, message
|
||||||
|
|
||||||
for p in products:
|
for p in products:
|
||||||
assert p.id
|
|
||||||
product = await update_product_quantity(p.id, p.quantity)
|
product = await update_product_quantity(p.id, p.quantity)
|
||||||
assert product
|
|
||||||
event = await sign_and_send_to_nostr(merchant, product)
|
event = await sign_and_send_to_nostr(merchant, product)
|
||||||
product.event_id = event.id
|
product.event_id = event.id
|
||||||
await update_product(merchant.id, product)
|
await update_product(merchant.id, product)
|
||||||
|
|
||||||
return True, "ok"
|
return True, "ok"
|
||||||
|
|
||||||
|
async def autoreply_for_products_in_order(
|
||||||
async def autoreply_for_products_in_order(merchant: Merchant, order: Order):
|
merchant: Merchant, order: Order
|
||||||
|
) -> Tuple[bool, str]:
|
||||||
product_ids = [i.product_id for i in order.items]
|
product_ids = [i.product_id for i in order.items]
|
||||||
|
|
||||||
products = await get_products_by_ids(merchant.id, product_ids)
|
products = await get_products_by_ids(merchant.id, product_ids)
|
||||||
products_with_autoreply = [p for p in products if p.config.use_autoreply]
|
products_with_autoreply = [p for p in products if p.config.use_autoreply]
|
||||||
|
|
||||||
for p in products_with_autoreply:
|
for p in products_with_autoreply:
|
||||||
dm_content = p.config.autoreply_message or ""
|
dm_content = p.config.autoreply_message
|
||||||
await send_dm(
|
await send_dm(
|
||||||
merchant,
|
merchant, order.public_key, DirectMessageType.PLAIN_TEXT.value, dm_content
|
||||||
order.public_key,
|
|
||||||
DirectMessageType.PLAIN_TEXT.value,
|
|
||||||
dm_content,
|
|
||||||
)
|
)
|
||||||
await asyncio.sleep(1) # do not send all autoreplies at once
|
await asyncio.sleep(1) # do not send all autoreplies at once
|
||||||
|
|
||||||
|
|
@ -429,39 +253,21 @@ async def autoreply_for_products_in_order(merchant: Merchant, order: Order):
|
||||||
async def send_dm(
|
async def send_dm(
|
||||||
merchant: Merchant,
|
merchant: Merchant,
|
||||||
other_pubkey: str,
|
other_pubkey: str,
|
||||||
type_: int,
|
type: str,
|
||||||
dm_content: str,
|
dm_content: str,
|
||||||
) -> DirectMessage:
|
):
|
||||||
# Post-#5: nsec stays in the bunker; both the to-recipient wrap and
|
dm_event = merchant.build_dm_event(dm_content, other_pubkey)
|
||||||
# the to-self archival wrap route their seal-layer crypto through
|
|
||||||
# the merchant's NostrSigner.
|
|
||||||
signer = await _resolve_merchant_signer(merchant)
|
|
||||||
|
|
||||||
# Wrap message to recipient via NIP-59 gift wrap
|
|
||||||
gift_wrap = await wrap_message(
|
|
||||||
dm_content,
|
|
||||||
signer,
|
|
||||||
other_pubkey,
|
|
||||||
)
|
|
||||||
|
|
||||||
dm = PartialDirectMessage(
|
dm = PartialDirectMessage(
|
||||||
event_id=gift_wrap.id,
|
event_id=dm_event.id,
|
||||||
event_created_at=gift_wrap.created_at,
|
event_created_at=dm_event.created_at,
|
||||||
message=dm_content,
|
message=dm_content,
|
||||||
public_key=other_pubkey,
|
public_key=other_pubkey,
|
||||||
type=type_,
|
type=type,
|
||||||
)
|
)
|
||||||
dm_reply = await create_direct_message(merchant.id, dm)
|
dm_reply = await create_direct_message(merchant.id, dm)
|
||||||
|
|
||||||
await nostr_client.publish_nostr_event(gift_wrap)
|
await nostr_client.publish_nostr_event(dm_event)
|
||||||
|
|
||||||
# Also wrap a copy to self for archival
|
|
||||||
self_wrap = await wrap_message(
|
|
||||||
dm_content,
|
|
||||||
signer,
|
|
||||||
merchant.public_key,
|
|
||||||
)
|
|
||||||
await nostr_client.publish_nostr_event(self_wrap)
|
|
||||||
|
|
||||||
await websocket_updater(
|
await websocket_updater(
|
||||||
merchant.id,
|
merchant.id,
|
||||||
|
|
@ -474,8 +280,6 @@ async def send_dm(
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
return dm_reply
|
|
||||||
|
|
||||||
|
|
||||||
async def compute_products_new_quantity(
|
async def compute_products_new_quantity(
|
||||||
merchant_id: str, product_ids: List[str], items: List[OrderItem]
|
merchant_id: str, product_ids: List[str], items: List[OrderItem]
|
||||||
|
|
@ -492,8 +296,7 @@ async def compute_products_new_quantity(
|
||||||
return (
|
return (
|
||||||
False,
|
False,
|
||||||
[],
|
[],
|
||||||
f"Quantity not sufficient for product: '{p.name}' ({p.id})."
|
f"Quantity not sufficient for product: '{p.name}' ({p.id}). Required '{required_quantity}' but only have '{p.quantity}'.",
|
||||||
f" Required '{required_quantity}' but only have '{p.quantity}'.",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
p.quantity -= required_quantity
|
p.quantity -= required_quantity
|
||||||
|
|
@ -503,48 +306,33 @@ async def compute_products_new_quantity(
|
||||||
|
|
||||||
async def process_nostr_message(msg: str):
|
async def process_nostr_message(msg: str):
|
||||||
try:
|
try:
|
||||||
parsed_msg = json.loads(msg)
|
type, *rest = json.loads(msg)
|
||||||
type_, *rest = parsed_msg
|
|
||||||
|
|
||||||
|
if type.upper() == "EVENT":
|
||||||
if type_.upper() == "EVENT":
|
|
||||||
if len(rest) < 2:
|
|
||||||
logger.warning(f"[NOSTRMARKET] ⚠️ EVENT message missing data: {rest}")
|
|
||||||
return
|
|
||||||
_, event = rest
|
_, event = rest
|
||||||
event = NostrEvent(**event)
|
event = NostrEvent(**event)
|
||||||
|
|
||||||
# Deduplicate events (overlap resubscriptions may deliver duplicates)
|
|
||||||
if nostr_client.is_duplicate_event(event.id):
|
|
||||||
return
|
|
||||||
|
|
||||||
if event.kind == 0:
|
if event.kind == 0:
|
||||||
await _handle_customer_profile_update(event)
|
await _handle_customer_profile_update(event)
|
||||||
elif event.kind == 1059:
|
elif event.kind == 4:
|
||||||
await _handle_gift_wrap(event)
|
await _handle_nip04_message(event)
|
||||||
elif event.kind == 30017:
|
elif event.kind == 30017:
|
||||||
await _handle_stall(event)
|
await _handle_stall(event)
|
||||||
elif event.kind == 30018:
|
elif event.kind == 30018:
|
||||||
await _handle_product(event)
|
await _handle_product(event)
|
||||||
else:
|
|
||||||
logger.info(f"[NOSTRMARKET] ❓ Unhandled event kind: {event.kind} - event: {event.id}")
|
|
||||||
return
|
return
|
||||||
else:
|
|
||||||
logger.info(f"[NOSTRMARKET] 🔄 Non-EVENT message type: {type_}")
|
|
||||||
|
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
logger.error(f"[NOSTRMARKET] ❌ Error processing nostr message: {ex}")
|
logger.debug(ex)
|
||||||
logger.error(f"[NOSTRMARKET] 📄 Raw message that failed: {msg}")
|
|
||||||
|
|
||||||
|
|
||||||
async def create_or_update_order_from_dm(
|
async def create_or_update_order_from_dm(
|
||||||
merchant_id: str, merchant_pubkey: str, dm: DirectMessage
|
merchant_id: str, merchant_pubkey: str, dm: DirectMessage
|
||||||
):
|
):
|
||||||
type_, json_data = PartialDirectMessage.parse_message(dm.message)
|
type, json_data = PartialDirectMessage.parse_message(dm.message)
|
||||||
if not json_data or "id" not in json_data:
|
if "id" not in json_data:
|
||||||
return
|
return
|
||||||
|
|
||||||
if type_ == DirectMessageType.CUSTOMER_ORDER:
|
if type == DirectMessageType.CUSTOMER_ORDER:
|
||||||
order = await extract_customer_order_from_dm(
|
order = await extract_customer_order_from_dm(
|
||||||
merchant_id, merchant_pubkey, dm, json_data
|
merchant_id, merchant_pubkey, dm, json_data
|
||||||
)
|
)
|
||||||
|
|
@ -560,7 +348,7 @@ async def create_or_update_order_from_dm(
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
if type_ == DirectMessageType.PAYMENT_REQUEST:
|
if type == DirectMessageType.PAYMENT_REQUEST:
|
||||||
payment_request = PaymentRequest(**json_data)
|
payment_request = PaymentRequest(**json_data)
|
||||||
pr = next(
|
pr = next(
|
||||||
(o.link for o in payment_request.payment_options if o.type == "ln"), None
|
(o.link for o in payment_request.payment_options if o.type == "ln"), None
|
||||||
|
|
@ -568,15 +356,14 @@ async def create_or_update_order_from_dm(
|
||||||
if not pr:
|
if not pr:
|
||||||
return
|
return
|
||||||
invoice = decode(pr)
|
invoice = decode(pr)
|
||||||
total = invoice.amount_msat / 1000 if invoice.amount_msat else 0
|
|
||||||
await update_order(
|
await update_order(
|
||||||
merchant_id,
|
merchant_id,
|
||||||
payment_request.id,
|
payment_request.id,
|
||||||
**{"total": total, "invoice_id": invoice.payment_hash},
|
**{"total": invoice.amount_msat / 1000, "invoice_id": invoice.payment_hash},
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
if type_ == DirectMessageType.ORDER_PAID_OR_SHIPPED:
|
if type == DirectMessageType.ORDER_PAID_OR_SHIPPED:
|
||||||
order_update = OrderStatusUpdate(**json_data)
|
order_update = OrderStatusUpdate(**json_data)
|
||||||
if order_update.paid:
|
if order_update.paid:
|
||||||
await update_order_paid_status(order_update.id, True)
|
await update_order_paid_status(order_update.id, True)
|
||||||
|
|
@ -593,18 +380,16 @@ async def extract_customer_order_from_dm(
|
||||||
)
|
)
|
||||||
extra = await OrderExtra.from_products(products)
|
extra = await OrderExtra.from_products(products)
|
||||||
order = Order(
|
order = Order(
|
||||||
id=str(json_data.get("id")),
|
id=json_data.get("id"),
|
||||||
event_id=dm.event_id,
|
event_id=dm.event_id,
|
||||||
event_created_at=dm.event_created_at,
|
event_created_at=dm.event_created_at,
|
||||||
public_key=dm.public_key,
|
public_key=dm.public_key,
|
||||||
merchant_public_key=merchant_pubkey,
|
merchant_public_key=merchant_pubkey,
|
||||||
shipping_id=json_data.get("shipping_id", "None"),
|
shipping_id=json_data.get("shipping_id", "None"),
|
||||||
items=order_items,
|
items=order_items,
|
||||||
contact=(
|
contact=OrderContact(**json_data.get("contact"))
|
||||||
OrderContact(**json_data.get("contact", {}))
|
if json_data.get("contact")
|
||||||
if json_data.get("contact")
|
else None,
|
||||||
else None
|
|
||||||
),
|
|
||||||
address=json_data.get("address"),
|
address=json_data.get("address"),
|
||||||
stall_id=products[0].stall_id if len(products) else "None",
|
stall_id=products[0].stall_id if len(products) else "None",
|
||||||
invoice_id="None",
|
invoice_id="None",
|
||||||
|
|
@ -615,42 +400,32 @@ async def extract_customer_order_from_dm(
|
||||||
return order
|
return order
|
||||||
|
|
||||||
|
|
||||||
async def _handle_gift_wrap(event: NostrEvent):
|
async def _handle_nip04_message(event: NostrEvent):
|
||||||
"""Handle an incoming kind 1059 gift wrap event (NIP-59/NIP-17)."""
|
merchant_public_key = event.pubkey
|
||||||
|
merchant = await get_merchant_by_pubkey(merchant_public_key)
|
||||||
|
|
||||||
p_tags = event.tag_values("p")
|
|
||||||
if not p_tags:
|
|
||||||
logger.warning(f"[NOSTRMARKET] ⚠️ Gift wrap has no p-tag: {event.id}")
|
|
||||||
return
|
|
||||||
|
|
||||||
# The p-tag identifies the recipient of the gift wrap
|
|
||||||
recipient_pubkey = p_tags[0]
|
|
||||||
merchant = await get_merchant_by_pubkey(recipient_pubkey)
|
|
||||||
if not merchant:
|
if not merchant:
|
||||||
logger.warning(
|
p_tags = event.tag_values("p")
|
||||||
f"[NOSTRMARKET] ⚠️ No merchant found for gift wrap recipient: {recipient_pubkey}"
|
merchant_public_key = p_tags[0] if len(p_tags) else None
|
||||||
|
merchant = (
|
||||||
|
await get_merchant_by_pubkey(merchant_public_key)
|
||||||
|
if merchant_public_key
|
||||||
|
else None
|
||||||
)
|
)
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
assert merchant, f"Merchant not found for public key '{merchant_public_key}'"
|
||||||
recipient_signer = await _resolve_merchant_signer(merchant)
|
|
||||||
rumor = await unwrap_message(event, recipient_signer)
|
|
||||||
except Exception as ex:
|
|
||||||
logger.error(f"[NOSTRMARKET] ❌ Failed to unwrap gift wrap {event.id}: {ex}")
|
|
||||||
return
|
|
||||||
|
|
||||||
sender_pubkey = rumor.pubkey
|
if event.pubkey == merchant_public_key:
|
||||||
|
assert len(event.tag_values("p")) != 0, "Outgong message has no 'p' tag"
|
||||||
if sender_pubkey == merchant.public_key:
|
clear_text_msg = merchant.decrypt_message(
|
||||||
# This is a self-addressed wrap (outgoing message archive)
|
event.content, event.tag_values("p")[0]
|
||||||
# Extract the actual recipient from the rumor's p-tags
|
)
|
||||||
rumor_p_tags = rumor.tag_values("p")
|
await _handle_outgoing_dms(event, merchant, clear_text_msg)
|
||||||
if rumor_p_tags:
|
elif event.has_tag_value("p", merchant_public_key):
|
||||||
await _handle_outgoing_dms(rumor, merchant, rumor.content)
|
clear_text_msg = merchant.decrypt_message(event.content, event.pubkey)
|
||||||
return
|
await _handle_incoming_dms(event, merchant, clear_text_msg)
|
||||||
|
else:
|
||||||
# Incoming message from a customer
|
logger.warning(f"Bad NIP04 event: '{event.id}'")
|
||||||
await _handle_incoming_dms(rumor, merchant, rumor.content)
|
|
||||||
|
|
||||||
|
|
||||||
async def _handle_incoming_dms(
|
async def _handle_incoming_dms(
|
||||||
|
|
@ -686,34 +461,33 @@ async def _handle_outgoing_dms(
|
||||||
event: NostrEvent, merchant: Merchant, clear_text_msg: str
|
event: NostrEvent, merchant: Merchant, clear_text_msg: str
|
||||||
):
|
):
|
||||||
sent_to = event.tag_values("p")
|
sent_to = event.tag_values("p")
|
||||||
type_, _ = PartialDirectMessage.parse_message(clear_text_msg)
|
type, _ = PartialDirectMessage.parse_message(clear_text_msg)
|
||||||
if len(sent_to) != 0:
|
if len(sent_to) != 0:
|
||||||
dm = PartialDirectMessage(
|
dm = PartialDirectMessage(
|
||||||
event_id=event.id,
|
event_id=event.id,
|
||||||
event_created_at=event.created_at,
|
event_created_at=event.created_at,
|
||||||
message=clear_text_msg,
|
message=clear_text_msg,
|
||||||
public_key=sent_to[0],
|
public_key=sent_to[0],
|
||||||
type=type_.value,
|
type=type.value,
|
||||||
)
|
)
|
||||||
await create_direct_message(merchant.id, dm)
|
await create_direct_message(merchant.id, dm)
|
||||||
|
|
||||||
|
|
||||||
async def _handle_incoming_structured_dm(
|
async def _handle_incoming_structured_dm(
|
||||||
merchant: Merchant, dm: DirectMessage, json_data: dict
|
merchant: Merchant, dm: DirectMessage, json_data: dict
|
||||||
) -> Tuple[DirectMessageType, Optional[str]]:
|
) -> Tuple[DirectMessageType, str]:
|
||||||
try:
|
try:
|
||||||
if dm.type == DirectMessageType.CUSTOMER_ORDER.value and merchant.config.active:
|
if dm.type == DirectMessageType.CUSTOMER_ORDER.value and merchant.config.active:
|
||||||
json_resp = await _handle_new_order(
|
json_resp = await _handle_new_order(
|
||||||
merchant.id, merchant.public_key, dm, json_data
|
merchant.id, merchant.public_key, dm, json_data
|
||||||
)
|
)
|
||||||
|
|
||||||
return DirectMessageType.PAYMENT_REQUEST, json_resp
|
return DirectMessageType.PAYMENT_REQUEST, json_resp
|
||||||
else:
|
|
||||||
logger.info(f"[NOSTRMARKET] Skipping order processing - type: {dm.type}, expected: {DirectMessageType.CUSTOMER_ORDER.value}, merchant_active: {merchant.config.active}")
|
|
||||||
|
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
logger.error(f"[NOSTRMARKET] Error in _handle_incoming_structured_dm: {ex}")
|
logger.warning(ex)
|
||||||
|
|
||||||
return DirectMessageType.PLAIN_TEXT, None
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
async def _persist_dm(
|
async def _persist_dm(
|
||||||
|
|
@ -750,21 +524,16 @@ async def _persist_dm(
|
||||||
async def reply_to_structured_dm(
|
async def reply_to_structured_dm(
|
||||||
merchant: Merchant, customer_pubkey: str, dm_type: int, dm_reply: str
|
merchant: Merchant, customer_pubkey: str, dm_type: int, dm_reply: str
|
||||||
):
|
):
|
||||||
signer = await _resolve_merchant_signer(merchant)
|
dm_event = merchant.build_dm_event(dm_reply, customer_pubkey)
|
||||||
gift_wrap = await wrap_message(
|
|
||||||
dm_reply,
|
|
||||||
signer,
|
|
||||||
customer_pubkey,
|
|
||||||
)
|
|
||||||
dm = PartialDirectMessage(
|
dm = PartialDirectMessage(
|
||||||
event_id=gift_wrap.id,
|
event_id=dm_event.id,
|
||||||
event_created_at=gift_wrap.created_at,
|
event_created_at=dm_event.created_at,
|
||||||
message=dm_reply,
|
message=dm_reply,
|
||||||
public_key=customer_pubkey,
|
public_key=customer_pubkey,
|
||||||
type=dm_type,
|
type=dm_type,
|
||||||
)
|
)
|
||||||
await create_direct_message(merchant.id, dm)
|
await create_direct_message(merchant.id, dm)
|
||||||
await nostr_client.publish_nostr_event(gift_wrap)
|
await nostr_client.publish_nostr_event(dm_event)
|
||||||
|
|
||||||
await websocket_updater(
|
await websocket_updater(
|
||||||
merchant.id,
|
merchant.id,
|
||||||
|
|
@ -797,49 +566,18 @@ async def _handle_new_order(
|
||||||
wallet = await get_wallet(wallet_id)
|
wallet = await get_wallet(wallet_id)
|
||||||
assert wallet, f"Cannot find wallet for product id: {first_product_id}"
|
assert wallet, f"Cannot find wallet for product id: {first_product_id}"
|
||||||
|
|
||||||
|
|
||||||
payment_req = await create_new_order(merchant_public_key, partial_order)
|
payment_req = await create_new_order(merchant_public_key, partial_order)
|
||||||
|
|
||||||
if payment_req is None:
|
|
||||||
# Return existing order data instead of creating a failed order
|
|
||||||
existing_order = await get_order(merchant_id, partial_order.id)
|
|
||||||
if existing_order and existing_order.invoice_id != "None":
|
|
||||||
# Order exists with invoice, return existing payment request
|
|
||||||
duplicate_response = json.dumps({
|
|
||||||
"type": DirectMessageType.PAYMENT_REQUEST.value,
|
|
||||||
"id": existing_order.id,
|
|
||||||
"message": "Order already received and processed",
|
|
||||||
"payment_options": []
|
|
||||||
}, separators=(",", ":"), ensure_ascii=False)
|
|
||||||
return duplicate_response
|
|
||||||
else:
|
|
||||||
# Order exists but no invoice, skip processing
|
|
||||||
logger.info(f"[NOSTRMARKET] Order exists but no invoice, returning empty string")
|
|
||||||
return ""
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[NOSTRMARKET] Error creating order: {e}")
|
logger.debug(e)
|
||||||
logger.error(f"[NOSTRMARKET] Order data: {json_data}")
|
|
||||||
logger.error(f"[NOSTRMARKET] Exception type: {type(e).__name__}")
|
|
||||||
logger.error(f"[NOSTRMARKET] Exception details: {str(e)}")
|
|
||||||
payment_req = await create_new_failed_order(
|
payment_req = await create_new_failed_order(
|
||||||
merchant_id,
|
merchant_id, merchant_public_key, dm, json_data, "Order received, but cannot be processed. Please contact merchant."
|
||||||
merchant_public_key,
|
|
||||||
dm,
|
|
||||||
json_data,
|
|
||||||
"Order received, but cannot be processed. Please contact merchant.",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if not payment_req:
|
|
||||||
logger.error(f"[NOSTRMARKET] No payment request returned for order: {partial_order.id}")
|
|
||||||
return ""
|
|
||||||
|
|
||||||
response = {
|
response = {
|
||||||
"type": DirectMessageType.PAYMENT_REQUEST.value,
|
"type": DirectMessageType.PAYMENT_REQUEST.value,
|
||||||
**payment_req.dict(),
|
**payment_req.dict(),
|
||||||
}
|
}
|
||||||
response_json = json.dumps(response, separators=(",", ":"), ensure_ascii=False)
|
return json.dumps(response, separators=(",", ":"), ensure_ascii=False)
|
||||||
return response_json
|
|
||||||
|
|
||||||
|
|
||||||
async def create_new_failed_order(
|
async def create_new_failed_order(
|
||||||
|
|
@ -856,14 +594,12 @@ async def create_new_failed_order(
|
||||||
await create_order(merchant_id, order)
|
await create_order(merchant_id, order)
|
||||||
return PaymentRequest(id=order.id, message=fail_message, payment_options=[])
|
return PaymentRequest(id=order.id, message=fail_message, payment_options=[])
|
||||||
|
|
||||||
|
|
||||||
async def resubscribe_to_all_merchants():
|
async def resubscribe_to_all_merchants():
|
||||||
await nostr_client.unsubscribe_merchants()
|
await nostr_client.unsubscribe_merchants()
|
||||||
# give some time for the message to propagate
|
# give some time for the message to propagate
|
||||||
await asyncio.sleep(1)
|
asyncio.sleep(1)
|
||||||
await subscribe_to_all_merchants()
|
await subscribe_to_all_merchants()
|
||||||
|
|
||||||
|
|
||||||
async def subscribe_to_all_merchants():
|
async def subscribe_to_all_merchants():
|
||||||
ids = await get_merchants_ids_with_pubkeys()
|
ids = await get_merchants_ids_with_pubkeys()
|
||||||
public_keys = [pk for _, pk in ids]
|
public_keys = [pk for _, pk in ids]
|
||||||
|
|
@ -872,12 +608,7 @@ async def subscribe_to_all_merchants():
|
||||||
last_stall_time = await get_last_stall_update_time()
|
last_stall_time = await get_last_stall_update_time()
|
||||||
last_prod_time = await get_last_product_update_time()
|
last_prod_time = await get_last_product_update_time()
|
||||||
|
|
||||||
# Make dm_time more lenient by subtracting 5 minutes to avoid missing recent events
|
await nostr_client.subscribe_merchants(public_keys, last_dm_time, last_stall_time, last_prod_time, 0)
|
||||||
lenient_dm_time = max(0, last_dm_time - 300) if last_dm_time > 0 else 0
|
|
||||||
|
|
||||||
await nostr_client.subscribe_merchants(
|
|
||||||
public_keys, lenient_dm_time, last_stall_time, last_prod_time, 0
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _handle_new_customer(event: NostrEvent, merchant: Merchant):
|
async def _handle_new_customer(event: NostrEvent, merchant: Merchant):
|
||||||
|
|
|
||||||
|
|
@ -1,173 +0,0 @@
|
||||||
window.app.component('direct-messages', {
|
|
||||||
name: 'direct-messages',
|
|
||||||
props: ['active-chat-customer', 'merchant-id', 'adminkey', 'inkey'],
|
|
||||||
template: '#direct-messages',
|
|
||||||
delimiters: ['${', '}'],
|
|
||||||
watch: {
|
|
||||||
activeChatCustomer: async function (n) {
|
|
||||||
this.activePublicKey = n
|
|
||||||
},
|
|
||||||
activePublicKey: async function (n) {
|
|
||||||
await this.getDirectMessages(n)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
messagesAsJson: function () {
|
|
||||||
return this.messages.map(m => {
|
|
||||||
const dateFrom = moment(m.event_created_at * 1000).fromNow()
|
|
||||||
try {
|
|
||||||
const message = JSON.parse(m.message)
|
|
||||||
return {
|
|
||||||
isJson: message.type >= 0,
|
|
||||||
dateFrom,
|
|
||||||
...m,
|
|
||||||
message
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
isJson: false,
|
|
||||||
dateFrom,
|
|
||||||
...m,
|
|
||||||
message: m.message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
data: function () {
|
|
||||||
return {
|
|
||||||
customers: [],
|
|
||||||
unreadMessages: 0,
|
|
||||||
activePublicKey: null,
|
|
||||||
messages: [],
|
|
||||||
newMessage: '',
|
|
||||||
showAddPublicKey: false,
|
|
||||||
newPublicKey: null,
|
|
||||||
showRawMessage: false,
|
|
||||||
rawMessage: null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
sendMessage: async function () {},
|
|
||||||
buildCustomerLabel: function (c) {
|
|
||||||
if (!c) return ''
|
|
||||||
let label = c.profile.name || 'unknown'
|
|
||||||
if (c.profile.about) {
|
|
||||||
label += ` - ${c.profile.about.substring(0, 30)}`
|
|
||||||
if (c.profile.about.length > 30) label += '...'
|
|
||||||
}
|
|
||||||
if (c.unread_messages) {
|
|
||||||
label = `[${c.unread_messages} new] ${label}`
|
|
||||||
}
|
|
||||||
label += ` (${c.public_key.slice(0, 8)}...${c.public_key.slice(-8)})`
|
|
||||||
return label
|
|
||||||
},
|
|
||||||
getDirectMessages: async function (pubkey) {
|
|
||||||
if (!pubkey) {
|
|
||||||
this.messages = []
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const {data} = await LNbits.api.request(
|
|
||||||
'GET',
|
|
||||||
'/nostrmarket/api/v1/message/' + pubkey,
|
|
||||||
this.inkey
|
|
||||||
)
|
|
||||||
this.messages = data
|
|
||||||
|
|
||||||
this.focusOnChatBox(this.messages.length - 1)
|
|
||||||
} catch (error) {
|
|
||||||
LNbits.utils.notifyApiError(error)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
getCustomers: async function () {
|
|
||||||
try {
|
|
||||||
const {data} = await LNbits.api.request(
|
|
||||||
'GET',
|
|
||||||
'/nostrmarket/api/v1/customer',
|
|
||||||
this.inkey
|
|
||||||
)
|
|
||||||
this.customers = data
|
|
||||||
this.unreadMessages = data.filter(c => c.unread_messages).length
|
|
||||||
} catch (error) {
|
|
||||||
LNbits.utils.notifyApiError(error)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
sendDirectMesage: async function () {
|
|
||||||
try {
|
|
||||||
const {data} = await LNbits.api.request(
|
|
||||||
'POST',
|
|
||||||
'/nostrmarket/api/v1/message',
|
|
||||||
this.adminkey,
|
|
||||||
{
|
|
||||||
message: this.newMessage,
|
|
||||||
public_key: this.activePublicKey
|
|
||||||
}
|
|
||||||
)
|
|
||||||
this.messages = this.messages.concat([data])
|
|
||||||
this.newMessage = ''
|
|
||||||
this.focusOnChatBox(this.messages.length - 1)
|
|
||||||
} catch (error) {
|
|
||||||
LNbits.utils.notifyApiError(error)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
addPublicKey: async function () {
|
|
||||||
try {
|
|
||||||
const {data} = await LNbits.api.request(
|
|
||||||
'POST',
|
|
||||||
'/nostrmarket/api/v1/customer',
|
|
||||||
this.adminkey,
|
|
||||||
{
|
|
||||||
public_key: this.newPublicKey,
|
|
||||||
merchant_id: this.merchantId,
|
|
||||||
unread_messages: 0
|
|
||||||
}
|
|
||||||
)
|
|
||||||
this.newPublicKey = null
|
|
||||||
this.activePublicKey = data.public_key
|
|
||||||
await this.selectActiveCustomer()
|
|
||||||
} catch (error) {
|
|
||||||
LNbits.utils.notifyApiError(error)
|
|
||||||
} finally {
|
|
||||||
this.showAddPublicKey = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
handleNewMessage: async function (data) {
|
|
||||||
if (data.customerPubkey === this.activePublicKey) {
|
|
||||||
this.messages.push(data.dm)
|
|
||||||
this.focusOnChatBox(this.messages.length - 1)
|
|
||||||
// focus back on input box
|
|
||||||
}
|
|
||||||
this.getCustomersDebounced()
|
|
||||||
},
|
|
||||||
showOrderDetails: function (orderId, eventId) {
|
|
||||||
this.$emit('order-selected', {orderId, eventId})
|
|
||||||
},
|
|
||||||
showClientOrders: function () {
|
|
||||||
this.$emit('customer-selected', this.activePublicKey)
|
|
||||||
},
|
|
||||||
selectActiveCustomer: async function () {
|
|
||||||
await this.getDirectMessages(this.activePublicKey)
|
|
||||||
await this.getCustomers()
|
|
||||||
},
|
|
||||||
showMessageRawData: function (index) {
|
|
||||||
this.rawMessage = this.messages[index]?.message
|
|
||||||
this.showRawMessage = true
|
|
||||||
},
|
|
||||||
focusOnChatBox: function (index) {
|
|
||||||
setTimeout(() => {
|
|
||||||
const lastChatBox = document.getElementsByClassName(
|
|
||||||
`chat-mesage-index-${index}`
|
|
||||||
)
|
|
||||||
if (lastChatBox && lastChatBox[0]) {
|
|
||||||
lastChatBox[0].scrollIntoView()
|
|
||||||
}
|
|
||||||
}, 100)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
created: async function () {
|
|
||||||
await this.getCustomers()
|
|
||||||
this.getCustomersDebounced = _.debounce(this.getCustomers, 2000, false)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
104
static/components/direct-messages/direct-messages.html
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
<div>
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-2">
|
||||||
|
<h6 class="text-subtitle1 q-my-none">Messages</h6>
|
||||||
|
</div>
|
||||||
|
<div class="col-4">
|
||||||
|
<q-badge v-if="unreadMessages" color="primary" outline><span v-text="unreadMessages"></span> new</q-badge>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<q-btn v-if="activePublicKey" @click="showClientOrders" unelevated outline class="float-right">Client
|
||||||
|
Orders</q-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section class="q-pa-none">
|
||||||
|
<q-separator></q-separator>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-10">
|
||||||
|
<q-select v-model="activePublicKey"
|
||||||
|
:options="customers.map(c => ({label: buildCustomerLabel(c), value: c.public_key}))" label="Select Customer"
|
||||||
|
emit-value @input="selectActiveCustomer()">
|
||||||
|
</q-select>
|
||||||
|
</div>
|
||||||
|
<div class="col-2">
|
||||||
|
<q-btn label="Add" color="primary" class="float-right q-mt-md" @click="showAddPublicKey = true">
|
||||||
|
<q-tooltip>
|
||||||
|
Add a public key to chat with
|
||||||
|
</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section>
|
||||||
|
<div class="chat-container" ref="chatCard">
|
||||||
|
<div class="chat-box">
|
||||||
|
<div class="chat-messages" style="height: 45vh">
|
||||||
|
<q-chat-message v-for="(dm, index) in messagesAsJson" :key="index" :name="dm.incoming ? 'customer': 'me'"
|
||||||
|
:text="dm.isJson ? [] : [dm.message]" :sent="!dm.incoming"
|
||||||
|
:stamp="dm.dateFrom"
|
||||||
|
:bg-color="dm.incoming ? 'white' : 'light-green-2'" :class="'chat-mesage-index-'+index">
|
||||||
|
<div v-if="dm.isJson">
|
||||||
|
<div v-if="dm.message.type === 0">
|
||||||
|
<strong>New order:</strong>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="dm.message.type === 1">
|
||||||
|
<strong>Reply sent for order: </strong>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="dm.message.type === 2">
|
||||||
|
<q-badge v-if="dm.message.paid" color="green">Paid </q-badge>
|
||||||
|
<q-badge v-if="dm.message.shipped" color="green">Shipped </q-badge>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span v-text="dm.message.message"></span>
|
||||||
|
<q-badge color="orange">
|
||||||
|
<span v-text="dm.message.id" @click="showOrderDetails(dm.message.id, dm.event_id)" class="cursor-pointer"></span>
|
||||||
|
</q-badge>
|
||||||
|
</div>
|
||||||
|
<q-badge @click="showMessageRawData(index)" class="cursor-pointer">...</q-badge>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</q-chat-message>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<q-card-section>
|
||||||
|
<q-form @submit="sendDirectMesage" class="full-width chat-input">
|
||||||
|
<q-input ref="newMessage" v-model="newMessage" placeholder="Message" class="full-width" dense outlined>
|
||||||
|
<template>
|
||||||
|
<q-btn round dense flat type="submit" icon="send" color="primary" />
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
|
</q-form>
|
||||||
|
</q-card-section>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
<div>
|
||||||
|
<q-dialog v-model="showAddPublicKey" position="top">
|
||||||
|
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
|
||||||
|
<q-form @submit="addPublicKey" class="q-gutter-md">
|
||||||
|
<q-input filled dense v-model.trim="newPublicKey" label="Public Key (hex or nsec)"></q-input>
|
||||||
|
<div class="row q-mt-lg">
|
||||||
|
<q-btn unelevated color="primary" :disable="!newPublicKey" type="submit">Add</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="showRawMessage" position="top">
|
||||||
|
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
|
||||||
|
<q-input filled dense type="textarea" rows="20" v-model.trim="rawMessage" label="Raw Data"></q-input>
|
||||||
|
<div class="row q-mt-lg">
|
||||||
|
|
||||||
|
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
|
||||||
|
</div>
|
||||||
|
</q-form>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
173
static/components/direct-messages/direct-messages.js
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
async function directMessages(path) {
|
||||||
|
const template = await loadTemplateAsync(path)
|
||||||
|
Vue.component('direct-messages', {
|
||||||
|
name: 'direct-messages',
|
||||||
|
props: ['active-chat-customer', 'merchant-id', 'adminkey', 'inkey'],
|
||||||
|
template,
|
||||||
|
|
||||||
|
watch: {
|
||||||
|
activeChatCustomer: async function (n) {
|
||||||
|
this.activePublicKey = n
|
||||||
|
},
|
||||||
|
activePublicKey: async function (n) {
|
||||||
|
await this.getDirectMessages(n)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
messagesAsJson: function () {
|
||||||
|
return this.messages.map(m => {
|
||||||
|
const dateFrom = moment(m.event_created_at * 1000).fromNow()
|
||||||
|
try {
|
||||||
|
const message = JSON.parse(m.message)
|
||||||
|
return {
|
||||||
|
isJson: message.type >= 0,
|
||||||
|
dateFrom,
|
||||||
|
...m,
|
||||||
|
message
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
isJson: false,
|
||||||
|
dateFrom,
|
||||||
|
...m,
|
||||||
|
message: m.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data: function () {
|
||||||
|
return {
|
||||||
|
customers: [],
|
||||||
|
unreadMessages: 0,
|
||||||
|
activePublicKey: null,
|
||||||
|
messages: [],
|
||||||
|
newMessage: '',
|
||||||
|
showAddPublicKey: false,
|
||||||
|
newPublicKey: null,
|
||||||
|
showRawMessage: false,
|
||||||
|
rawMessage: null,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
sendMessage: async function () { },
|
||||||
|
buildCustomerLabel: function (c) {
|
||||||
|
let label = `${c.profile.name || 'unknown'} ${c.profile.about || ''}`
|
||||||
|
if (c.unread_messages) {
|
||||||
|
label += `[new: ${c.unread_messages}]`
|
||||||
|
}
|
||||||
|
label += ` (${c.public_key.slice(0, 16)}...${c.public_key.slice(
|
||||||
|
c.public_key.length - 16
|
||||||
|
)}`
|
||||||
|
return label
|
||||||
|
},
|
||||||
|
getDirectMessages: async function (pubkey) {
|
||||||
|
if (!pubkey) {
|
||||||
|
this.messages = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { data } = await LNbits.api.request(
|
||||||
|
'GET',
|
||||||
|
'/nostrmarket/api/v1/message/' + pubkey,
|
||||||
|
this.inkey
|
||||||
|
)
|
||||||
|
this.messages = data
|
||||||
|
|
||||||
|
this.focusOnChatBox(this.messages.length - 1)
|
||||||
|
} catch (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getCustomers: async function () {
|
||||||
|
try {
|
||||||
|
const { data } = await LNbits.api.request(
|
||||||
|
'GET',
|
||||||
|
'/nostrmarket/api/v1/customer',
|
||||||
|
this.inkey
|
||||||
|
)
|
||||||
|
this.customers = data
|
||||||
|
this.unreadMessages = data.filter(c => c.unread_messages).length
|
||||||
|
} catch (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
sendDirectMesage: async function () {
|
||||||
|
try {
|
||||||
|
const { data } = await LNbits.api.request(
|
||||||
|
'POST',
|
||||||
|
'/nostrmarket/api/v1/message',
|
||||||
|
this.adminkey,
|
||||||
|
{
|
||||||
|
message: this.newMessage,
|
||||||
|
public_key: this.activePublicKey
|
||||||
|
}
|
||||||
|
)
|
||||||
|
this.messages = this.messages.concat([data])
|
||||||
|
this.newMessage = ''
|
||||||
|
this.focusOnChatBox(this.messages.length - 1)
|
||||||
|
} catch (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
addPublicKey: async function () {
|
||||||
|
try {
|
||||||
|
const { data } = await LNbits.api.request(
|
||||||
|
'POST',
|
||||||
|
'/nostrmarket/api/v1/customer',
|
||||||
|
this.adminkey,
|
||||||
|
{
|
||||||
|
public_key: this.newPublicKey,
|
||||||
|
merchant_id: this.merchantId,
|
||||||
|
unread_messages: 0
|
||||||
|
}
|
||||||
|
)
|
||||||
|
this.newPublicKey = null
|
||||||
|
this.activePublicKey = data.public_key
|
||||||
|
await this.selectActiveCustomer()
|
||||||
|
} catch (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
} finally {
|
||||||
|
this.showAddPublicKey = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
handleNewMessage: async function (data) {
|
||||||
|
if (data.customerPubkey === this.activePublicKey) {
|
||||||
|
this.messages.push(data.dm)
|
||||||
|
this.focusOnChatBox(this.messages.length - 1)
|
||||||
|
// focus back on input box
|
||||||
|
}
|
||||||
|
this.getCustomersDebounced()
|
||||||
|
},
|
||||||
|
showOrderDetails: function (orderId, eventId) {
|
||||||
|
this.$emit('order-selected', { orderId, eventId })
|
||||||
|
},
|
||||||
|
showClientOrders: function () {
|
||||||
|
this.$emit('customer-selected', this.activePublicKey)
|
||||||
|
},
|
||||||
|
selectActiveCustomer: async function () {
|
||||||
|
await this.getDirectMessages(this.activePublicKey)
|
||||||
|
await this.getCustomers()
|
||||||
|
},
|
||||||
|
showMessageRawData: function (index) {
|
||||||
|
this.rawMessage = this.messages[index]?.message
|
||||||
|
this.showRawMessage = true
|
||||||
|
},
|
||||||
|
focusOnChatBox: function (index) {
|
||||||
|
setTimeout(() => {
|
||||||
|
const lastChatBox = document.getElementsByClassName(
|
||||||
|
`chat-mesage-index-${index}`
|
||||||
|
)
|
||||||
|
if (lastChatBox && lastChatBox[0]) {
|
||||||
|
lastChatBox[0].scrollIntoView()
|
||||||
|
}
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created: async function () {
|
||||||
|
await this.getCustomers()
|
||||||
|
this.getCustomersDebounced = _.debounce(this.getCustomers, 2000, false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -1,91 +0,0 @@
|
||||||
window.app.component('edit-profile-dialog', {
|
|
||||||
name: 'edit-profile-dialog',
|
|
||||||
template: '#edit-profile-dialog',
|
|
||||||
delimiters: ['${', '}'],
|
|
||||||
props: ['model-value', 'merchant-id', 'merchant-config', 'adminkey'],
|
|
||||||
emits: ['update:model-value', 'profile-updated'],
|
|
||||||
data: function () {
|
|
||||||
return {
|
|
||||||
saving: false,
|
|
||||||
formData: {
|
|
||||||
name: '',
|
|
||||||
display_name: '',
|
|
||||||
about: '',
|
|
||||||
picture: '',
|
|
||||||
banner: '',
|
|
||||||
website: '',
|
|
||||||
nip05: '',
|
|
||||||
lud16: ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
show: {
|
|
||||||
get() {
|
|
||||||
return this.modelValue
|
|
||||||
},
|
|
||||||
set(value) {
|
|
||||||
this.$emit('update:model-value', value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
saveProfile: async function () {
|
|
||||||
this.saving = true
|
|
||||||
try {
|
|
||||||
const config = {
|
|
||||||
...this.merchantConfig,
|
|
||||||
name: this.formData.name || null,
|
|
||||||
display_name: this.formData.display_name || null,
|
|
||||||
about: this.formData.about || null,
|
|
||||||
picture: this.formData.picture || null,
|
|
||||||
banner: this.formData.banner || null,
|
|
||||||
website: this.formData.website || null,
|
|
||||||
nip05: this.formData.nip05 || null,
|
|
||||||
lud16: this.formData.lud16 || null
|
|
||||||
}
|
|
||||||
await LNbits.api.request(
|
|
||||||
'PATCH',
|
|
||||||
`/nostrmarket/api/v1/merchant/${this.merchantId}`,
|
|
||||||
this.adminkey,
|
|
||||||
config
|
|
||||||
)
|
|
||||||
// Publish to Nostr
|
|
||||||
await LNbits.api.request(
|
|
||||||
'PUT',
|
|
||||||
`/nostrmarket/api/v1/merchant/${this.merchantId}/nostr`,
|
|
||||||
this.adminkey
|
|
||||||
)
|
|
||||||
this.show = false
|
|
||||||
this.$q.notify({
|
|
||||||
type: 'positive',
|
|
||||||
message: 'Profile saved and published to Nostr!'
|
|
||||||
})
|
|
||||||
this.$emit('profile-updated')
|
|
||||||
} catch (error) {
|
|
||||||
LNbits.utils.notifyApiError(error)
|
|
||||||
} finally {
|
|
||||||
this.saving = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
loadFormData: function () {
|
|
||||||
if (this.merchantConfig) {
|
|
||||||
this.formData.name = this.merchantConfig.name || ''
|
|
||||||
this.formData.display_name = this.merchantConfig.display_name || ''
|
|
||||||
this.formData.about = this.merchantConfig.about || ''
|
|
||||||
this.formData.picture = this.merchantConfig.picture || ''
|
|
||||||
this.formData.banner = this.merchantConfig.banner || ''
|
|
||||||
this.formData.website = this.merchantConfig.website || ''
|
|
||||||
this.formData.nip05 = this.merchantConfig.nip05 || ''
|
|
||||||
this.formData.lud16 = this.merchantConfig.lud16 || ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
modelValue(newVal) {
|
|
||||||
if (newVal) {
|
|
||||||
this.loadFormData()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
44
static/components/key-pair/key-pair.html
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
<div>
|
||||||
|
<q-separator></q-separator>
|
||||||
|
<div class="row q-mt-md">
|
||||||
|
<div class="col-6 q-pl-xl">Public Key</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<q-toggle v-model="showPrivateKey" class="q-pl-xl" color="secodary">
|
||||||
|
Show Private Key
|
||||||
|
</q-toggle>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-6">
|
||||||
|
<div class="text-center q-mb-lg cursor-pointer">
|
||||||
|
<q-responsive :ratio="1" class="q-mx-xl" @click="copyText(publicKey)">
|
||||||
|
<qrcode
|
||||||
|
:value="publicKey"
|
||||||
|
:options="{width: 250}"
|
||||||
|
class="rounded-borders"
|
||||||
|
></qrcode>
|
||||||
|
</q-responsive>
|
||||||
|
<small><span v-text="publicKey"></span><br />Click to copy</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 cursor-pointer">
|
||||||
|
<div v-if="showPrivateKey">
|
||||||
|
<div class="text-center q-mb-lg">
|
||||||
|
<q-responsive
|
||||||
|
:ratio="1"
|
||||||
|
class="q-mx-xl"
|
||||||
|
@click="copyText(privateKey)"
|
||||||
|
>
|
||||||
|
<qrcode
|
||||||
|
:value="privateKey"
|
||||||
|
:options="{width: 250}"
|
||||||
|
class="rounded-borders"
|
||||||
|
></qrcode>
|
||||||
|
</q-responsive>
|
||||||
|
<small><span v-text="privateKey"></span><br />Click to copy</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
25
static/components/key-pair/key-pair.js
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
async function keyPair(path) {
|
||||||
|
const template = await loadTemplateAsync(path)
|
||||||
|
Vue.component('key-pair', {
|
||||||
|
name: 'key-pair',
|
||||||
|
template,
|
||||||
|
|
||||||
|
props: ['public-key', 'private-key'],
|
||||||
|
data: function () {
|
||||||
|
return {
|
||||||
|
showPrivateKey: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
copyText: function (text, message, position) {
|
||||||
|
var notify = this.$q.notify
|
||||||
|
Quasar.utils.copyToClipboard(text).then(function () {
|
||||||
|
notify({
|
||||||
|
message: message || 'Copied to clipboard!',
|
||||||
|
position: position || 'bottom'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -1,102 +0,0 @@
|
||||||
window.app.component('merchant-details', {
|
|
||||||
name: 'merchant-details',
|
|
||||||
template: '#merchant-details',
|
|
||||||
props: ['merchant-id', 'adminkey', 'inkey', 'showKeys'],
|
|
||||||
delimiters: ['${', '}'],
|
|
||||||
data: function () {
|
|
||||||
return {}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
toggleShowKeys: async function () {
|
|
||||||
this.$emit('toggle-show-keys')
|
|
||||||
},
|
|
||||||
|
|
||||||
republishMerchantData: async function () {
|
|
||||||
try {
|
|
||||||
await LNbits.api.request(
|
|
||||||
'PUT',
|
|
||||||
`/nostrmarket/api/v1/merchant/${this.merchantId}/nostr`,
|
|
||||||
this.adminkey
|
|
||||||
)
|
|
||||||
this.$q.notify({
|
|
||||||
type: 'positive',
|
|
||||||
message: 'Merchant data republished to Nostr',
|
|
||||||
timeout: 5000
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(error)
|
|
||||||
LNbits.utils.notifyApiError(error)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
requeryMerchantData: async function () {
|
|
||||||
try {
|
|
||||||
await LNbits.api.request(
|
|
||||||
'GET',
|
|
||||||
`/nostrmarket/api/v1/merchant/${this.merchantId}/nostr`,
|
|
||||||
this.adminkey
|
|
||||||
)
|
|
||||||
this.$q.notify({
|
|
||||||
type: 'positive',
|
|
||||||
message: 'Merchant data refreshed from Nostr',
|
|
||||||
timeout: 5000
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(error)
|
|
||||||
LNbits.utils.notifyApiError(error)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
deleteMerchantTables: function () {
|
|
||||||
LNbits.utils
|
|
||||||
.confirmDialog(
|
|
||||||
`
|
|
||||||
Stalls, products and orders will be deleted also!
|
|
||||||
Are you sure you want to delete this merchant?
|
|
||||||
`
|
|
||||||
)
|
|
||||||
.onOk(async () => {
|
|
||||||
try {
|
|
||||||
await LNbits.api.request(
|
|
||||||
'DELETE',
|
|
||||||
'/nostrmarket/api/v1/merchant/' + this.merchantId,
|
|
||||||
this.adminkey
|
|
||||||
)
|
|
||||||
this.$emit('merchant-deleted', this.merchantId)
|
|
||||||
this.$q.notify({
|
|
||||||
type: 'positive',
|
|
||||||
message: 'Merchant Deleted',
|
|
||||||
timeout: 5000
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(error)
|
|
||||||
LNbits.utils.notifyApiError(error)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
deleteMerchantFromNostr: function () {
|
|
||||||
LNbits.utils
|
|
||||||
.confirmDialog(
|
|
||||||
`
|
|
||||||
Do you want to remove the merchant from Nostr?
|
|
||||||
`
|
|
||||||
)
|
|
||||||
.onOk(async () => {
|
|
||||||
try {
|
|
||||||
await LNbits.api.request(
|
|
||||||
'DELETE',
|
|
||||||
`/nostrmarket/api/v1/merchant/${this.merchantId}/nostr`,
|
|
||||||
this.adminkey
|
|
||||||
)
|
|
||||||
this.$q.notify({
|
|
||||||
type: 'positive',
|
|
||||||
message: 'Merchant Deleted from Nostr',
|
|
||||||
timeout: 5000
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(error)
|
|
||||||
LNbits.utils.notifyApiError(error)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
created: async function () {}
|
|
||||||
})
|
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
>
|
>
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
</q-item>
|
</q-item>
|
||||||
<q-item @click="toggleShowKeys" clickable v-close-popup>
|
<q-item @click="toggleMerchantKeys" clickable v-close-popup>
|
||||||
<q-item-section>
|
<q-item-section>
|
||||||
<q-item-label v-if="!showKeys">Show Keys</q-item-label>
|
<q-item-label v-if="!showKeys">Show Keys</q-item-label>
|
||||||
<q-item-label v-else>Hide Keys</q-item-label>
|
<q-item-label v-else>Hide Keys</q-item-label>
|
||||||
108
static/components/merchant-details/merchant-details.js
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
async function merchantDetails(path) {
|
||||||
|
const template = await loadTemplateAsync(path)
|
||||||
|
Vue.component('merchant-details', {
|
||||||
|
name: 'merchant-details',
|
||||||
|
props: ['merchant-id', 'adminkey', 'inkey'],
|
||||||
|
template,
|
||||||
|
|
||||||
|
data: function () {
|
||||||
|
return {
|
||||||
|
showKeys: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
toggleMerchantKeys: async function () {
|
||||||
|
this.showKeys = !this.showKeys
|
||||||
|
this.$emit('show-keys', this.showKeys)
|
||||||
|
},
|
||||||
|
|
||||||
|
republishMerchantData: async function () {
|
||||||
|
try {
|
||||||
|
await LNbits.api.request(
|
||||||
|
'PUT',
|
||||||
|
`/nostrmarket/api/v1/merchant/${this.merchantId}/nostr`,
|
||||||
|
this.adminkey
|
||||||
|
)
|
||||||
|
this.$q.notify({
|
||||||
|
type: 'positive',
|
||||||
|
message: 'Merchant data republished to Nostr',
|
||||||
|
timeout: 5000
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(error)
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
requeryMerchantData: async function () {
|
||||||
|
try {
|
||||||
|
await LNbits.api.request(
|
||||||
|
'GET',
|
||||||
|
`/nostrmarket/api/v1/merchant/${this.merchantId}/nostr`,
|
||||||
|
this.adminkey
|
||||||
|
)
|
||||||
|
this.$q.notify({
|
||||||
|
type: 'positive',
|
||||||
|
message: 'Merchant data refreshed from Nostr',
|
||||||
|
timeout: 5000
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(error)
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
deleteMerchantTables: function () {
|
||||||
|
LNbits.utils
|
||||||
|
.confirmDialog(
|
||||||
|
`
|
||||||
|
Stalls, products and orders will be deleted also!
|
||||||
|
Are you sure you want to delete this merchant?
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.onOk(async () => {
|
||||||
|
try {
|
||||||
|
await LNbits.api.request(
|
||||||
|
'DELETE',
|
||||||
|
'/nostrmarket/api/v1/merchant/' + this.merchantId,
|
||||||
|
this.adminkey
|
||||||
|
)
|
||||||
|
this.$emit('merchant-deleted', this.merchantId)
|
||||||
|
this.$q.notify({
|
||||||
|
type: 'positive',
|
||||||
|
message: 'Merchant Deleted',
|
||||||
|
timeout: 5000
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(error)
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
deleteMerchantFromNostr: function () {
|
||||||
|
LNbits.utils
|
||||||
|
.confirmDialog(
|
||||||
|
`
|
||||||
|
Do you want to remove the merchant from Nostr?
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.onOk(async () => {
|
||||||
|
try {
|
||||||
|
await LNbits.api.request(
|
||||||
|
'DELETE',
|
||||||
|
`/nostrmarket/api/v1/merchant/${this.merchantId}/nostr`,
|
||||||
|
this.adminkey
|
||||||
|
)
|
||||||
|
this.$q.notify({
|
||||||
|
type: 'positive',
|
||||||
|
message: 'Merchant Deleted from Nostr',
|
||||||
|
timeout: 5000
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(error)
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created: async function () {}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -1,102 +0,0 @@
|
||||||
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',
|
|
||||||
'merchant-config'
|
|
||||||
],
|
|
||||||
emits: [
|
|
||||||
'toggle-show-keys',
|
|
||||||
'hide-keys',
|
|
||||||
'merchant-deleted',
|
|
||||||
'toggle-merchant-state',
|
|
||||||
'restart-nostr-connection',
|
|
||||||
'profile-updated'
|
|
||||||
],
|
|
||||||
data: function () {
|
|
||||||
return {
|
|
||||||
showEditProfileDialog: false,
|
|
||||||
showKeysDialog: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
marketClientUrl: function () {
|
|
||||||
if (!this.publicKey) {
|
|
||||||
return '/nostrmarket/market'
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = new URL('/nostrmarket/market', window.location.origin)
|
|
||||||
url.searchParams.set('merchant', this.publicKey)
|
|
||||||
return url.pathname + url.search
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
publishProfile: async function () {
|
|
||||||
try {
|
|
||||||
await LNbits.api.request(
|
|
||||||
'PUT',
|
|
||||||
`/nostrmarket/api/v1/merchant/${this.merchantId}/nostr`,
|
|
||||||
this.adminkey
|
|
||||||
)
|
|
||||||
this.$q.notify({
|
|
||||||
type: 'positive',
|
|
||||||
message: 'Profile published to Nostr!'
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
LNbits.utils.notifyApiError(error)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
toggleShowKeys: function () {
|
|
||||||
this.$emit('toggle-show-keys')
|
|
||||||
},
|
|
||||||
hideKeys: function () {
|
|
||||||
this.$emit('hide-keys')
|
|
||||||
},
|
|
||||||
handleMerchantDeleted: function () {
|
|
||||||
this.$emit('merchant-deleted')
|
|
||||||
},
|
|
||||||
removeMerchant: function () {
|
|
||||||
const name =
|
|
||||||
this.merchantConfig?.display_name ||
|
|
||||||
this.merchantConfig?.name ||
|
|
||||||
'this merchant'
|
|
||||||
LNbits.utils
|
|
||||||
.confirmDialog(
|
|
||||||
`Are you sure you want to remove "${name}"? This will delete all associated data (stalls, products, orders, messages).`
|
|
||||||
)
|
|
||||||
.onOk(async () => {
|
|
||||||
try {
|
|
||||||
await LNbits.api.request(
|
|
||||||
'DELETE',
|
|
||||||
`/nostrmarket/api/v1/merchant/${this.merchantId}`,
|
|
||||||
this.adminkey
|
|
||||||
)
|
|
||||||
this.$emit('merchant-deleted')
|
|
||||||
this.$q.notify({
|
|
||||||
type: 'positive',
|
|
||||||
message: 'Merchant removed'
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
LNbits.utils.notifyApiError(error)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
toggleMerchantState: function () {
|
|
||||||
this.$emit('toggle-merchant-state')
|
|
||||||
},
|
|
||||||
restartNostrConnection: function () {
|
|
||||||
this.$emit('restart-nostr-connection')
|
|
||||||
},
|
|
||||||
handleImageError: function (e) {
|
|
||||||
e.target.style.display = 'none'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
window.app.component('nostr-keys-dialog', {
|
|
||||||
name: 'nostr-keys-dialog',
|
|
||||||
template: '#nostr-keys-dialog',
|
|
||||||
delimiters: ['${', '}'],
|
|
||||||
props: ['public-key', 'private-key', 'model-value'],
|
|
||||||
emits: ['update:model-value'],
|
|
||||||
data: function () {
|
|
||||||
return {
|
|
||||||
showNsec: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
show: {
|
|
||||||
get() {
|
|
||||||
return this.modelValue
|
|
||||||
},
|
|
||||||
set(value) {
|
|
||||||
this.$emit('update:model-value', value)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
npub: function () {
|
|
||||||
if (!this.publicKey) return ''
|
|
||||||
try {
|
|
||||||
return window.NostrTools.nip19.npubEncode(this.publicKey)
|
|
||||||
} catch (e) {
|
|
||||||
return this.publicKey
|
|
||||||
}
|
|
||||||
},
|
|
||||||
nsec: function () {
|
|
||||||
if (!this.privateKey) return ''
|
|
||||||
try {
|
|
||||||
return window.NostrTools.nip19.nsecEncode(this.privateKey)
|
|
||||||
} catch (e) {
|
|
||||||
return this.privateKey
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
copyText: function (text, message) {
|
|
||||||
var notify = this.$q.notify
|
|
||||||
Quasar.copyToClipboard(text).then(function () {
|
|
||||||
notify({
|
|
||||||
message: message || 'Copied to clipboard!',
|
|
||||||
position: 'bottom'
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
modelValue(newVal) {
|
|
||||||
if (!newVal) {
|
|
||||||
this.showNsec = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
@ -1,406 +0,0 @@
|
||||||
window.app.component('order-list', {
|
|
||||||
name: 'order-list',
|
|
||||||
props: ['stall-id', 'customer-pubkey-filter', 'adminkey', 'inkey'],
|
|
||||||
template: '#order-list',
|
|
||||||
delimiters: ['${', '}'],
|
|
||||||
watch: {
|
|
||||||
customerPubkeyFilter: async function (n) {
|
|
||||||
this.search.publicKey = n
|
|
||||||
this.search.isPaid = {label: 'All', id: null}
|
|
||||||
this.search.isShipped = {label: 'All', id: null}
|
|
||||||
await this.getOrders()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
data: function () {
|
|
||||||
return {
|
|
||||||
orders: [],
|
|
||||||
stalls: [],
|
|
||||||
selectedOrder: null,
|
|
||||||
shippingMessage: '',
|
|
||||||
showShipDialog: false,
|
|
||||||
filter: '',
|
|
||||||
search: {
|
|
||||||
publicKey: null,
|
|
||||||
isPaid: {
|
|
||||||
label: 'All',
|
|
||||||
id: null
|
|
||||||
},
|
|
||||||
isShipped: {
|
|
||||||
label: 'All',
|
|
||||||
id: null
|
|
||||||
},
|
|
||||||
restoring: false
|
|
||||||
},
|
|
||||||
customers: [],
|
|
||||||
ternaryOptions: [
|
|
||||||
{
|
|
||||||
label: 'All',
|
|
||||||
id: null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Yes',
|
|
||||||
id: 'true'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'No',
|
|
||||||
id: 'false'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
zoneOptions: [],
|
|
||||||
ordersTable: {
|
|
||||||
columns: [
|
|
||||||
{
|
|
||||||
name: '',
|
|
||||||
align: 'left',
|
|
||||||
label: '',
|
|
||||||
field: ''
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'id',
|
|
||||||
align: 'left',
|
|
||||||
label: 'Order ID',
|
|
||||||
field: 'id'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'total',
|
|
||||||
align: 'left',
|
|
||||||
label: 'Total Sats',
|
|
||||||
field: 'total'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'fiat',
|
|
||||||
align: 'left',
|
|
||||||
label: 'Total Fiat',
|
|
||||||
field: 'fiat'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'paid',
|
|
||||||
align: 'left',
|
|
||||||
label: 'Paid',
|
|
||||||
field: 'paid'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'shipped',
|
|
||||||
align: 'left',
|
|
||||||
label: 'Shipped',
|
|
||||||
field: 'shipped'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'public_key',
|
|
||||||
align: 'left',
|
|
||||||
label: 'Customer',
|
|
||||||
field: 'pubkey'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'event_created_at',
|
|
||||||
align: 'left',
|
|
||||||
label: 'Created At',
|
|
||||||
field: 'event_created_at'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
pagination: {
|
|
||||||
rowsPerPage: 10
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
customerOptions: function () {
|
|
||||||
const options = this.customers.map(c => ({
|
|
||||||
label: this.buildCustomerLabel(c),
|
|
||||||
value: c.public_key
|
|
||||||
}))
|
|
||||||
options.unshift({label: 'All', value: null, id: null})
|
|
||||||
return options
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
toShortId: function (value) {
|
|
||||||
return value.substring(0, 5) + '...' + value.substring(value.length - 5)
|
|
||||||
},
|
|
||||||
formatDate: function (value) {
|
|
||||||
return Quasar.date.formatDate(new Date(value * 1000), 'YYYY-MM-DD HH:mm')
|
|
||||||
},
|
|
||||||
satBtc(val, showUnit = true) {
|
|
||||||
return satOrBtc(val, showUnit, true)
|
|
||||||
},
|
|
||||||
formatFiat(value, currency) {
|
|
||||||
return Math.trunc(value) + ' ' + currency
|
|
||||||
},
|
|
||||||
shortLabel(value = '') {
|
|
||||||
if (value.length <= 44) return value
|
|
||||||
return value.substring(0, 20) + '...'
|
|
||||||
},
|
|
||||||
productName: function (order, productId) {
|
|
||||||
product = order.extra.products.find(p => p.id === productId)
|
|
||||||
if (product) {
|
|
||||||
return product.name
|
|
||||||
}
|
|
||||||
return ''
|
|
||||||
},
|
|
||||||
productPrice: function (order, productId) {
|
|
||||||
product = order.extra.products.find(p => p.id === productId)
|
|
||||||
if (product) {
|
|
||||||
return `${product.price} ${order.extra.currency}`
|
|
||||||
}
|
|
||||||
return ''
|
|
||||||
},
|
|
||||||
orderTotal: function (order) {
|
|
||||||
const productCost = order.items.reduce((t, item) => {
|
|
||||||
product = order.extra.products.find(p => p.id === item.product_id)
|
|
||||||
return t + item.quantity * product.price
|
|
||||||
}, 0)
|
|
||||||
return productCost + order.extra.shipping_cost
|
|
||||||
},
|
|
||||||
getOrders: async function () {
|
|
||||||
try {
|
|
||||||
const ordersPath = this.stallId
|
|
||||||
? `stall/order/${this.stallId}`
|
|
||||||
: 'order'
|
|
||||||
|
|
||||||
const query = []
|
|
||||||
if (this.search.publicKey) {
|
|
||||||
query.push(`pubkey=${this.search.publicKey}`)
|
|
||||||
}
|
|
||||||
if (this.search.isPaid.id) {
|
|
||||||
query.push(`paid=${this.search.isPaid.id}`)
|
|
||||||
}
|
|
||||||
if (this.search.isShipped.id) {
|
|
||||||
query.push(`shipped=${this.search.isShipped.id}`)
|
|
||||||
}
|
|
||||||
const {data} = await LNbits.api.request(
|
|
||||||
'GET',
|
|
||||||
`/nostrmarket/api/v1/${ordersPath}?${query.join('&')}`,
|
|
||||||
this.inkey
|
|
||||||
)
|
|
||||||
this.orders = data.map(s => ({...s, expanded: false}))
|
|
||||||
} catch (error) {
|
|
||||||
LNbits.utils.notifyApiError(error)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
getOrder: async function (orderId) {
|
|
||||||
try {
|
|
||||||
const {data} = await LNbits.api.request(
|
|
||||||
'GET',
|
|
||||||
`/nostrmarket/api/v1/order/${orderId}`,
|
|
||||||
this.inkey
|
|
||||||
)
|
|
||||||
return {...data, expanded: false, isNew: true}
|
|
||||||
} catch (error) {
|
|
||||||
LNbits.utils.notifyApiError(error)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
restoreOrder: async function (eventId) {
|
|
||||||
console.log('### restoreOrder', eventId)
|
|
||||||
try {
|
|
||||||
this.search.restoring = true
|
|
||||||
const {data} = await LNbits.api.request(
|
|
||||||
'PUT',
|
|
||||||
`/nostrmarket/api/v1/order/restore/${eventId}`,
|
|
||||||
this.adminkey
|
|
||||||
)
|
|
||||||
await this.getOrders()
|
|
||||||
this.$q.notify({
|
|
||||||
type: 'positive',
|
|
||||||
message: 'Order restored!'
|
|
||||||
})
|
|
||||||
return data
|
|
||||||
} catch (error) {
|
|
||||||
LNbits.utils.notifyApiError(error)
|
|
||||||
} finally {
|
|
||||||
this.search.restoring = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
restoreOrders: async function () {
|
|
||||||
try {
|
|
||||||
this.search.restoring = true
|
|
||||||
await LNbits.api.request(
|
|
||||||
'PUT',
|
|
||||||
`/nostrmarket/api/v1/orders/restore`,
|
|
||||||
this.adminkey
|
|
||||||
)
|
|
||||||
await this.getOrders()
|
|
||||||
this.$q.notify({
|
|
||||||
type: 'positive',
|
|
||||||
message: 'Orders restored!'
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
LNbits.utils.notifyApiError(error)
|
|
||||||
} finally {
|
|
||||||
this.search.restoring = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
reissueOrderInvoice: async function (order) {
|
|
||||||
try {
|
|
||||||
const {data} = await LNbits.api.request(
|
|
||||||
'PUT',
|
|
||||||
`/nostrmarket/api/v1/order/reissue`,
|
|
||||||
this.adminkey,
|
|
||||||
{
|
|
||||||
id: order.id,
|
|
||||||
shipping_id: order.shipping_id
|
|
||||||
}
|
|
||||||
)
|
|
||||||
this.$q.notify({
|
|
||||||
type: 'positive',
|
|
||||||
message: 'Order invoice reissued!'
|
|
||||||
})
|
|
||||||
data.expanded = order.expanded
|
|
||||||
|
|
||||||
const i = this.orders.map(o => o.id).indexOf(order.id)
|
|
||||||
if (i !== -1) {
|
|
||||||
this.orders[i] = {...this.orders[i], ...data}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
LNbits.utils.notifyApiError(error)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
updateOrderShipped: async function () {
|
|
||||||
this.selectedOrder.shipped = !this.selectedOrder.shipped
|
|
||||||
try {
|
|
||||||
await LNbits.api.request(
|
|
||||||
'PATCH',
|
|
||||||
`/nostrmarket/api/v1/order/${this.selectedOrder.id}`,
|
|
||||||
this.adminkey,
|
|
||||||
{
|
|
||||||
id: this.selectedOrder.id,
|
|
||||||
message: this.shippingMessage,
|
|
||||||
shipped: this.selectedOrder.shipped
|
|
||||||
}
|
|
||||||
)
|
|
||||||
this.$q.notify({
|
|
||||||
type: 'positive',
|
|
||||||
message: 'Order updated!'
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
LNbits.utils.notifyApiError(error)
|
|
||||||
}
|
|
||||||
this.showShipDialog = false
|
|
||||||
},
|
|
||||||
addOrder: async function (data) {
|
|
||||||
if (
|
|
||||||
!this.search.publicKey ||
|
|
||||||
this.search.publicKey === data.customerPubkey
|
|
||||||
) {
|
|
||||||
const orderData = JSON.parse(data.dm.message)
|
|
||||||
const i = this.orders.map(o => o.id).indexOf(orderData.id)
|
|
||||||
if (i === -1) {
|
|
||||||
const order = await this.getOrder(orderData.id)
|
|
||||||
this.orders.unshift(order)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
orderSelected: async function (orderId, eventId) {
|
|
||||||
const order = await this.getOrder(orderId)
|
|
||||||
if (!order) {
|
|
||||||
LNbits.utils
|
|
||||||
.confirmDialog(
|
|
||||||
'Order could not be found. Do you want to restore it from this direct message?'
|
|
||||||
)
|
|
||||||
.onOk(async () => {
|
|
||||||
const restoredOrder = await this.restoreOrder(eventId)
|
|
||||||
console.log('### restoredOrder', restoredOrder)
|
|
||||||
if (restoredOrder) {
|
|
||||||
restoredOrder.expanded = true
|
|
||||||
restoredOrder.isNew = false
|
|
||||||
this.orders = [restoredOrder]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
order.expanded = true
|
|
||||||
order.isNew = false
|
|
||||||
this.orders = [order]
|
|
||||||
},
|
|
||||||
getZones: async function () {
|
|
||||||
try {
|
|
||||||
const {data} = await LNbits.api.request(
|
|
||||||
'GET',
|
|
||||||
'/nostrmarket/api/v1/zone',
|
|
||||||
this.inkey
|
|
||||||
)
|
|
||||||
return data.map(z => ({
|
|
||||||
id: z.id,
|
|
||||||
value: z.id,
|
|
||||||
label: z.name
|
|
||||||
? `${z.name} (${z.countries.join(', ')})`
|
|
||||||
: z.countries.join(', ')
|
|
||||||
}))
|
|
||||||
} catch (error) {
|
|
||||||
LNbits.utils.notifyApiError(error)
|
|
||||||
}
|
|
||||||
return []
|
|
||||||
},
|
|
||||||
getStalls: async function (pending = false) {
|
|
||||||
try {
|
|
||||||
const {data} = await LNbits.api.request(
|
|
||||||
'GET',
|
|
||||||
`/nostrmarket/api/v1/stall?pending=${pending}`,
|
|
||||||
this.inkey
|
|
||||||
)
|
|
||||||
return data.map(s => ({...s, expanded: false}))
|
|
||||||
} catch (error) {
|
|
||||||
LNbits.utils.notifyApiError(error)
|
|
||||||
}
|
|
||||||
return []
|
|
||||||
},
|
|
||||||
getStallZones: function (stallId) {
|
|
||||||
const stall = this.stalls.find(s => s.id === stallId)
|
|
||||||
if (!stall) return []
|
|
||||||
|
|
||||||
return this.zoneOptions.filter(z =>
|
|
||||||
stall.shipping_zones.find(s => s.id === z.id)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
showShipOrderDialog: function (order) {
|
|
||||||
this.selectedOrder = order
|
|
||||||
this.shippingMessage = order.shipped
|
|
||||||
? 'The order has been shipped!'
|
|
||||||
: 'The order has NOT yet been shipped!'
|
|
||||||
|
|
||||||
// do not change the status yet
|
|
||||||
this.selectedOrder.shipped = !order.shipped
|
|
||||||
this.showShipDialog = true
|
|
||||||
},
|
|
||||||
customerSelected: function (customerPubkey) {
|
|
||||||
this.$emit('customer-selected', customerPubkey)
|
|
||||||
},
|
|
||||||
getCustomers: async function () {
|
|
||||||
try {
|
|
||||||
const {data} = await LNbits.api.request(
|
|
||||||
'GET',
|
|
||||||
'/nostrmarket/api/v1/customer',
|
|
||||||
this.inkey
|
|
||||||
)
|
|
||||||
this.customers = data
|
|
||||||
} catch (error) {
|
|
||||||
LNbits.utils.notifyApiError(error)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
buildCustomerLabel: function (c) {
|
|
||||||
let label = `${c.profile.name || 'unknown'} ${c.profile.about || ''}`
|
|
||||||
if (c.unread_messages) {
|
|
||||||
label += `[new: ${c.unread_messages}]`
|
|
||||||
}
|
|
||||||
label += ` (${c.public_key.slice(0, 16)}...${c.public_key.slice(
|
|
||||||
c.public_key.length - 16
|
|
||||||
)}`
|
|
||||||
return label
|
|
||||||
},
|
|
||||||
orderPaid: function (orderId) {
|
|
||||||
const order = this.orders.find(o => o.id === orderId)
|
|
||||||
if (order) {
|
|
||||||
order.paid = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
created: async function () {
|
|
||||||
if (this.stallId) {
|
|
||||||
await this.getOrders()
|
|
||||||
}
|
|
||||||
await this.getCustomers()
|
|
||||||
this.zoneOptions = await this.getZones()
|
|
||||||
this.stalls = await this.getStalls()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
212
static/components/order-list/order-list.html
Normal file
|
|
@ -0,0 +1,212 @@
|
||||||
|
<div>
|
||||||
|
<div class="row q-mb-md">
|
||||||
|
<div class="col-md-4 col-sm-6 q-pr-lg">
|
||||||
|
<q-select v-model="search.publicKey" :options="customerOptions" label="Customer" emit-value class="text-wrap">
|
||||||
|
</q-select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2 col-sm-6 q-pr-lg">
|
||||||
|
<q-select v-model="search.isPaid" :options="ternaryOptions" label="Paid" emit-value>
|
||||||
|
</q-select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2 col-sm-6 q-pr-lg">
|
||||||
|
<q-select v-model="search.isShipped" :options="ternaryOptions" label="Shipped" emit-value>
|
||||||
|
</q-select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 col-sm-6">
|
||||||
|
|
||||||
|
<q-btn-dropdown @click="getOrders()" :disable="search.restoring" outline unelevated split
|
||||||
|
class="q-pt-md float-right" :label="search.restoring ? 'Restoring Orders...' : 'Load Orders'">
|
||||||
|
<q-spinner v-if="search.restoring" color="primary" size="2.55em" class="q-pt-md float-right"></q-spinner>
|
||||||
|
<q-item @click="restoreOrders" clickable v-close-popup>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label>Restore Orders</q-item-label>
|
||||||
|
<q-item-label caption>Restore previous orders from Nostr</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-btn-dropdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row q-mt-md">
|
||||||
|
<div class="col">
|
||||||
|
<q-table flat dense :data="orders" row-key="id" :columns="ordersTable.columns"
|
||||||
|
:pagination.sync="ordersTable.pagination" :filter="filter">
|
||||||
|
<template v-slot:body="props">
|
||||||
|
<q-tr :props="props">
|
||||||
|
<q-td auto-width>
|
||||||
|
<q-btn size="sm" color="primary" round dense @click="props.row.expanded= !props.row.expanded"
|
||||||
|
:icon="props.row.expanded? 'remove' : 'add'" />
|
||||||
|
</q-td>
|
||||||
|
|
||||||
|
<q-td key="id" :props="props">
|
||||||
|
{{toShortId(props.row.id)}}
|
||||||
|
<q-badge v-if="props.row.isNew" color="orange">new</q-badge></q-td>
|
||||||
|
<q-td key="total" :props="props">
|
||||||
|
{{satBtc(props.row.total)}}
|
||||||
|
</q-td>
|
||||||
|
<q-td key="fiat" :props="props">
|
||||||
|
<span v-if="props.row.extra.currency !== 'sat'">
|
||||||
|
{{orderTotal(props.row)}} {{props.row.extra.currency}}
|
||||||
|
</span>
|
||||||
|
</q-td>
|
||||||
|
|
||||||
|
<q-td key="paid" :props="props">
|
||||||
|
<q-checkbox v-model="props.row.paid" :label="props.row.paid ? 'Yes' : 'No'" disable readonly
|
||||||
|
size="sm"></q-checkbox>
|
||||||
|
</q-td>
|
||||||
|
<q-td key="shipped" :props="props">
|
||||||
|
<q-checkbox v-model="props.row.shipped" @input="showShipOrderDialog(props.row)"
|
||||||
|
:label="props.row.shipped ? 'Yes' : 'No'" size="sm"></q-checkbox>
|
||||||
|
</q-td>
|
||||||
|
|
||||||
|
<q-td key="public_key" :props="props">
|
||||||
|
<span @click="customerSelected(props.row.public_key)" class="cursor-pointer">
|
||||||
|
{{toShortId(props.row.public_key)}}
|
||||||
|
</span>
|
||||||
|
</q-td>
|
||||||
|
<q-td key="event_created_at" :props="props">
|
||||||
|
{{formatDate(props.row.event_created_at)}}
|
||||||
|
</q-td>
|
||||||
|
</q-tr>
|
||||||
|
<q-tr v-if="props.row.expanded" :props="props">
|
||||||
|
<q-td colspan="100%">
|
||||||
|
<div class="row items-center no-wrap">
|
||||||
|
<div class="col-3 q-pr-lg">Products:</div>
|
||||||
|
<div class="col-8">
|
||||||
|
<div class="row items-center no-wrap q-mb-md">
|
||||||
|
<div class="col-1"><strong>Quantity</strong></div>
|
||||||
|
<div class="col-1"></div>
|
||||||
|
<div class="col-4"><strong>Name</strong></div>
|
||||||
|
<div class="col-2"><strong>Price</strong></div>
|
||||||
|
<div class="col-4"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-1"></div>
|
||||||
|
</div>
|
||||||
|
<div class="row items-center no-wrap q-mb-md">
|
||||||
|
<div class="col-3 q-pr-lg"></div>
|
||||||
|
<div class="col-8">
|
||||||
|
<div v-for="item in props.row.items" class="row items-center no-wrap q-mb-md">
|
||||||
|
<div class="col-1">{{item.quantity}}</div>
|
||||||
|
<div class="col-1">x</div>
|
||||||
|
<div class="col-4">
|
||||||
|
<p :title="productName(props.row, item.product_id)">
|
||||||
|
{{shortLabel(productName(props.row, item.product_id))}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-2">
|
||||||
|
{{productPrice(props.row, item.product_id)}}
|
||||||
|
</div>
|
||||||
|
<div class="col-4"></div>
|
||||||
|
</div>
|
||||||
|
<div v-if="props.row.extra.shipping_cost" class="row items-center no-wrap q-mb-md">
|
||||||
|
<div class="col-1"></div>
|
||||||
|
<div class="col-1"></div>
|
||||||
|
<div class="col-4">Shipping Cost</div>
|
||||||
|
<div class="col-2">
|
||||||
|
{{props.row.extra.shipping_cost}} {{props.row.extra.currency}}
|
||||||
|
</div>
|
||||||
|
<div class="col-4"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-1"></div>
|
||||||
|
</div>
|
||||||
|
<div v-if="props.row.extra.currency !== 'sat'" class="row items-center no-wrap q-mb-md q-mt-md">
|
||||||
|
<div class="col-3 q-pr-lg">Exchange Rate (1 BTC):</div>
|
||||||
|
<div class="col-6 col-sm-8 q-pr-lg">
|
||||||
|
<q-input filled dense readonly disabled
|
||||||
|
:value="formatFiat(props.row.extra.btc_price, props.row.extra.currency)" type="text"></q-input>
|
||||||
|
</div>
|
||||||
|
<div class="col-3 col-sm-1"></div>
|
||||||
|
</div>
|
||||||
|
<div v-if="props.row.extra.fail_message" class="row items-center no-wrap q-mb-md">
|
||||||
|
<div class="col-3 q-pr-lg">Error:</div>
|
||||||
|
<div class="col-6 col-sm-8 q-pr-lg">
|
||||||
|
<q-badge color="pink"><span v-text="props.row.extra.fail_message"></span></q-badge>
|
||||||
|
</div>
|
||||||
|
<div class="col-3 col-sm-1"></div>
|
||||||
|
</div>
|
||||||
|
<div class="row items-center no-wrap q-mb-md q-mt-md">
|
||||||
|
<div class="col-3 q-pr-lg">Order ID:</div>
|
||||||
|
<div class="col-6 col-sm-8 q-pr-lg">
|
||||||
|
<q-input filled dense readonly disabled v-model.trim="props.row.id" type="text"></q-input>
|
||||||
|
</div>
|
||||||
|
<div class="col-3 col-sm-1"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row items-center no-wrap q-mb-md">
|
||||||
|
<div class="col-3 q-pr-lg">Customer Public Key:</div>
|
||||||
|
<div class="col-6 col-sm-8 q-pr-lg">
|
||||||
|
<q-input filled dense readonly disabled v-model.trim="props.row.public_key" type="text"></q-input>
|
||||||
|
</div>
|
||||||
|
<div class="col-3 col-sm-1"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="props.row.address" class="row items-center no-wrap q-mb-md">
|
||||||
|
<div class="col-3 q-pr-lg">Address:</div>
|
||||||
|
<div class="col-6 col-sm-8 q-pr-lg">
|
||||||
|
<q-input filled dense readonly disabled v-model.trim="props.row.address" type="text"></q-input>
|
||||||
|
</div>
|
||||||
|
<div class="col-3 col-sm-1"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="props.row.contact.phone" class="row items-center no-wrap q-mb-md">
|
||||||
|
<div class="col-3 q-pr-lg">Phone:</div>
|
||||||
|
<div class="col-6 col-sm-8 q-pr-lg">
|
||||||
|
<q-input filled dense readonly disabled v-model.trim="props.row.contact.phone" type="text"></q-input>
|
||||||
|
</div>
|
||||||
|
<div class="col-3 col-sm-1"></div>
|
||||||
|
</div>
|
||||||
|
<div v-if="props.row.contact.email" class="row items-center no-wrap q-mb-md">
|
||||||
|
<div class="col-3 q-pr-lg">Email:</div>
|
||||||
|
<div class="col-6 col-sm-8 q-pr-lg">
|
||||||
|
<q-input filled dense readonly disabled v-model.trim="props.row.contact.email" type="text"></q-input>
|
||||||
|
</div>
|
||||||
|
<div class="col-3 col-sm-1"></div>
|
||||||
|
</div>
|
||||||
|
<div class="row items-center no-wrap q-mb-md">
|
||||||
|
<div class="col-3 q-pr-lg">Shipping Zone:</div>
|
||||||
|
<div class="col-6 col-sm-8 q-pr-lg">
|
||||||
|
<q-select :options="getStallZones(props.row.stall_id)" filled dense emit-value
|
||||||
|
v-model.trim="props.row.shipping_id" label="Shipping Zones"></q-select>
|
||||||
|
</div>
|
||||||
|
<div class="col-3 col-sm-1"></div>
|
||||||
|
</div>
|
||||||
|
<div class="row items-center no-wrap q-mb-md">
|
||||||
|
<div class="col-3 q-pr-lg">Invoice ID:</div>
|
||||||
|
<div class="col-6 col-sm-8 q-pr-lg">
|
||||||
|
<q-input filled dense readonly disabled v-model.trim="props.row.invoice_id" type="text"></q-input>
|
||||||
|
</div>
|
||||||
|
<div class="col-3">
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row items-center no-wrap q-mb-md">
|
||||||
|
<div class="col-3 q-pr-lg"></div>
|
||||||
|
|
||||||
|
<div class="col-9">
|
||||||
|
<q-btn @click="reissueOrderInvoice(props.row)" unelevated color="primary" type="submit"
|
||||||
|
class="float-left" label="Reissue Invoice"></q-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-td>
|
||||||
|
</q-tr>
|
||||||
|
</template>
|
||||||
|
</q-table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<q-dialog v-model="showShipDialog" position="top">
|
||||||
|
<q-card v-if="selectedOrder" class="q-pa-lg q-pt-xl" style="width: 500px">
|
||||||
|
<q-form @submit="updateOrderShipped" class="q-gutter-md">
|
||||||
|
<q-input filled dense v-model.trim="shippingMessage" label="Shipping Message" type="textarea"
|
||||||
|
rows="4"></q-input>
|
||||||
|
|
||||||
|
<div class="row q-mt-lg">
|
||||||
|
<q-btn unelevated color="primary" type="submit"
|
||||||
|
:label="selectedOrder.shipped? 'Unship Order' : 'Ship Order'"></q-btn>
|
||||||
|
|
||||||
|
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
|
||||||
|
</div>
|
||||||
|
</q-form>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</div>
|
||||||
409
static/components/order-list/order-list.js
Normal file
|
|
@ -0,0 +1,409 @@
|
||||||
|
async function orderList(path) {
|
||||||
|
const template = await loadTemplateAsync(path)
|
||||||
|
Vue.component('order-list', {
|
||||||
|
name: 'order-list',
|
||||||
|
props: ['stall-id', 'customer-pubkey-filter', 'adminkey', 'inkey'],
|
||||||
|
template,
|
||||||
|
|
||||||
|
watch: {
|
||||||
|
customerPubkeyFilter: async function (n) {
|
||||||
|
this.search.publicKey = n
|
||||||
|
this.search.isPaid = { label: 'All', id: null }
|
||||||
|
this.search.isShipped = { label: 'All', id: null }
|
||||||
|
await this.getOrders()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
data: function () {
|
||||||
|
return {
|
||||||
|
orders: [],
|
||||||
|
stalls: [],
|
||||||
|
selectedOrder: null,
|
||||||
|
shippingMessage: '',
|
||||||
|
showShipDialog: false,
|
||||||
|
filter: '',
|
||||||
|
search: {
|
||||||
|
publicKey: null,
|
||||||
|
isPaid: {
|
||||||
|
label: 'All',
|
||||||
|
id: null
|
||||||
|
},
|
||||||
|
isShipped: {
|
||||||
|
label: 'All',
|
||||||
|
id: null
|
||||||
|
},
|
||||||
|
restoring: false
|
||||||
|
},
|
||||||
|
customers: [],
|
||||||
|
ternaryOptions: [
|
||||||
|
{
|
||||||
|
label: 'All',
|
||||||
|
id: null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Yes',
|
||||||
|
id: 'true'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'No',
|
||||||
|
id: 'false'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
zoneOptions: [],
|
||||||
|
ordersTable: {
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: '',
|
||||||
|
align: 'left',
|
||||||
|
label: '',
|
||||||
|
field: ''
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'id',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Order ID',
|
||||||
|
field: 'id'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'total',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Total Sats',
|
||||||
|
field: 'total'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'fiat',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Total Fiat',
|
||||||
|
field: 'fiat'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'paid',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Paid',
|
||||||
|
field: 'paid'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'shipped',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Shipped',
|
||||||
|
field: 'shipped'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'public_key',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Customer',
|
||||||
|
field: 'pubkey'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'event_created_at',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Created At',
|
||||||
|
field: 'event_created_at'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
pagination: {
|
||||||
|
rowsPerPage: 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
customerOptions: function () {
|
||||||
|
const options = this.customers.map(c => ({ label: this.buildCustomerLabel(c), value: c.public_key }))
|
||||||
|
options.unshift({ label: 'All', value: null, id: null })
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
toShortId: function (value) {
|
||||||
|
return value.substring(0, 5) + '...' + value.substring(value.length - 5)
|
||||||
|
},
|
||||||
|
formatDate: function (value) {
|
||||||
|
return Quasar.utils.date.formatDate(
|
||||||
|
new Date(value * 1000),
|
||||||
|
'YYYY-MM-DD HH:mm'
|
||||||
|
)
|
||||||
|
},
|
||||||
|
satBtc(val, showUnit = true) {
|
||||||
|
return satOrBtc(val, showUnit, true)
|
||||||
|
},
|
||||||
|
formatFiat(value, currency) {
|
||||||
|
return Math.trunc(value) + ' ' + currency
|
||||||
|
},
|
||||||
|
shortLabel(value = ''){
|
||||||
|
if (value.length <= 44) return value
|
||||||
|
return value.substring(0, 20) + '...'
|
||||||
|
},
|
||||||
|
productName: function (order, productId) {
|
||||||
|
product = order.extra.products.find(p => p.id === productId)
|
||||||
|
if (product) {
|
||||||
|
return product.name
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
},
|
||||||
|
productPrice: function (order, productId) {
|
||||||
|
product = order.extra.products.find(p => p.id === productId)
|
||||||
|
if (product) {
|
||||||
|
return `${product.price} ${order.extra.currency}`
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
},
|
||||||
|
orderTotal: function (order) {
|
||||||
|
const productCost = order.items.reduce((t, item) => {
|
||||||
|
product = order.extra.products.find(p => p.id === item.product_id)
|
||||||
|
return t + item.quantity * product.price
|
||||||
|
}, 0)
|
||||||
|
return productCost + order.extra.shipping_cost
|
||||||
|
},
|
||||||
|
getOrders: async function () {
|
||||||
|
try {
|
||||||
|
const ordersPath = this.stallId
|
||||||
|
? `stall/order/${this.stallId}`
|
||||||
|
: 'order'
|
||||||
|
|
||||||
|
const query = []
|
||||||
|
if (this.search.publicKey) {
|
||||||
|
query.push(`pubkey=${this.search.publicKey}`)
|
||||||
|
}
|
||||||
|
if (this.search.isPaid.id) {
|
||||||
|
query.push(`paid=${this.search.isPaid.id}`)
|
||||||
|
}
|
||||||
|
if (this.search.isShipped.id) {
|
||||||
|
query.push(`shipped=${this.search.isShipped.id}`)
|
||||||
|
}
|
||||||
|
const { data } = await LNbits.api.request(
|
||||||
|
'GET',
|
||||||
|
`/nostrmarket/api/v1/${ordersPath}?${query.join('&')}`,
|
||||||
|
this.inkey
|
||||||
|
)
|
||||||
|
this.orders = data.map(s => ({ ...s, expanded: false }))
|
||||||
|
} catch (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getOrder: async function (orderId) {
|
||||||
|
try {
|
||||||
|
const { data } = await LNbits.api.request(
|
||||||
|
'GET',
|
||||||
|
`/nostrmarket/api/v1/order/${orderId}`,
|
||||||
|
this.inkey
|
||||||
|
)
|
||||||
|
return { ...data, expanded: false, isNew: true }
|
||||||
|
} catch (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
restoreOrder: async function (eventId) {
|
||||||
|
console.log('### restoreOrder', eventId)
|
||||||
|
try {
|
||||||
|
this.search.restoring = true
|
||||||
|
const {data} = await LNbits.api.request(
|
||||||
|
'PUT',
|
||||||
|
`/nostrmarket/api/v1/order/restore/${eventId}`,
|
||||||
|
this.adminkey
|
||||||
|
)
|
||||||
|
await this.getOrders()
|
||||||
|
this.$q.notify({
|
||||||
|
type: 'positive',
|
||||||
|
message: 'Order restored!'
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
} catch (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
} finally {
|
||||||
|
this.search.restoring = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
restoreOrders: async function () {
|
||||||
|
try {
|
||||||
|
this.search.restoring = true
|
||||||
|
await LNbits.api.request(
|
||||||
|
'PUT',
|
||||||
|
`/nostrmarket/api/v1/orders/restore`,
|
||||||
|
this.adminkey
|
||||||
|
)
|
||||||
|
await this.getOrders()
|
||||||
|
this.$q.notify({
|
||||||
|
type: 'positive',
|
||||||
|
message: 'Orders restored!'
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
} finally {
|
||||||
|
this.search.restoring = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
reissueOrderInvoice: async function (order) {
|
||||||
|
try {
|
||||||
|
const { data } = await LNbits.api.request(
|
||||||
|
'PUT',
|
||||||
|
`/nostrmarket/api/v1/order/reissue`,
|
||||||
|
this.adminkey,
|
||||||
|
{
|
||||||
|
id: order.id,
|
||||||
|
shipping_id: order.shipping_id
|
||||||
|
}
|
||||||
|
)
|
||||||
|
this.$q.notify({
|
||||||
|
type: 'positive',
|
||||||
|
message: 'Order invoice reissued!'
|
||||||
|
})
|
||||||
|
data.expanded = order.expanded
|
||||||
|
|
||||||
|
const i = this.orders.map(o => o.id).indexOf(order.id)
|
||||||
|
if (i !== -1) {
|
||||||
|
this.orders[i] = { ...this.orders[i], ...data }
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updateOrderShipped: async function () {
|
||||||
|
this.selectedOrder.shipped = !this.selectedOrder.shipped
|
||||||
|
try {
|
||||||
|
await LNbits.api.request(
|
||||||
|
'PATCH',
|
||||||
|
`/nostrmarket/api/v1/order/${this.selectedOrder.id}`,
|
||||||
|
this.adminkey,
|
||||||
|
{
|
||||||
|
id: this.selectedOrder.id,
|
||||||
|
message: this.shippingMessage,
|
||||||
|
shipped: this.selectedOrder.shipped
|
||||||
|
}
|
||||||
|
)
|
||||||
|
this.$q.notify({
|
||||||
|
type: 'positive',
|
||||||
|
message: 'Order updated!'
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
}
|
||||||
|
this.showShipDialog = false
|
||||||
|
},
|
||||||
|
addOrder: async function (data) {
|
||||||
|
if (
|
||||||
|
!this.search.publicKey ||
|
||||||
|
this.search.publicKey === data.customerPubkey
|
||||||
|
) {
|
||||||
|
const orderData = JSON.parse(data.dm.message)
|
||||||
|
const i = this.orders.map(o => o.id).indexOf(orderData.id)
|
||||||
|
if (i === -1) {
|
||||||
|
const order = await this.getOrder(orderData.id)
|
||||||
|
this.orders.unshift(order)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
},
|
||||||
|
orderSelected: async function (orderId, eventId) {
|
||||||
|
const order = await this.getOrder(orderId)
|
||||||
|
if (!order) {
|
||||||
|
LNbits.utils
|
||||||
|
.confirmDialog(
|
||||||
|
"Order could not be found. Do you want to restore it from this direct message?"
|
||||||
|
)
|
||||||
|
.onOk(async () => {
|
||||||
|
const restoredOrder = await this.restoreOrder(eventId)
|
||||||
|
console.log('### restoredOrder', restoredOrder)
|
||||||
|
if (restoredOrder) {
|
||||||
|
restoredOrder.expanded = true
|
||||||
|
restoredOrder.isNew = false
|
||||||
|
this.orders = [restoredOrder]
|
||||||
|
}
|
||||||
|
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
order.expanded = true
|
||||||
|
order.isNew = false
|
||||||
|
this.orders = [order]
|
||||||
|
},
|
||||||
|
getZones: async function () {
|
||||||
|
try {
|
||||||
|
const { data } = await LNbits.api.request(
|
||||||
|
'GET',
|
||||||
|
'/nostrmarket/api/v1/zone',
|
||||||
|
this.inkey
|
||||||
|
)
|
||||||
|
return data.map(z => ({
|
||||||
|
id: z.id,
|
||||||
|
value: z.id,
|
||||||
|
label: z.name
|
||||||
|
? `${z.name} (${z.countries.join(', ')})`
|
||||||
|
: z.countries.join(', ')
|
||||||
|
}))
|
||||||
|
} catch (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
},
|
||||||
|
getStalls: async function (pending = false) {
|
||||||
|
try {
|
||||||
|
const { data } = await LNbits.api.request(
|
||||||
|
'GET',
|
||||||
|
`/nostrmarket/api/v1/stall?pending=${pending}`,
|
||||||
|
this.inkey
|
||||||
|
)
|
||||||
|
return data.map(s => ({ ...s, expanded: false }))
|
||||||
|
} catch (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
},
|
||||||
|
getStallZones: function (stallId) {
|
||||||
|
const stall = this.stalls.find(s => s.id === stallId)
|
||||||
|
if (!stall) return []
|
||||||
|
|
||||||
|
return this.zoneOptions.filter(z => stall.shipping_zones.find(s => s.id === z.id))
|
||||||
|
},
|
||||||
|
showShipOrderDialog: function (order) {
|
||||||
|
this.selectedOrder = order
|
||||||
|
this.shippingMessage = order.shipped
|
||||||
|
? 'The order has been shipped!'
|
||||||
|
: 'The order has NOT yet been shipped!'
|
||||||
|
|
||||||
|
// do not change the status yet
|
||||||
|
this.selectedOrder.shipped = !order.shipped
|
||||||
|
this.showShipDialog = true
|
||||||
|
},
|
||||||
|
customerSelected: function (customerPubkey) {
|
||||||
|
this.$emit('customer-selected', customerPubkey)
|
||||||
|
},
|
||||||
|
getCustomers: async function () {
|
||||||
|
try {
|
||||||
|
const { data } = await LNbits.api.request(
|
||||||
|
'GET',
|
||||||
|
'/nostrmarket/api/v1/customer',
|
||||||
|
this.inkey
|
||||||
|
)
|
||||||
|
this.customers = data
|
||||||
|
} catch (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
buildCustomerLabel: function (c) {
|
||||||
|
let label = `${c.profile.name || 'unknown'} ${c.profile.about || ''}`
|
||||||
|
if (c.unread_messages) {
|
||||||
|
label += `[new: ${c.unread_messages}]`
|
||||||
|
}
|
||||||
|
label += ` (${c.public_key.slice(0, 16)}...${c.public_key.slice(
|
||||||
|
c.public_key.length - 16
|
||||||
|
)}`
|
||||||
|
return label
|
||||||
|
},
|
||||||
|
orderPaid: function (orderId) {
|
||||||
|
const order = this.orders.find(o => o.id === orderId)
|
||||||
|
if (order) {
|
||||||
|
order.paid = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created: async function () {
|
||||||
|
if (this.stallId) {
|
||||||
|
await this.getOrders()
|
||||||
|
}
|
||||||
|
await this.getCustomers()
|
||||||
|
this.zoneOptions = await this.getZones()
|
||||||
|
this.stalls = await this.getStalls()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -1,261 +0,0 @@
|
||||||
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()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
@ -1,209 +0,0 @@
|
||||||
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()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
@ -1,173 +0,0 @@
|
||||||
window.app.component('shipping-zones', {
|
|
||||||
name: 'shipping-zones',
|
|
||||||
props: ['adminkey', 'inkey'],
|
|
||||||
template: '#shipping-zones',
|
|
||||||
delimiters: ['${', '}'],
|
|
||||||
data: function () {
|
|
||||||
return {
|
|
||||||
zones: [],
|
|
||||||
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'
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
openZoneDialog: function (data) {
|
|
||||||
data = data || {
|
|
||||||
id: null,
|
|
||||||
name: '',
|
|
||||||
countries: [],
|
|
||||||
cost: 0,
|
|
||||||
currency: 'sat'
|
|
||||||
}
|
|
||||||
this.zoneDialog.data = data
|
|
||||||
|
|
||||||
this.zoneDialog.showDialog = true
|
|
||||||
},
|
|
||||||
createZone: async function () {
|
|
||||||
try {
|
|
||||||
const {data} = await LNbits.api.request(
|
|
||||||
'POST',
|
|
||||||
'/nostrmarket/api/v1/zone',
|
|
||||||
this.adminkey,
|
|
||||||
{}
|
|
||||||
)
|
|
||||||
this.zones = data
|
|
||||||
} catch (error) {
|
|
||||||
LNbits.utils.notifyApiError(error)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
deleteShippingZone: async function () {
|
|
||||||
try {
|
|
||||||
await LNbits.api.request(
|
|
||||||
'DELETE',
|
|
||||||
`/nostrmarket/api/v1/zone/${this.zoneDialog.data.id}`,
|
|
||||||
this.adminkey
|
|
||||||
)
|
|
||||||
this.$q.notify({
|
|
||||||
type: 'positive',
|
|
||||||
message: 'Zone deleted!'
|
|
||||||
})
|
|
||||||
await this.getZones()
|
|
||||||
this.zoneDialog.showDialog = false
|
|
||||||
} catch (error) {
|
|
||||||
LNbits.utils.notifyApiError(error)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
getCurrencies() {
|
|
||||||
const currencies = window.g.allowedCurrencies || []
|
|
||||||
this.currencies = ['sat', ...currencies]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
created: async function () {
|
|
||||||
await this.getZones()
|
|
||||||
this.getCurrencies()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
@ -22,13 +22,12 @@
|
||||||
@click="openZoneDialog(zone)"
|
@click="openZoneDialog(zone)"
|
||||||
>
|
>
|
||||||
<q-item-section>
|
<q-item-section>
|
||||||
<q-item-label><span v-text="zone.name"></span></q-item-label>
|
<q-item-label>{{zone.name}}</q-item-label>
|
||||||
<q-item-label caption
|
<q-item-label caption>{{zone.countries.join(", ")}}</q-item-label>
|
||||||
><span v-text="zone.countries.join('', '')"></span
|
|
||||||
></q-item-label>
|
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
</q-item> </q-list
|
</q-item>
|
||||||
></q-btn-dropdown>
|
</q-list></q-btn-dropdown
|
||||||
|
>
|
||||||
|
|
||||||
<q-dialog v-model="zoneDialog.showDialog" position="top">
|
<q-dialog v-model="zoneDialog.showDialog" 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">
|
||||||
|
|
@ -48,36 +47,26 @@
|
||||||
label="Countries"
|
label="Countries"
|
||||||
v-model="zoneDialog.data.countries"
|
v-model="zoneDialog.data.countries"
|
||||||
></q-select>
|
></q-select>
|
||||||
<div class="row items-start">
|
<q-select
|
||||||
<div class="col q-mr-sm">
|
:disabled="!!zoneDialog.data.id"
|
||||||
<q-input
|
:readonly="!!zoneDialog.data.id"
|
||||||
filled
|
filled
|
||||||
dense
|
dense
|
||||||
label="Default shipping cost"
|
v-model="zoneDialog.data.currency"
|
||||||
fill-mask="0"
|
type="text"
|
||||||
reverse-fill-mask
|
label="Unit"
|
||||||
:step="zoneDialog.data.currency != 'sat' ? '0.01' : '1'"
|
:options="currencies"
|
||||||
type="number"
|
></q-select>
|
||||||
v-model.trim="zoneDialog.data.cost"
|
<q-input
|
||||||
:error="(zoneDialog.data.currency === 'sat' && zoneDialog.data.cost % 1 !== 0) || (zoneDialog.data.currency !== 'sat' && (zoneDialog.data.cost * 100) % 1 !== 0)"
|
filled
|
||||||
:error-message="zoneDialog.data.currency === 'sat' ? 'Satoshis must be whole numbers' : 'Maximum 2 decimal places allowed'"
|
dense
|
||||||
hint="Additional costs can be set per product"
|
:label="'Cost of Shipping (' + zoneDialog.data.currency + ') *'"
|
||||||
></q-input>
|
fill-mask="0"
|
||||||
</div>
|
reverse-fill-mask
|
||||||
<div class="col-auto">
|
:step="zoneDialog.data.currency != 'sat' ? '0.01' : '1'"
|
||||||
<q-select
|
type="number"
|
||||||
:disabled="!!zoneDialog.data.id"
|
v-model.trim="zoneDialog.data.cost"
|
||||||
:readonly="!!zoneDialog.data.id"
|
></q-input>
|
||||||
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>
|
||||||
|
|
@ -93,7 +82,7 @@
|
||||||
<q-btn
|
<q-btn
|
||||||
unelevated
|
unelevated
|
||||||
color="primary"
|
color="primary"
|
||||||
: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)"
|
:disable="!zoneDialog.data.countries || !zoneDialog.data.countries.length"
|
||||||
type="submit"
|
type="submit"
|
||||||
>Create Shipping Zone</q-btn
|
>Create Shipping Zone</q-btn
|
||||||
>
|
>
|
||||||
186
static/components/shipping-zones/shipping-zones.js
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
async function shippingZones(path) {
|
||||||
|
const template = await loadTemplateAsync(path)
|
||||||
|
Vue.component('shipping-zones', {
|
||||||
|
name: 'shipping-zones',
|
||||||
|
props: ['adminkey', 'inkey'],
|
||||||
|
template,
|
||||||
|
|
||||||
|
data: function () {
|
||||||
|
return {
|
||||||
|
zones: [],
|
||||||
|
zoneDialog: {
|
||||||
|
showDialog: false,
|
||||||
|
data: {
|
||||||
|
id: null,
|
||||||
|
name: '',
|
||||||
|
countries: [],
|
||||||
|
cost: 0,
|
||||||
|
currency: 'sat'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
currencies: [],
|
||||||
|
shippingZoneOptions: [
|
||||||
|
'Free (digital)',
|
||||||
|
'Flat rate',
|
||||||
|
'Worldwide',
|
||||||
|
'Europe',
|
||||||
|
'Australia',
|
||||||
|
'Austria',
|
||||||
|
'Belgium',
|
||||||
|
'Brazil',
|
||||||
|
'Canada',
|
||||||
|
'Denmark',
|
||||||
|
'Finland',
|
||||||
|
'France',
|
||||||
|
'Germany',
|
||||||
|
'Greece',
|
||||||
|
'Hong Kong',
|
||||||
|
'Hungary',
|
||||||
|
'Ireland',
|
||||||
|
'Indonesia',
|
||||||
|
'Israel',
|
||||||
|
'Italy',
|
||||||
|
'Japan',
|
||||||
|
'Kazakhstan',
|
||||||
|
'Korea',
|
||||||
|
'Luxembourg',
|
||||||
|
'Malaysia',
|
||||||
|
'Mexico',
|
||||||
|
'Netherlands',
|
||||||
|
'New Zealand',
|
||||||
|
'Norway',
|
||||||
|
'Poland',
|
||||||
|
'Portugal',
|
||||||
|
'Romania',
|
||||||
|
'Russia',
|
||||||
|
'Saudi Arabia',
|
||||||
|
'Singapore',
|
||||||
|
'Spain',
|
||||||
|
'Sweden',
|
||||||
|
'Switzerland',
|
||||||
|
'Thailand',
|
||||||
|
'Turkey',
|
||||||
|
'Ukraine',
|
||||||
|
'United Kingdom**',
|
||||||
|
'United States***',
|
||||||
|
'Vietnam',
|
||||||
|
'China'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
openZoneDialog: function (data) {
|
||||||
|
data = data || {
|
||||||
|
id: null,
|
||||||
|
name: '',
|
||||||
|
countries: [],
|
||||||
|
cost: 0,
|
||||||
|
currency: 'sat'
|
||||||
|
}
|
||||||
|
this.zoneDialog.data = data
|
||||||
|
|
||||||
|
this.zoneDialog.showDialog = true
|
||||||
|
},
|
||||||
|
createZone: async function () {
|
||||||
|
try {
|
||||||
|
const {data} = await LNbits.api.request(
|
||||||
|
'POST',
|
||||||
|
'/nostrmarket/api/v1/zone',
|
||||||
|
this.adminkey,
|
||||||
|
{}
|
||||||
|
)
|
||||||
|
this.zones = data
|
||||||
|
} catch (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
deleteShippingZone: async function () {
|
||||||
|
try {
|
||||||
|
await LNbits.api.request(
|
||||||
|
'DELETE',
|
||||||
|
`/nostrmarket/api/v1/zone/${this.zoneDialog.data.id}`,
|
||||||
|
this.adminkey
|
||||||
|
)
|
||||||
|
this.$q.notify({
|
||||||
|
type: 'positive',
|
||||||
|
message: 'Zone deleted!'
|
||||||
|
})
|
||||||
|
await this.getZones()
|
||||||
|
this.zoneDialog.showDialog = false
|
||||||
|
} catch (error) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created: async function () {
|
||||||
|
await this.getZones()
|
||||||
|
await this.getCurrencies()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -1,338 +0,0 @@
|
||||||
window.app.component('stall-details', {
|
|
||||||
name: 'stall-details',
|
|
||||||
template: '#stall-details',
|
|
||||||
delimiters: ['${', '}'],
|
|
||||||
props: [
|
|
||||||
'stall-id',
|
|
||||||
'adminkey',
|
|
||||||
'inkey',
|
|
||||||
'wallet-options',
|
|
||||||
'zone-options',
|
|
||||||
'currencies'
|
|
||||||
],
|
|
||||||
data: function () {
|
|
||||||
return {
|
|
||||||
tab: 'products',
|
|
||||||
stall: null,
|
|
||||||
products: [],
|
|
||||||
pendingProducts: [],
|
|
||||||
productDialog: {
|
|
||||||
showDialog: false,
|
|
||||||
showRestore: false,
|
|
||||||
url: true,
|
|
||||||
data: null
|
|
||||||
},
|
|
||||||
productsFilter: '',
|
|
||||||
productsTable: {
|
|
||||||
columns: [
|
|
||||||
{
|
|
||||||
name: 'delete',
|
|
||||||
align: 'left',
|
|
||||||
label: '',
|
|
||||||
field: ''
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'edit',
|
|
||||||
align: 'left',
|
|
||||||
label: '',
|
|
||||||
field: ''
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'activate',
|
|
||||||
align: 'left',
|
|
||||||
label: '',
|
|
||||||
field: ''
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
name: 'id',
|
|
||||||
align: 'left',
|
|
||||||
label: 'ID',
|
|
||||||
field: 'id'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'name',
|
|
||||||
align: 'left',
|
|
||||||
label: 'Name',
|
|
||||||
field: 'name'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'price',
|
|
||||||
align: 'left',
|
|
||||||
label: 'Price',
|
|
||||||
field: 'price'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'quantity',
|
|
||||||
align: 'left',
|
|
||||||
label: 'Quantity',
|
|
||||||
field: 'quantity'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
pagination: {
|
|
||||||
rowsPerPage: 10
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
filteredZoneOptions: function () {
|
|
||||||
if (!this.stall) return []
|
|
||||||
return this.zoneOptions.filter(z => z.currency === this.stall.currency)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
mapStall: function (stall) {
|
|
||||||
stall.shipping_zones.forEach(
|
|
||||||
z =>
|
|
||||||
(z.label = z.name
|
|
||||||
? `${z.name} (${z.countries.join(', ')})`
|
|
||||||
: z.countries.join(', '))
|
|
||||||
)
|
|
||||||
return stall
|
|
||||||
},
|
|
||||||
newEmtpyProductData: function () {
|
|
||||||
return {
|
|
||||||
id: null,
|
|
||||||
name: '',
|
|
||||||
categories: [],
|
|
||||||
images: [],
|
|
||||||
image: null,
|
|
||||||
price: 0,
|
|
||||||
|
|
||||||
quantity: 0,
|
|
||||||
config: {
|
|
||||||
description: '',
|
|
||||||
use_autoreply: false,
|
|
||||||
autoreply_message: '',
|
|
||||||
shipping: (this.stall.shipping_zones || []).map(z => ({
|
|
||||||
id: z.id,
|
|
||||||
name: z.name,
|
|
||||||
cost: 0
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
getStall: async function () {
|
|
||||||
try {
|
|
||||||
const {data} = await LNbits.api.request(
|
|
||||||
'GET',
|
|
||||||
'/nostrmarket/api/v1/stall/' + this.stallId,
|
|
||||||
this.inkey
|
|
||||||
)
|
|
||||||
this.stall = this.mapStall(data)
|
|
||||||
} catch (error) {
|
|
||||||
LNbits.utils.notifyApiError(error)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
updateStall: async function () {
|
|
||||||
try {
|
|
||||||
const {data} = await LNbits.api.request(
|
|
||||||
'PUT',
|
|
||||||
'/nostrmarket/api/v1/stall/' + this.stallId,
|
|
||||||
this.adminkey,
|
|
||||||
this.stall
|
|
||||||
)
|
|
||||||
this.stall = this.mapStall(data)
|
|
||||||
this.$emit('stall-updated', this.stall)
|
|
||||||
this.$q.notify({
|
|
||||||
type: 'positive',
|
|
||||||
message: 'Stall Updated',
|
|
||||||
timeout: 5000
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(error)
|
|
||||||
LNbits.utils.notifyApiError(error)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
deleteStall: function () {
|
|
||||||
LNbits.utils
|
|
||||||
.confirmDialog(
|
|
||||||
`
|
|
||||||
Products and orders will be deleted also!
|
|
||||||
Are you sure you want to delete this stall?
|
|
||||||
`
|
|
||||||
)
|
|
||||||
.onOk(async () => {
|
|
||||||
try {
|
|
||||||
await LNbits.api.request(
|
|
||||||
'DELETE',
|
|
||||||
'/nostrmarket/api/v1/stall/' + this.stallId,
|
|
||||||
this.adminkey
|
|
||||||
)
|
|
||||||
this.$emit('stall-deleted', this.stallId)
|
|
||||||
this.$q.notify({
|
|
||||||
type: 'positive',
|
|
||||||
message: 'Stall Deleted',
|
|
||||||
timeout: 5000
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(error)
|
|
||||||
LNbits.utils.notifyApiError(error)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
addProductImage: function () {
|
|
||||||
if (!isValidImageUrl(this.productDialog.data.image)) {
|
|
||||||
this.$q.notify({
|
|
||||||
type: 'warning',
|
|
||||||
message: 'Not a valid image URL',
|
|
||||||
timeout: 5000
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
getProducts: async function (pending = false) {
|
|
||||||
try {
|
|
||||||
const {data} = await LNbits.api.request(
|
|
||||||
'GET',
|
|
||||||
`/nostrmarket/api/v1/stall/product/${this.stall.id}?pending=${pending}`,
|
|
||||||
this.inkey
|
|
||||||
)
|
|
||||||
return data
|
|
||||||
} catch (error) {
|
|
||||||
LNbits.utils.notifyApiError(error)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
sendProductFormData: function () {
|
|
||||||
const data = {
|
|
||||||
stall_id: this.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
|
|
||||||
this.updateProduct(data)
|
|
||||||
} else {
|
|
||||||
this.createProduct(data)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
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(r => r.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',
|
|
||||||
timeout: 5000
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(error)
|
|
||||||
LNbits.utils.notifyApiError(error)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
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',
|
|
||||||
timeout: 5000
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(error)
|
|
||||||
LNbits.utils.notifyApiError(error)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
editProduct: async function (product) {
|
|
||||||
const emptyShipping = this.newEmtpyProductData().config.shipping
|
|
||||||
this.productDialog.data = {...product}
|
|
||||||
this.productDialog.data.config.shipping = emptyShipping.map(
|
|
||||||
shippingZone => {
|
|
||||||
const existingShippingCost = (product.config.shipping || []).find(
|
|
||||||
ps => ps.id === shippingZone.id
|
|
||||||
)
|
|
||||||
shippingZone.cost = existingShippingCost?.cost || 0
|
|
||||||
return shippingZone
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
this.productDialog.showDialog = true
|
|
||||||
},
|
|
||||||
deleteProduct: async function (productId) {
|
|
||||||
LNbits.utils
|
|
||||||
.confirmDialog('Are you sure you want to delete this product?')
|
|
||||||
.onOk(async () => {
|
|
||||||
try {
|
|
||||||
await LNbits.api.request(
|
|
||||||
'DELETE',
|
|
||||||
'/nostrmarket/api/v1/product/' + productId,
|
|
||||||
this.adminkey
|
|
||||||
)
|
|
||||||
this.products = _.reject(this.products, function (obj) {
|
|
||||||
return obj.id === productId
|
|
||||||
})
|
|
||||||
this.$q.notify({
|
|
||||||
type: 'positive',
|
|
||||||
message: 'Product deleted',
|
|
||||||
timeout: 5000
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(error)
|
|
||||||
LNbits.utils.notifyApiError(error)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
showNewProductDialog: async function (data) {
|
|
||||||
this.productDialog.data = data || this.newEmtpyProductData()
|
|
||||||
this.productDialog.showDialog = true
|
|
||||||
},
|
|
||||||
openSelectPendingProductDialog: async function () {
|
|
||||||
this.productDialog.showRestore = true
|
|
||||||
this.pendingProducts = await this.getProducts(true)
|
|
||||||
},
|
|
||||||
openRestoreProductDialog: async function (pendingProduct) {
|
|
||||||
pendingProduct.pending = true
|
|
||||||
await this.showNewProductDialog(pendingProduct)
|
|
||||||
},
|
|
||||||
restoreAllPendingProducts: async function () {
|
|
||||||
for (const p of this.pendingProducts) {
|
|
||||||
p.pending = false
|
|
||||||
await this.updateProduct(p)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
customerSelectedForOrder: function (customerPubkey) {
|
|
||||||
this.$emit('customer-selected-for-order', customerPubkey)
|
|
||||||
},
|
|
||||||
shortLabel(value = '') {
|
|
||||||
if (value.length <= 44) return value
|
|
||||||
return value.substring(0, 40) + '...'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
created: async function () {
|
|
||||||
await this.getStall()
|
|
||||||
this.products = await this.getProducts()
|
|
||||||
this.productDialog.data = this.newEmtpyProductData()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
245
static/components/stall-details/stall-details.html
Normal file
|
|
@ -0,0 +1,245 @@
|
||||||
|
<div>
|
||||||
|
<q-tabs v-model="tab" no-caps class="bg-dark text-white shadow-2">
|
||||||
|
<q-tab name="info" label="Stall Info"></q-tab>
|
||||||
|
<q-tab name="products" label="Products"></q-tab>
|
||||||
|
<q-tab name="orders" label="Orders"></q-tab>
|
||||||
|
</q-tabs>
|
||||||
|
<q-tab-panels v-model="tab">
|
||||||
|
<q-tab-panel name="info">
|
||||||
|
<div v-if="stall">
|
||||||
|
<div class="row items-center no-wrap q-mb-md">
|
||||||
|
<div class="col-3 q-pr-lg">ID:</div>
|
||||||
|
<div class="col-6 col-sm-8 q-pr-lg">
|
||||||
|
<q-input filled dense readonly disabled v-model.trim="stall.id" type="text"></q-input>
|
||||||
|
</div>
|
||||||
|
<div class="col-3 col-sm-1"></div>
|
||||||
|
</div>
|
||||||
|
<div class="row items-center no-wrap q-mb-md">
|
||||||
|
<div class="col-3 q-pr-lg">Name:</div>
|
||||||
|
<div class="col-6 col-sm-8 q-pr-lg">
|
||||||
|
<q-input filled dense v-model.trim="stall.name" type="text"></q-input>
|
||||||
|
</div>
|
||||||
|
<div class="col-3 col-sm-1"></div>
|
||||||
|
</div>
|
||||||
|
<div class="row items-center no-wrap q-mb-md">
|
||||||
|
<div class="col-3 q-pr-lg">Description:</div>
|
||||||
|
<div class="col-6 col-sm-8 q-pr-lg">
|
||||||
|
<q-input filled dense v-model.trim="stall.config.description" type="textarea" rows="3"
|
||||||
|
label="Description"></q-input>
|
||||||
|
</div>
|
||||||
|
<div class="col-3 col-sm-1"></div>
|
||||||
|
</div>
|
||||||
|
<div class="row items-center no-wrap q-mb-md">
|
||||||
|
<div class="col-3 q-pr-lg">Wallet:</div>
|
||||||
|
<div class="col-6 col-sm-8 q-pr-lg">
|
||||||
|
<q-select filled dense emit-value v-model="stall.wallet" :options="walletOptions" label="Wallet *">
|
||||||
|
</q-select>
|
||||||
|
</div>
|
||||||
|
<div class="col-3 col-sm-1"></div>
|
||||||
|
</div>
|
||||||
|
<div class="row items-center no-wrap q-mb-md">
|
||||||
|
<div class="col-3 q-pr-lg">Currency:</div>
|
||||||
|
<div class="col-6 col-sm-8 q-pr-lg">
|
||||||
|
<q-select filled dense v-model="stall.currency" type="text" label="Unit" :options="currencies"></q-select>
|
||||||
|
</div>
|
||||||
|
<div class="col-3 col-sm-1"></div>
|
||||||
|
</div>
|
||||||
|
<div class="row items-center no-wrap q-mb-md">
|
||||||
|
<div class="col-3 q-pr-lg">Shipping Zones:</div>
|
||||||
|
<div class="col-6 col-sm-8 q-pr-lg">
|
||||||
|
<q-select :options="filteredZoneOptions" filled dense multiple v-model.trim="stall.shipping_zones"
|
||||||
|
label="Shipping Zones"></q-select>
|
||||||
|
</div>
|
||||||
|
<div class="col-3 col-sm-1"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row items-center q-mt-xl">
|
||||||
|
<div class="col-6 q-pr-lg">
|
||||||
|
<q-btn outline unelevated class="float-left" color="primary" @click="updateStall()">Update Stall</q-btn>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<q-btn outline unelevated icon="cancel" class="float-right" @click="deleteStall()">Delete Stall</q-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-tab-panel>
|
||||||
|
<q-tab-panel name="products">
|
||||||
|
<div v-if="stall">
|
||||||
|
<div class="row items-center no-wrap q-mb-md">
|
||||||
|
<div class="col-3 q-pr-lg">
|
||||||
|
|
||||||
|
<q-btn-dropdown @click="showNewProductDialog()" outline unelevated split class="float-left" color="primary"
|
||||||
|
label="New Product">
|
||||||
|
<q-item @click="showNewProductDialog()" clickable v-close-popup>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label>New Product</q-item-label>
|
||||||
|
<q-item-label caption>Create a new product</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
<q-item @click="openSelectPendingProductDialog" clickable v-close-popup>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label>Restore Product</q-item-label>
|
||||||
|
<q-item-label caption>Restore existing product from Nostr</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-btn-dropdown>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-sm-8 q-pr-lg"></div>
|
||||||
|
<div class="col-3 col-sm-1"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row items-center no-wrap q-mb-md">
|
||||||
|
<div class="col-12">
|
||||||
|
<q-table flat dense :data="products" row-key="id" :columns="productsTable.columns"
|
||||||
|
:pagination.sync="productsTable.pagination" :filter="productsFilter">
|
||||||
|
<template v-slot:body="props">
|
||||||
|
<q-tr :props="props">
|
||||||
|
<q-td auto-width>
|
||||||
|
<q-btn size="sm" color="grey" dense @click="deleteProduct(props.row.id)" icon="delete" />
|
||||||
|
</q-td>
|
||||||
|
<q-td auto-width>
|
||||||
|
<q-btn size="sm" color="primary" dense @click="editProduct(props.row)" icon="edit" />
|
||||||
|
</q-td>
|
||||||
|
|
||||||
|
<q-td key="id" :props="props"> {{props.row.id}} </q-td>
|
||||||
|
<q-td key="name" :props="props"> {{shortLabel(props.row.name)}} </q-td>
|
||||||
|
<q-td key="price" :props="props"> {{props.row.price}} </q-td>
|
||||||
|
<q-td key="quantity" :props="props">
|
||||||
|
{{props.row.quantity}}
|
||||||
|
</q-td>
|
||||||
|
</q-tr>
|
||||||
|
</template>
|
||||||
|
</q-table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-tab-panel>
|
||||||
|
<q-tab-panel name="orders">
|
||||||
|
<div v-if="stall">
|
||||||
|
<order-list :adminkey="adminkey" :inkey="inkey" :stall-id="stallId"
|
||||||
|
@customer-selected="customerSelectedForOrder"></order-list>
|
||||||
|
</div>
|
||||||
|
</q-tab-panel>
|
||||||
|
</q-tab-panels>
|
||||||
|
<q-dialog v-model="productDialog.showDialog" position="top">
|
||||||
|
<q-card v-if="stall && productDialog.data" class="q-pa-lg q-pt-xl" style="width: 500px">
|
||||||
|
<q-form @submit="sendProductFormData" class="q-gutter-md">
|
||||||
|
<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 (' + stall.currency + ') *'" :step="stall.currency != 'sat' ? '0.01' : '1'"
|
||||||
|
:mask="stall.currency != 'sat' ? '#.##' : '#'" fill-mask="0" reverse-fill-mask></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 producsts, make them easy to search.">
|
||||||
|
<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
|
||||||
|
multiple hide-dropdown-icon input-debounce="0" new-value-mode="add-unique"
|
||||||
|
label="Categories (Hit Enter to add)" placeholder="crafts,robots,etc"></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="addProductImage" type="url"
|
||||||
|
label="Image URL">
|
||||||
|
<q-btn @click="addProductImage" dense flat icon="add"></q-btn></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>
|
||||||
|
|
||||||
|
|
||||||
|
<q-expansion-item group="advanced" label="Custom Shipping Cost"
|
||||||
|
caption="Configure custom shipping costs for this product">
|
||||||
|
<div v-for="zone of productDialog.data.config.shipping" class="row q-mb-sm q-ml-lg q-mt-sm">
|
||||||
|
<div class="col">
|
||||||
|
<span v-text="zone.name"></span>
|
||||||
|
</div>
|
||||||
|
<div class="col q-pr-md">
|
||||||
|
<q-input v-model="zone.cost" filled dense type="number" label="Extra cost">
|
||||||
|
</q-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-expansion-item>
|
||||||
|
<q-expansion-item group="advanced" label="Autoreply" caption="Autoreply when paid">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<div class="row q-mb-sm">
|
||||||
|
<div class="col">
|
||||||
|
<q-checkbox v-model="productDialog.data.config.use_autoreply" dense
|
||||||
|
label="Send a direct message when paid" class="q-ml-sm" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row q-mb-sm q-ml-sm">
|
||||||
|
<div class="col">
|
||||||
|
<q-input v-model="productDialog.data.config.autoreply_message" filled dense type="textarea" rows="5"
|
||||||
|
label="Autoreply message" hint="It can include link to a digital asset">
|
||||||
|
</q-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</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.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>
|
||||||
|
<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-section class="float-right">
|
||||||
|
<q-btn @click="deleteProduct(pendingProduct.id)" 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 products to be restored.
|
||||||
|
</div>
|
||||||
|
<div class="row q-mt-lg">
|
||||||
|
<q-btn @click="restoreAllPendingProducts" v-close-popup flat color="green">Restore All</q-btn>
|
||||||
|
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
|
||||||
|
</div>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</div>
|
||||||
328
static/components/stall-details/stall-details.js
Normal file
|
|
@ -0,0 +1,328 @@
|
||||||
|
async function stallDetails(path) {
|
||||||
|
const template = await loadTemplateAsync(path)
|
||||||
|
|
||||||
|
Vue.component('stall-details', {
|
||||||
|
name: 'stall-details',
|
||||||
|
template,
|
||||||
|
|
||||||
|
props: [
|
||||||
|
'stall-id',
|
||||||
|
'adminkey',
|
||||||
|
'inkey',
|
||||||
|
'wallet-options',
|
||||||
|
'zone-options',
|
||||||
|
'currencies'
|
||||||
|
],
|
||||||
|
data: function () {
|
||||||
|
return {
|
||||||
|
tab: 'products',
|
||||||
|
stall: null,
|
||||||
|
products: [],
|
||||||
|
pendingProducts: [],
|
||||||
|
productDialog: {
|
||||||
|
showDialog: false,
|
||||||
|
showRestore: false,
|
||||||
|
url: true,
|
||||||
|
data: null
|
||||||
|
},
|
||||||
|
productsFilter: '',
|
||||||
|
productsTable: {
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: 'delete',
|
||||||
|
align: 'left',
|
||||||
|
label: '',
|
||||||
|
field: ''
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'edit',
|
||||||
|
align: 'left',
|
||||||
|
label: '',
|
||||||
|
field: ''
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'id',
|
||||||
|
align: 'left',
|
||||||
|
label: 'ID',
|
||||||
|
field: 'id'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'name',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Name',
|
||||||
|
field: 'name'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'price',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Price',
|
||||||
|
field: 'price'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'quantity',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Quantity',
|
||||||
|
field: 'quantity'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
pagination: {
|
||||||
|
rowsPerPage: 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
filteredZoneOptions: function () {
|
||||||
|
if (!this.stall) return []
|
||||||
|
return this.zoneOptions.filter(z => z.currency === this.stall.currency)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
mapStall: function (stall) {
|
||||||
|
stall.shipping_zones.forEach(
|
||||||
|
z =>
|
||||||
|
(z.label = z.name
|
||||||
|
? `${z.name} (${z.countries.join(', ')})`
|
||||||
|
: z.countries.join(', '))
|
||||||
|
)
|
||||||
|
return stall
|
||||||
|
},
|
||||||
|
newEmtpyProductData: function() {
|
||||||
|
return {
|
||||||
|
id: null,
|
||||||
|
name: '',
|
||||||
|
categories: [],
|
||||||
|
images: [],
|
||||||
|
image: null,
|
||||||
|
price: 0,
|
||||||
|
|
||||||
|
quantity: 0,
|
||||||
|
config: {
|
||||||
|
description: '',
|
||||||
|
use_autoreply: false,
|
||||||
|
autoreply_message: '',
|
||||||
|
shipping: (this.stall.shipping_zones || []).map(z => ({id: z.id, name: z.name, cost: 0}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getStall: async function () {
|
||||||
|
try {
|
||||||
|
const { data } = await LNbits.api.request(
|
||||||
|
'GET',
|
||||||
|
'/nostrmarket/api/v1/stall/' + this.stallId,
|
||||||
|
this.inkey
|
||||||
|
)
|
||||||
|
this.stall = this.mapStall(data)
|
||||||
|
} catch (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updateStall: async function () {
|
||||||
|
try {
|
||||||
|
const { data } = await LNbits.api.request(
|
||||||
|
'PUT',
|
||||||
|
'/nostrmarket/api/v1/stall/' + this.stallId,
|
||||||
|
this.adminkey,
|
||||||
|
this.stall
|
||||||
|
)
|
||||||
|
this.stall = this.mapStall(data)
|
||||||
|
this.$emit('stall-updated', this.stall)
|
||||||
|
this.$q.notify({
|
||||||
|
type: 'positive',
|
||||||
|
message: 'Stall Updated',
|
||||||
|
timeout: 5000
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(error)
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
deleteStall: function () {
|
||||||
|
LNbits.utils
|
||||||
|
.confirmDialog(
|
||||||
|
`
|
||||||
|
Products and orders will be deleted also!
|
||||||
|
Are you sure you want to delete this stall?
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.onOk(async () => {
|
||||||
|
try {
|
||||||
|
await LNbits.api.request(
|
||||||
|
'DELETE',
|
||||||
|
'/nostrmarket/api/v1/stall/' + this.stallId,
|
||||||
|
this.adminkey
|
||||||
|
)
|
||||||
|
this.$emit('stall-deleted', this.stallId)
|
||||||
|
this.$q.notify({
|
||||||
|
type: 'positive',
|
||||||
|
message: 'Stall Deleted',
|
||||||
|
timeout: 5000
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(error)
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
addProductImage: function () {
|
||||||
|
if (!isValidImageUrl(this.productDialog.data.image)) {
|
||||||
|
this.$q.notify({
|
||||||
|
type: 'warning',
|
||||||
|
message: 'Not a valid image URL',
|
||||||
|
timeout: 5000
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getProducts: async function (pending = false) {
|
||||||
|
try {
|
||||||
|
const { data } = await LNbits.api.request(
|
||||||
|
'GET',
|
||||||
|
`/nostrmarket/api/v1/stall/product/${this.stall.id}?pending=${pending}`,
|
||||||
|
this.inkey
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
} catch (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
sendProductFormData: function () {
|
||||||
|
const data = {
|
||||||
|
stall_id: this.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
|
||||||
|
this.updateProduct(data)
|
||||||
|
} else {
|
||||||
|
this.createProduct(data)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
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(r => r.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',
|
||||||
|
timeout: 5000
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(error)
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
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',
|
||||||
|
timeout: 5000
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(error)
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
editProduct: async function (product) {
|
||||||
|
const emptyShipping = this.newEmtpyProductData().config.shipping
|
||||||
|
this.productDialog.data = { ...product }
|
||||||
|
this.productDialog.data.config.shipping = emptyShipping.map(shippingZone => {
|
||||||
|
const existingShippingCost = (product.config.shipping || []).find(ps => ps.id === shippingZone.id)
|
||||||
|
shippingZone.cost = existingShippingCost?.cost || 0
|
||||||
|
return shippingZone
|
||||||
|
})
|
||||||
|
|
||||||
|
this.productDialog.showDialog = true
|
||||||
|
},
|
||||||
|
deleteProduct: async function (productId) {
|
||||||
|
LNbits.utils
|
||||||
|
.confirmDialog('Are you sure you want to delete this product?')
|
||||||
|
.onOk(async () => {
|
||||||
|
try {
|
||||||
|
await LNbits.api.request(
|
||||||
|
'DELETE',
|
||||||
|
'/nostrmarket/api/v1/product/' + productId,
|
||||||
|
this.adminkey
|
||||||
|
)
|
||||||
|
this.products = _.reject(this.products, function (obj) {
|
||||||
|
return obj.id === productId
|
||||||
|
})
|
||||||
|
this.$q.notify({
|
||||||
|
type: 'positive',
|
||||||
|
message: 'Product deleted',
|
||||||
|
timeout: 5000
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(error)
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
showNewProductDialog: async function (data) {
|
||||||
|
this.productDialog.data = data || this.newEmtpyProductData()
|
||||||
|
this.productDialog.showDialog = true
|
||||||
|
},
|
||||||
|
openSelectPendingProductDialog: async function () {
|
||||||
|
this.productDialog.showRestore = true
|
||||||
|
this.pendingProducts = await this.getProducts(true)
|
||||||
|
},
|
||||||
|
openRestoreProductDialog: async function (pendingProduct) {
|
||||||
|
pendingProduct.pending = true
|
||||||
|
await this.showNewProductDialog(pendingProduct)
|
||||||
|
},
|
||||||
|
restoreAllPendingProducts: async function () {
|
||||||
|
for (const p of this.pendingProducts){
|
||||||
|
p.pending = false
|
||||||
|
await this.updateProduct(p)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
customerSelectedForOrder: function (customerPubkey) {
|
||||||
|
this.$emit('customer-selected-for-order', customerPubkey)
|
||||||
|
},
|
||||||
|
shortLabel(value = ''){
|
||||||
|
if (value.length <= 44) return value
|
||||||
|
return value.substring(0, 40) + '...'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created: async function () {
|
||||||
|
await this.getStall()
|
||||||
|
this.products = await this.getProducts()
|
||||||
|
this.productDialog.data = this.newEmtpyProductData()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -1,305 +0,0 @@
|
||||||
window.app.component('stall-list', {
|
|
||||||
name: 'stall-list',
|
|
||||||
template: '#stall-list',
|
|
||||||
delimiters: ['${', '}'],
|
|
||||||
props: ['adminkey', 'inkey', 'wallet-options'],
|
|
||||||
data: function () {
|
|
||||||
return {
|
|
||||||
filter: '',
|
|
||||||
stalls: [],
|
|
||||||
pendingStalls: [],
|
|
||||||
currencies: [],
|
|
||||||
stallDialog: {
|
|
||||||
show: false,
|
|
||||||
showRestore: false,
|
|
||||||
data: {
|
|
||||||
name: '',
|
|
||||||
description: '',
|
|
||||||
wallet: null,
|
|
||||||
currency: 'sat',
|
|
||||||
shippingZones: []
|
|
||||||
}
|
|
||||||
},
|
|
||||||
editDialog: {
|
|
||||||
show: false,
|
|
||||||
data: {
|
|
||||||
id: '',
|
|
||||||
name: '',
|
|
||||||
description: '',
|
|
||||||
wallet: null,
|
|
||||||
currency: 'sat',
|
|
||||||
shippingZones: []
|
|
||||||
}
|
|
||||||
},
|
|
||||||
zoneOptions: [],
|
|
||||||
stallsTable: {
|
|
||||||
columns: [
|
|
||||||
{name: 'name', align: 'left', label: 'Name', field: 'name'},
|
|
||||||
{
|
|
||||||
name: 'currency',
|
|
||||||
align: 'left',
|
|
||||||
label: 'Currency',
|
|
||||||
field: 'currency'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'description',
|
|
||||||
align: 'left',
|
|
||||||
label: 'Description',
|
|
||||||
field: row => row.config?.description || ''
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'shippingZones',
|
|
||||||
align: 'left',
|
|
||||||
label: 'Shipping Zones',
|
|
||||||
field: row => row.shipping_zones?.map(z => z.name).join(', ') || ''
|
|
||||||
},
|
|
||||||
{name: 'actions', align: 'right', label: 'Actions', field: ''}
|
|
||||||
],
|
|
||||||
pagination: {
|
|
||||||
rowsPerPage: 10
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
filteredZoneOptions: function () {
|
|
||||||
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: {
|
|
||||||
emitStallCount: function () {
|
|
||||||
this.$emit('stalls-updated', this.stalls.length)
|
|
||||||
},
|
|
||||||
sendStallFormData: async function () {
|
|
||||||
const stallData = {
|
|
||||||
name: this.stallDialog.data.name,
|
|
||||||
wallet: this.stallDialog.data.wallet,
|
|
||||||
currency: this.stallDialog.data.currency,
|
|
||||||
shipping_zones: this.stallDialog.data.shippingZones,
|
|
||||||
config: {
|
|
||||||
description: this.stallDialog.data.description
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (this.stallDialog.data.id) {
|
|
||||||
stallData.id = this.stallDialog.data.id
|
|
||||||
await this.restoreStall(stallData)
|
|
||||||
} else {
|
|
||||||
await this.createStall(stallData)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
createStall: async function (stall) {
|
|
||||||
try {
|
|
||||||
const {data} = await LNbits.api.request(
|
|
||||||
'POST',
|
|
||||||
'/nostrmarket/api/v1/stall',
|
|
||||||
this.adminkey,
|
|
||||||
stall
|
|
||||||
)
|
|
||||||
this.stallDialog.show = false
|
|
||||||
this.stalls.unshift(data)
|
|
||||||
this.emitStallCount()
|
|
||||||
this.$q.notify({
|
|
||||||
type: 'positive',
|
|
||||||
message: 'Stall created!'
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
LNbits.utils.notifyApiError(error)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
restoreStall: async function (stallData) {
|
|
||||||
try {
|
|
||||||
stallData.pending = false
|
|
||||||
const {data} = await LNbits.api.request(
|
|
||||||
'PUT',
|
|
||||||
`/nostrmarket/api/v1/stall/${stallData.id}`,
|
|
||||||
this.adminkey,
|
|
||||||
stallData
|
|
||||||
)
|
|
||||||
this.stallDialog.show = false
|
|
||||||
this.stalls.unshift(data)
|
|
||||||
this.emitStallCount()
|
|
||||||
this.$q.notify({
|
|
||||||
type: 'positive',
|
|
||||||
message: 'Stall restored!'
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
LNbits.utils.notifyApiError(error)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
updateStall: async function () {
|
|
||||||
try {
|
|
||||||
const stallData = {
|
|
||||||
id: this.editDialog.data.id,
|
|
||||||
name: this.editDialog.data.name,
|
|
||||||
wallet: this.editDialog.data.wallet,
|
|
||||||
currency: this.editDialog.data.currency,
|
|
||||||
shipping_zones: this.editDialog.data.shippingZones,
|
|
||||||
config: {
|
|
||||||
description: this.editDialog.data.description
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const {data} = await LNbits.api.request(
|
|
||||||
'PUT',
|
|
||||||
`/nostrmarket/api/v1/stall/${stallData.id}`,
|
|
||||||
this.adminkey,
|
|
||||||
stallData
|
|
||||||
)
|
|
||||||
this.editDialog.show = false
|
|
||||||
const index = this.stalls.findIndex(s => s.id === data.id)
|
|
||||||
if (index !== -1) {
|
|
||||||
this.stalls.splice(index, 1, data)
|
|
||||||
}
|
|
||||||
this.emitStallCount()
|
|
||||||
this.$q.notify({
|
|
||||||
type: 'positive',
|
|
||||||
message: 'Stall updated!'
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
LNbits.utils.notifyApiError(error)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
deleteStall: async function (stall) {
|
|
||||||
try {
|
|
||||||
await LNbits.api.request(
|
|
||||||
'DELETE',
|
|
||||||
'/nostrmarket/api/v1/stall/' + 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.emitStallCount()
|
|
||||||
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 {
|
|
||||||
const {data} = await LNbits.api.request(
|
|
||||||
'GET',
|
|
||||||
`/nostrmarket/api/v1/stall?pending=${pending}`,
|
|
||||||
this.inkey
|
|
||||||
)
|
|
||||||
return data
|
|
||||||
} catch (error) {
|
|
||||||
LNbits.utils.notifyApiError(error)
|
|
||||||
}
|
|
||||||
return []
|
|
||||||
},
|
|
||||||
getZones: async function () {
|
|
||||||
try {
|
|
||||||
const {data} = await LNbits.api.request(
|
|
||||||
'GET',
|
|
||||||
'/nostrmarket/api/v1/zone',
|
|
||||||
this.inkey
|
|
||||||
)
|
|
||||||
return data.map(z => ({
|
|
||||||
...z,
|
|
||||||
label: z.name
|
|
||||||
? `${z.name} (${z.countries.join(', ')})`
|
|
||||||
: z.countries.join(', ')
|
|
||||||
}))
|
|
||||||
} catch (error) {
|
|
||||||
LNbits.utils.notifyApiError(error)
|
|
||||||
}
|
|
||||||
return []
|
|
||||||
},
|
|
||||||
openCreateStallDialog: async function (stallData) {
|
|
||||||
this.currencies = this.getCurrencies()
|
|
||||||
this.zoneOptions = await this.getZones()
|
|
||||||
if (!this.zoneOptions || !this.zoneOptions.length) {
|
|
||||||
this.$q.notify({
|
|
||||||
type: 'warning',
|
|
||||||
message: 'Please create a Shipping Zone first!'
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
this.stallDialog.data = stallData || {
|
|
||||||
name: '',
|
|
||||||
description: '',
|
|
||||||
wallet: null,
|
|
||||||
currency: 'sat',
|
|
||||||
shippingZones: []
|
|
||||||
}
|
|
||||||
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)
|
|
||||||
},
|
|
||||||
openRestoreStallDialog: async function (pendingStall) {
|
|
||||||
const shippingZonesIds = this.zoneOptions.map(z => z.id)
|
|
||||||
await this.openCreateStallDialog({
|
|
||||||
id: pendingStall.id,
|
|
||||||
name: pendingStall.name,
|
|
||||||
description: pendingStall.config?.description,
|
|
||||||
currency: pendingStall.currency,
|
|
||||||
shippingZones: (pendingStall.shipping_zones || [])
|
|
||||||
.filter(z => shippingZonesIds.indexOf(z.id) !== -1)
|
|
||||||
.map(z => ({
|
|
||||||
...z,
|
|
||||||
label: z.name
|
|
||||||
? `${z.name} (${z.countries.join(', ')})`
|
|
||||||
: z.countries.join(', ')
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
},
|
|
||||||
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
|
|
||||||
return value.substring(0, 60) + '...'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
created: async function () {
|
|
||||||
this.stalls = await this.getStalls()
|
|
||||||
this.emitStallCount()
|
|
||||||
this.currencies = this.getCurrencies()
|
|
||||||
this.zoneOptions = await this.getZones()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
117
static/components/stall-list/stall-list.html
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
<div>
|
||||||
|
<div class="row items-center no-wrap q-mb-md">
|
||||||
|
<div class="col q-pr-lg">
|
||||||
|
|
||||||
|
<q-btn-dropdown @click="openCreateStallDialog()" outline unelevated split class="float-left" color="primary"
|
||||||
|
label="New Stall (Store)">
|
||||||
|
<q-item @click="openCreateStallDialog()" clickable v-close-popup>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label>New Stall</q-item-label>
|
||||||
|
<q-item-label caption>Create a new stall</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
<q-item @click="openSelectPendingStallDialog" clickable v-close-popup>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label>Restore Stall</q-item-label>
|
||||||
|
<q-item-label caption>Restore existing stall from Nostr</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-btn-dropdown>
|
||||||
|
<q-input borderless dense debounce="300" v-model="filter" placeholder="Search" class="float-right">
|
||||||
|
<template v-slot:append>
|
||||||
|
<q-icon name="search"></q-icon>
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-table flat dense :data="stalls" row-key="id" :columns="stallsTable.columns"
|
||||||
|
:pagination.sync="stallsTable.pagination" :filter="filter">
|
||||||
|
<template v-slot:body="props">
|
||||||
|
<q-tr :props="props">
|
||||||
|
<q-td auto-width>
|
||||||
|
<q-btn size="sm" color="primary" round dense @click="props.row.expanded= !props.row.expanded"
|
||||||
|
:icon="props.row.expanded? 'remove' : 'add'" />
|
||||||
|
</q-td>
|
||||||
|
|
||||||
|
<q-td key="id" :props="props"> {{shortLabel(props.row.name)}} </q-td>
|
||||||
|
<q-td key="currency" :props="props"> {{props.row.currency}} </q-td>
|
||||||
|
<q-td key="description" :props="props">
|
||||||
|
{{shortLabel(props.row.config.description)}}
|
||||||
|
</q-td>
|
||||||
|
<q-td key="shippingZones" :props="props">
|
||||||
|
<div>
|
||||||
|
{{shortLabel(props.row.shipping_zones.filter(z => !!z.name).map(z => z.name).join(', '))}}
|
||||||
|
</div>
|
||||||
|
</q-td>
|
||||||
|
</q-tr>
|
||||||
|
<q-tr v-if="props.row.expanded" :props="props">
|
||||||
|
<q-td colspan="100%">
|
||||||
|
<div class="row items-center q-mb-lg">
|
||||||
|
<div class="col-12">
|
||||||
|
<stall-details :stall-id="props.row.id" :adminkey="adminkey" :inkey="inkey"
|
||||||
|
:wallet-options="walletOptions" :zone-options="zoneOptions" :currencies="currencies"
|
||||||
|
@stall-deleted="handleStallDeleted" @stall-updated="handleStallUpdated"
|
||||||
|
@customer-selected-for-order="customerSelectedForOrder"></stall-details>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-td>
|
||||||
|
</q-tr>
|
||||||
|
</template>
|
||||||
|
</q-table>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<q-dialog v-model="stallDialog.show" position="top">
|
||||||
|
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
|
||||||
|
<q-form @submit="sendStallFormData" class="q-gutter-md">
|
||||||
|
<q-input filled dense v-model.trim="stallDialog.data.name" label="Name"></q-input>
|
||||||
|
<q-input filled dense v-model.trim="stallDialog.data.description" type="textarea" rows="3"
|
||||||
|
label="Description"></q-input>
|
||||||
|
<q-select filled dense emit-value v-model="stallDialog.data.wallet" :options="walletOptions" label="Wallet *">
|
||||||
|
</q-select>
|
||||||
|
<q-select filled dense v-model="stallDialog.data.currency" type="text" label="Unit"
|
||||||
|
:options="currencies"></q-select>
|
||||||
|
<q-select :options="filteredZoneOptions" filled dense multiple v-model.trim="stallDialog.data.shippingZones"
|
||||||
|
label="Shipping Zones"></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">
|
||||||
|
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
|
||||||
|
</div>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
266
static/components/stall-list/stall-list.js
Normal file
|
|
@ -0,0 +1,266 @@
|
||||||
|
async function stallList(path) {
|
||||||
|
const template = await loadTemplateAsync(path)
|
||||||
|
Vue.component('stall-list', {
|
||||||
|
name: 'stall-list',
|
||||||
|
template,
|
||||||
|
|
||||||
|
props: [`adminkey`, 'inkey', 'wallet-options'],
|
||||||
|
data: function () {
|
||||||
|
return {
|
||||||
|
filter: '',
|
||||||
|
stalls: [],
|
||||||
|
pendingStalls: [],
|
||||||
|
currencies: [],
|
||||||
|
stallDialog: {
|
||||||
|
show: false,
|
||||||
|
showRestore: false,
|
||||||
|
data: {
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
wallet: null,
|
||||||
|
currency: 'sat',
|
||||||
|
shippingZones: []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
zoneOptions: [],
|
||||||
|
stallsTable: {
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: '',
|
||||||
|
align: 'left',
|
||||||
|
label: '',
|
||||||
|
field: ''
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'id',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Name',
|
||||||
|
field: 'id'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'currency',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Currency',
|
||||||
|
field: 'currency'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'description',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Description',
|
||||||
|
field: 'description'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'shippingZones',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Shipping Zones',
|
||||||
|
field: 'shippingZones'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
pagination: {
|
||||||
|
rowsPerPage: 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
filteredZoneOptions: function () {
|
||||||
|
return this.zoneOptions.filter(
|
||||||
|
z => z.currency === this.stallDialog.data.currency
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
sendStallFormData: async function () {
|
||||||
|
const stallData = {
|
||||||
|
name: this.stallDialog.data.name,
|
||||||
|
wallet: this.stallDialog.data.wallet,
|
||||||
|
currency: this.stallDialog.data.currency,
|
||||||
|
shipping_zones: this.stallDialog.data.shippingZones,
|
||||||
|
config: {
|
||||||
|
description: this.stallDialog.data.description
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.stallDialog.data.id) {
|
||||||
|
stallData.id = this.stallDialog.data.id
|
||||||
|
await this.restoreStall(stallData)
|
||||||
|
} else {
|
||||||
|
await this.createStall(stallData)
|
||||||
|
}
|
||||||
|
|
||||||
|
},
|
||||||
|
createStall: async function (stall) {
|
||||||
|
try {
|
||||||
|
const { data } = await LNbits.api.request(
|
||||||
|
'POST',
|
||||||
|
'/nostrmarket/api/v1/stall',
|
||||||
|
this.adminkey,
|
||||||
|
stall
|
||||||
|
)
|
||||||
|
this.stallDialog.show = false
|
||||||
|
data.expanded = false
|
||||||
|
this.stalls.unshift(data)
|
||||||
|
this.$q.notify({
|
||||||
|
type: 'positive',
|
||||||
|
message: 'Stall created!'
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
restoreStall: async function (stallData) {
|
||||||
|
try {
|
||||||
|
stallData.pending = false
|
||||||
|
const { data } = await LNbits.api.request(
|
||||||
|
'PUT',
|
||||||
|
`/nostrmarket/api/v1/stall/${stallData.id}`,
|
||||||
|
this.adminkey,
|
||||||
|
stallData
|
||||||
|
)
|
||||||
|
this.stallDialog.show = false
|
||||||
|
data.expanded = false
|
||||||
|
this.stalls.unshift(data)
|
||||||
|
this.$q.notify({
|
||||||
|
type: 'positive',
|
||||||
|
message: 'Stall restored!'
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
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 () {
|
||||||
|
try {
|
||||||
|
const { data } = await LNbits.api.request(
|
||||||
|
'GET',
|
||||||
|
'/nostrmarket/api/v1/currencies',
|
||||||
|
this.inkey
|
||||||
|
)
|
||||||
|
|
||||||
|
return ['sat', ...data]
|
||||||
|
} catch (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
},
|
||||||
|
getStalls: async function (pending = false) {
|
||||||
|
try {
|
||||||
|
const { data } = await LNbits.api.request(
|
||||||
|
'GET',
|
||||||
|
`/nostrmarket/api/v1/stall?pending=${pending}`,
|
||||||
|
this.inkey
|
||||||
|
)
|
||||||
|
return data.map(s => ({ ...s, expanded: false }))
|
||||||
|
} catch (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
},
|
||||||
|
getZones: async function () {
|
||||||
|
try {
|
||||||
|
const { data } = await LNbits.api.request(
|
||||||
|
'GET',
|
||||||
|
'/nostrmarket/api/v1/zone',
|
||||||
|
this.inkey
|
||||||
|
)
|
||||||
|
return data.map(z => ({
|
||||||
|
...z,
|
||||||
|
label: z.name
|
||||||
|
? `${z.name} (${z.countries.join(', ')})`
|
||||||
|
: z.countries.join(', ')
|
||||||
|
}))
|
||||||
|
} catch (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
}
|
||||||
|
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.zoneOptions = await this.getZones()
|
||||||
|
if (!this.zoneOptions || !this.zoneOptions.length) {
|
||||||
|
this.$q.notify({
|
||||||
|
type: 'warning',
|
||||||
|
message: 'Please create a Shipping Zone first!'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.stallDialog.data = stallData || {
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
wallet: null,
|
||||||
|
currency: 'sat',
|
||||||
|
shippingZones: []
|
||||||
|
}
|
||||||
|
this.stallDialog.show = true
|
||||||
|
},
|
||||||
|
openSelectPendingStallDialog: async function () {
|
||||||
|
this.stallDialog.showRestore = true
|
||||||
|
this.pendingStalls = await this.getStalls(true)
|
||||||
|
},
|
||||||
|
openRestoreStallDialog: async function (pendingStall) {
|
||||||
|
const shippingZonesIds = this.zoneOptions.map(z => z.id)
|
||||||
|
await this.openCreateStallDialog({
|
||||||
|
id: pendingStall.id,
|
||||||
|
name: pendingStall.name,
|
||||||
|
description: pendingStall.config?.description,
|
||||||
|
currency: pendingStall.currency,
|
||||||
|
shippingZones: (pendingStall.shipping_zones || [])
|
||||||
|
.filter(z => shippingZonesIds.indexOf(z.id) !== -1)
|
||||||
|
.map(z => ({
|
||||||
|
...z,
|
||||||
|
label: z.name
|
||||||
|
? `${z.name} (${z.countries.join(', ')})`
|
||||||
|
: z.countries.join(', ')
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
},
|
||||||
|
customerSelectedForOrder: function (customerPubkey) {
|
||||||
|
this.$emit('customer-selected-for-order', customerPubkey)
|
||||||
|
},
|
||||||
|
shortLabel(value = ''){
|
||||||
|
if (value.length <= 64) return value
|
||||||
|
return value.substring(0, 60) + '...'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created: async function () {
|
||||||
|
this.stalls = await this.getStalls()
|
||||||
|
this.currencies = await this.getCurrencies()
|
||||||
|
this.zoneOptions = await this.getZones()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 532 KiB |
|
Before Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 59 KiB |
|
|
@ -1,123 +0,0 @@
|
||||||
#!/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")
|
|
||||||
|
Before Width: | Height: | Size: 4.3 KiB |
|
|
@ -1,391 +1,241 @@
|
||||||
const nostr = window.NostrTools
|
const merchant = async () => {
|
||||||
|
Vue.component(VueQrcode.name, VueQrcode)
|
||||||
|
|
||||||
window.app = Vue.createApp({
|
await keyPair('static/components/key-pair/key-pair.html')
|
||||||
el: '#vue',
|
await shippingZones('static/components/shipping-zones/shipping-zones.html')
|
||||||
mixins: [window.windowMixin],
|
await stallDetails('static/components/stall-details/stall-details.html')
|
||||||
data: function () {
|
await stallList('static/components/stall-list/stall-list.html')
|
||||||
return {
|
await orderList('static/components/order-list/order-list.html')
|
||||||
activeTab: 'orders',
|
await directMessages('static/components/direct-messages/direct-messages.html')
|
||||||
selectedStallFilter: null,
|
await merchantDetails(
|
||||||
merchant: {},
|
'static/components/merchant-details/merchant-details.html'
|
||||||
shippingZones: [],
|
)
|
||||||
activeChatCustomer: '',
|
|
||||||
orderPubkey: null,
|
const nostr = window.NostrTools
|
||||||
showKeys: false,
|
|
||||||
stallCount: 0,
|
new Vue({
|
||||||
wsConnection: null,
|
el: '#vue',
|
||||||
nostrStatus: {
|
mixins: [windowMixin],
|
||||||
connected: false,
|
data: function () {
|
||||||
error: null,
|
return {
|
||||||
relays_connected: 0,
|
merchant: {},
|
||||||
relays_total: 0
|
shippingZones: [],
|
||||||
}
|
activeChatCustomer: '',
|
||||||
}
|
orderPubkey: null,
|
||||||
},
|
showKeys: false,
|
||||||
computed: {
|
importKeyDialog: {
|
||||||
nostrStatusColor: function () {
|
show: false,
|
||||||
if (this.nostrStatus.connected) {
|
data: {
|
||||||
return 'green'
|
privateKey: null
|
||||||
} else if (this.nostrStatus.warning) {
|
|
||||||
return 'orange'
|
|
||||||
}
|
|
||||||
return 'red'
|
|
||||||
},
|
|
||||||
nostrStatusLabel: function () {
|
|
||||||
return 'Connect'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
migrateKeys: async function () {
|
|
||||||
LNbits.utils
|
|
||||||
.confirmDialog(
|
|
||||||
'This will update your merchant to use your current account Nostr keypair ' +
|
|
||||||
'and republish all stalls and products under the new identity. ' +
|
|
||||||
'Existing orders and messages are preserved. Continue?'
|
|
||||||
)
|
|
||||||
.onOk(async () => {
|
|
||||||
try {
|
|
||||||
const {data} = await LNbits.api.request(
|
|
||||||
'POST',
|
|
||||||
`/nostrmarket/api/v1/merchant/${this.merchant.id}/migrate-keys`,
|
|
||||||
this.g.user.wallets[0].adminkey
|
|
||||||
)
|
|
||||||
this.merchant = data
|
|
||||||
this.$q.notify({
|
|
||||||
type: 'positive',
|
|
||||||
message: 'Merchant keys migrated and stalls republished'
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
LNbits.utils.notifyApiError(error)
|
|
||||||
}
|
}
|
||||||
})
|
},
|
||||||
},
|
wsConnection: null
|
||||||
toggleShowKeys: function () {
|
|
||||||
this.showKeys = !this.showKeys
|
|
||||||
},
|
|
||||||
toggleMerchantState: async function () {
|
|
||||||
const merchant = await this.getMerchant()
|
|
||||||
if (!merchant) {
|
|
||||||
this.$q.notify({
|
|
||||||
timeout: 5000,
|
|
||||||
type: 'warning',
|
|
||||||
message: 'Cannot fetch merchant!'
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const message = merchant.config.active
|
|
||||||
? 'New orders will not be processed. Are you sure you want to deactivate?'
|
|
||||||
: merchant.config.restore_in_progress
|
|
||||||
? 'Merchant restore from nostr in progress. Please wait!! ' +
|
|
||||||
'Activating now can lead to duplicate order processing. Click "OK" if you want to activate anyway?'
|
|
||||||
: 'Are you sure you want activate this merchant?'
|
|
||||||
|
|
||||||
LNbits.utils.confirmDialog(message).onOk(async () => {
|
|
||||||
await this.toggleMerchant()
|
|
||||||
})
|
|
||||||
},
|
|
||||||
toggleMerchant: async function () {
|
|
||||||
try {
|
|
||||||
const {data} = await LNbits.api.request(
|
|
||||||
'PUT',
|
|
||||||
`/nostrmarket/api/v1/merchant/${this.merchant.id}/toggle`,
|
|
||||||
this.g.user.wallets[0].adminkey
|
|
||||||
)
|
|
||||||
const state = data.config.active ? 'activated' : 'disabled'
|
|
||||||
this.merchant = data
|
|
||||||
this.$q.notify({
|
|
||||||
type: 'positive',
|
|
||||||
message: `'Merchant ${state}`,
|
|
||||||
timeout: 5000
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(error)
|
|
||||||
LNbits.utils.notifyApiError(error)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
handleMerchantDeleted: function () {
|
methods: {
|
||||||
this.merchant = null
|
generateKeys: async function () {
|
||||||
this.shippingZones = []
|
const privateKey = nostr.generatePrivateKey()
|
||||||
this.activeChatCustomer = ''
|
await this.createMerchant(privateKey)
|
||||||
this.showKeys = false
|
},
|
||||||
this.stallCount = 0
|
importKeys: async function () {
|
||||||
},
|
this.importKeyDialog.show = false
|
||||||
createMerchant: async function () {
|
let privateKey = this.importKeyDialog.data.privateKey
|
||||||
try {
|
if (!privateKey) {
|
||||||
const payload = {
|
return
|
||||||
config: {}
|
|
||||||
}
|
}
|
||||||
const {data} = await LNbits.api.request(
|
try {
|
||||||
'POST',
|
if (privateKey.toLowerCase().startsWith('nsec')) {
|
||||||
'/nostrmarket/api/v1/merchant',
|
privateKey = nostr.nip19.decode(privateKey).data
|
||||||
this.g.user.wallets[0].adminkey,
|
|
||||||
payload
|
|
||||||
)
|
|
||||||
this.merchant = data
|
|
||||||
this.$q.notify({
|
|
||||||
type: 'positive',
|
|
||||||
message: 'Merchant Created!'
|
|
||||||
})
|
|
||||||
this.waitForNotifications()
|
|
||||||
} catch (error) {
|
|
||||||
LNbits.utils.notifyApiError(error)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
getMerchant: async function () {
|
|
||||||
try {
|
|
||||||
const {data} = await LNbits.api.request(
|
|
||||||
'GET',
|
|
||||||
'/nostrmarket/api/v1/merchant',
|
|
||||||
this.g.user.wallets[0].inkey
|
|
||||||
)
|
|
||||||
this.merchant = data
|
|
||||||
return data
|
|
||||||
} catch (error) {
|
|
||||||
LNbits.utils.notifyApiError(error)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
customerSelectedForOrder: function (customerPubkey) {
|
|
||||||
this.activeChatCustomer = customerPubkey
|
|
||||||
},
|
|
||||||
filterOrdersForCustomer: function (customerPubkey) {
|
|
||||||
this.orderPubkey = customerPubkey
|
|
||||||
},
|
|
||||||
showOrderDetails: async function (orderData) {
|
|
||||||
await this.$refs.orderListRef.orderSelected(
|
|
||||||
orderData.orderId,
|
|
||||||
orderData.eventId
|
|
||||||
)
|
|
||||||
},
|
|
||||||
waitForNotifications: async function () {
|
|
||||||
if (!this.merchant) return
|
|
||||||
try {
|
|
||||||
const scheme = location.protocol === 'http:' ? 'ws' : 'wss'
|
|
||||||
const port = location.port ? `:${location.port}` : ''
|
|
||||||
const wsUrl = `${scheme}://${document.domain}${port}/api/v1/ws/${this.merchant.id}`
|
|
||||||
console.log('Reconnecting to websocket: ', wsUrl)
|
|
||||||
this.wsConnection = new WebSocket(wsUrl)
|
|
||||||
this.wsConnection.onmessage = async e => {
|
|
||||||
const data = JSON.parse(e.data)
|
|
||||||
if (data.type === 'dm:0') {
|
|
||||||
this.$q.notify({
|
|
||||||
timeout: 5000,
|
|
||||||
type: 'positive',
|
|
||||||
message: 'New Order'
|
|
||||||
})
|
|
||||||
|
|
||||||
await this.$refs.directMessagesRef.handleNewMessage(data)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
if (data.type === 'dm:1') {
|
} catch (error) {
|
||||||
await this.$refs.directMessagesRef.handleNewMessage(data)
|
|
||||||
await this.$refs.orderListRef.addOrder(data)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (data.type === 'dm:2') {
|
|
||||||
const orderStatus = JSON.parse(data.dm.message)
|
|
||||||
this.$q.notify({
|
|
||||||
timeout: 5000,
|
|
||||||
type: 'positive',
|
|
||||||
message: orderStatus.message
|
|
||||||
})
|
|
||||||
if (orderStatus.paid) {
|
|
||||||
await this.$refs.orderListRef.orderPaid(orderStatus.id)
|
|
||||||
}
|
|
||||||
await this.$refs.directMessagesRef.handleNewMessage(data)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (data.type === 'dm:-1') {
|
|
||||||
await this.$refs.directMessagesRef.handleNewMessage(data)
|
|
||||||
}
|
|
||||||
// order paid
|
|
||||||
// order shipped
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
this.$q.notify({
|
|
||||||
timeout: 5000,
|
|
||||||
type: 'warning',
|
|
||||||
message: 'Failed to watch for updates',
|
|
||||||
caption: `${error}`
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
checkNostrStatus: async function (showNotification = false) {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/nostrclient/api/v1/relays')
|
|
||||||
const body = await response.json()
|
|
||||||
|
|
||||||
if (response.status === 200) {
|
|
||||||
const relaysConnected = body.filter(r => r.connected).length
|
|
||||||
if (body.length === 0) {
|
|
||||||
this.nostrStatus = {
|
|
||||||
connected: false,
|
|
||||||
error: 'No relays configured in Nostr Client',
|
|
||||||
relays_connected: 0,
|
|
||||||
relays_total: 0,
|
|
||||||
warning: true
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this.nostrStatus = {
|
|
||||||
connected: true,
|
|
||||||
error: null,
|
|
||||||
relays_connected: relaysConnected,
|
|
||||||
relays_total: body.length
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this.nostrStatus = {
|
|
||||||
connected: false,
|
|
||||||
error: body.detail,
|
|
||||||
relays_connected: 0,
|
|
||||||
relays_total: 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showNotification) {
|
|
||||||
this.$q.notify({
|
this.$q.notify({
|
||||||
timeout: 3000,
|
type: 'negative',
|
||||||
type: this.nostrStatus.connected ? 'positive' : 'warning',
|
message: `${error}`
|
||||||
message: this.nostrStatus.connected ? 'Connected' : 'Disconnected',
|
|
||||||
caption: this.nostrStatus.error || undefined
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} catch (error) {
|
await this.createMerchant(privateKey)
|
||||||
console.error('Failed to check nostr status:', error)
|
},
|
||||||
this.nostrStatus = {
|
showImportKeysDialog: async function () {
|
||||||
connected: false,
|
this.importKeyDialog.show = true
|
||||||
error: error.message,
|
},
|
||||||
relays_connected: 0,
|
toggleMerchantKeys: function (value) {
|
||||||
relays_total: 0
|
this.showKeys = value
|
||||||
}
|
},
|
||||||
if (showNotification) {
|
toggleMerchantState: async function () {
|
||||||
|
const merchant = await this.getMerchant()
|
||||||
|
if (!merchant) {
|
||||||
this.$q.notify({
|
this.$q.notify({
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
type: 'negative',
|
type: 'warning',
|
||||||
message: this.nostrStatus.error
|
message: "Cannot fetch merchant!"
|
||||||
})
|
})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
const message = merchant.config.active ?
|
||||||
},
|
'New orders will not be processed. Are you sure you want to deactivate?' :
|
||||||
restartNostrConnection: async function () {
|
merchant.config.restore_in_progress ?
|
||||||
LNbits.utils
|
'Merchant restore from nostr in progress. Please wait!! ' +
|
||||||
.confirmDialog(
|
'Activating now can lead to duplicate order processing. Click "OK" if you want to activate anyway?' :
|
||||||
'Are you sure you want to reconnect to the nostrclient extension?'
|
'Are you sure you want activate this merchant?'
|
||||||
)
|
|
||||||
.onOk(async () => {
|
LNbits.utils
|
||||||
try {
|
.confirmDialog(message)
|
||||||
this.$q.notify({
|
.onOk(async () => {
|
||||||
timeout: 2000,
|
await this.toggleMerchant()
|
||||||
type: 'info',
|
})
|
||||||
message: 'Reconnecting...'
|
},
|
||||||
})
|
toggleMerchant: async function () {
|
||||||
await LNbits.api.request(
|
try {
|
||||||
'PUT',
|
const { data } = await LNbits.api.request(
|
||||||
'/nostrmarket/api/v1/restart',
|
|
||||||
this.g.user.wallets[0].adminkey
|
|
||||||
)
|
|
||||||
// Check status after restart (give time for websocket to reconnect)
|
|
||||||
setTimeout(() => this.checkNostrStatus(true), 3000)
|
|
||||||
} catch (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',
|
'PUT',
|
||||||
`/nostrmarket/api/v1/stall/${stall.id}`,
|
`/nostrmarket/api/v1/merchant/${this.merchant.id}/toggle`,
|
||||||
this.g.user.wallets[0].adminkey,
|
this.g.user.wallets[0].adminkey,
|
||||||
stall
|
|
||||||
)
|
)
|
||||||
|
const state = data.config.active ? 'activated' : 'disabled'
|
||||||
|
this.merchant = data
|
||||||
|
this.$q.notify({
|
||||||
|
type: 'positive',
|
||||||
|
message: `'Merchant ${state}`,
|
||||||
|
timeout: 5000
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(error)
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
}
|
}
|
||||||
// Fetch products from all stalls
|
},
|
||||||
let productCount = 0
|
handleMerchantDeleted: function () {
|
||||||
for (const stall of stalls) {
|
this.merchant = null
|
||||||
const {data: products} = await LNbits.api.request(
|
this.shippingZones = []
|
||||||
|
this.activeChatCustomer = ''
|
||||||
|
this.showKeys = false
|
||||||
|
},
|
||||||
|
createMerchant: async function (privateKey) {
|
||||||
|
try {
|
||||||
|
const pubkey = nostr.getPublicKey(privateKey)
|
||||||
|
const payload = {
|
||||||
|
private_key: privateKey,
|
||||||
|
public_key: pubkey,
|
||||||
|
config: {}
|
||||||
|
}
|
||||||
|
const { data } = await LNbits.api.request(
|
||||||
|
'POST',
|
||||||
|
'/nostrmarket/api/v1/merchant',
|
||||||
|
this.g.user.wallets[0].adminkey,
|
||||||
|
payload
|
||||||
|
)
|
||||||
|
this.merchant = data
|
||||||
|
this.$q.notify({
|
||||||
|
type: 'positive',
|
||||||
|
message: 'Merchant Created!'
|
||||||
|
})
|
||||||
|
this.waitForNotifications()
|
||||||
|
} catch (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getMerchant: async function () {
|
||||||
|
try {
|
||||||
|
const { data } = await LNbits.api.request(
|
||||||
'GET',
|
'GET',
|
||||||
`/nostrmarket/api/v1/stall/product/${stall.id}?pending=false`,
|
'/nostrmarket/api/v1/merchant',
|
||||||
this.g.user.wallets[0].inkey
|
this.g.user.wallets[0].inkey
|
||||||
)
|
)
|
||||||
for (const product of products) {
|
this.merchant = data
|
||||||
await LNbits.api.request(
|
return data
|
||||||
'PATCH',
|
} catch (error) {
|
||||||
`/nostrmarket/api/v1/product/${product.id}`,
|
LNbits.utils.notifyApiError(error)
|
||||||
this.g.user.wallets[0].adminkey,
|
|
||||||
product
|
|
||||||
)
|
|
||||||
productCount++
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
this.$q.notify({
|
},
|
||||||
type: 'positive',
|
customerSelectedForOrder: function (customerPubkey) {
|
||||||
message: `Published ${stalls.length} stall(s) and ${productCount} product(s) to Nostr (NIP-15)`
|
this.activeChatCustomer = customerPubkey
|
||||||
})
|
},
|
||||||
} catch (error) {
|
filterOrdersForCustomer: function (customerPubkey) {
|
||||||
LNbits.utils.notifyApiError(error)
|
this.orderPubkey = customerPubkey
|
||||||
}
|
},
|
||||||
},
|
showOrderDetails: async function (orderData) {
|
||||||
refreshNip15: async function () {
|
await this.$refs.orderListRef.orderSelected(orderData.orderId, orderData.eventId)
|
||||||
LNbits.utils
|
},
|
||||||
.confirmDialog(
|
waitForNotifications: async function () {
|
||||||
'This will sync your stalls and products from Nostr relays. Continue?'
|
if (!this.merchant) return
|
||||||
)
|
try {
|
||||||
.onOk(async () => {
|
const scheme = location.protocol === 'http:' ? 'ws' : 'wss'
|
||||||
try {
|
const port = location.port ? `:${location.port}` : ''
|
||||||
await LNbits.api.request(
|
const wsUrl = `${scheme}://${document.domain}${port}/api/v1/ws/${this.merchant.id}`
|
||||||
'PUT',
|
console.log('Reconnecting to websocket: ', wsUrl)
|
||||||
'/nostrmarket/api/v1/restart',
|
this.wsConnection = new WebSocket(wsUrl)
|
||||||
this.g.user.wallets[0].adminkey
|
this.wsConnection.onmessage = async e => {
|
||||||
)
|
const data = JSON.parse(e.data)
|
||||||
this.$q.notify({
|
if (data.type === 'dm:0') {
|
||||||
type: 'positive',
|
this.$q.notify({
|
||||||
message: 'Refreshing NIP-15 data from Nostr...'
|
timeout: 5000,
|
||||||
})
|
type: 'positive',
|
||||||
} catch (error) {
|
message: 'New Order'
|
||||||
LNbits.utils.notifyApiError(error)
|
})
|
||||||
|
|
||||||
|
await this.$refs.directMessagesRef.handleNewMessage(data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (data.type === 'dm:1') {
|
||||||
|
await this.$refs.directMessagesRef.handleNewMessage(data)
|
||||||
|
await this.$refs.orderListRef.addOrder(data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (data.type === 'dm:2') {
|
||||||
|
const orderStatus = JSON.parse(data.dm.message)
|
||||||
|
this.$q.notify({
|
||||||
|
timeout: 5000,
|
||||||
|
type: 'positive',
|
||||||
|
message: orderStatus.message
|
||||||
|
})
|
||||||
|
if (orderStatus.paid) {
|
||||||
|
await this.$refs.orderListRef.orderPaid(orderStatus.id)
|
||||||
|
}
|
||||||
|
await this.$refs.directMessagesRef.handleNewMessage(data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (data.type === 'dm:-1') {
|
||||||
|
await this.$refs.directMessagesRef.handleNewMessage(data)
|
||||||
|
}
|
||||||
|
// order paid
|
||||||
|
// order shipped
|
||||||
}
|
}
|
||||||
})
|
|
||||||
},
|
} catch (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({
|
this.$q.notify({
|
||||||
type: 'info',
|
timeout: 5000,
|
||||||
message: 'Delete NIP-15 from Nostr not yet implemented'
|
type: 'warning',
|
||||||
|
message: 'Failed to watch for updates',
|
||||||
|
caption: `${error}`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
restartNostrConnection: async function () {
|
||||||
|
LNbits.utils
|
||||||
|
.confirmDialog(
|
||||||
|
'Are you sure you want to reconnect to the nostrcient extension?'
|
||||||
|
)
|
||||||
|
.onOk(async () => {
|
||||||
|
try {
|
||||||
|
await LNbits.api.request(
|
||||||
|
'PUT',
|
||||||
|
'/nostrmarket/api/v1/restart',
|
||||||
|
this.g.user.wallets[0].adminkey
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
})
|
|
||||||
},
|
|
||||||
goToProducts: function (stallId) {
|
|
||||||
this.selectedStallFilter = stallId
|
|
||||||
this.activeTab = 'products'
|
|
||||||
},
|
|
||||||
goToOrders: function (stallId) {
|
|
||||||
this.selectedStallFilter = stallId
|
|
||||||
}
|
|
||||||
},
|
|
||||||
created: async function () {
|
|
||||||
const merchant = await this.getMerchant()
|
|
||||||
if (!merchant) {
|
|
||||||
// Auto-create merchant using the account's existing Nostr keypair
|
|
||||||
await this.createMerchant()
|
|
||||||
}
|
|
||||||
await this.checkNostrStatus()
|
|
||||||
setInterval(async () => {
|
|
||||||
if (
|
|
||||||
!this.wsConnection ||
|
|
||||||
this.wsConnection.readyState !== WebSocket.OPEN
|
|
||||||
) {
|
|
||||||
await this.waitForNotifications()
|
|
||||||
}
|
}
|
||||||
}, 1000)
|
},
|
||||||
}
|
created: async function () {
|
||||||
})
|
await this.getMerchant()
|
||||||
|
setInterval(async () => {
|
||||||
|
if (!this.wsConnection || this.wsConnection.readyState !== WebSocket.OPEN) {
|
||||||
|
await this.waitForNotifications()
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
merchant()
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ function imgSizeFit(img, maxWidth = 1024, maxHeight = 768) {
|
||||||
maxWidth / img.naturalWidth,
|
maxWidth / img.naturalWidth,
|
||||||
maxHeight / img.naturalHeight
|
maxHeight / img.naturalHeight
|
||||||
)
|
)
|
||||||
return {width: img.naturalWidth * ratio, height: img.naturalHeight * ratio}
|
return { width: img.naturalWidth * ratio, height: img.naturalHeight * ratio }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function hash(string) {
|
async function hash(string) {
|
||||||
|
|
@ -125,7 +125,7 @@ function isValidImageUrl(string) {
|
||||||
function isValidKey(key, prefix = 'n') {
|
function isValidKey(key, prefix = 'n') {
|
||||||
try {
|
try {
|
||||||
if (key && key.startsWith(prefix)) {
|
if (key && key.startsWith(prefix)) {
|
||||||
let {_, data} = NostrTools.nip19.decode(key)
|
let { _, data } = NostrTools.nip19.decode(key)
|
||||||
key = data
|
key = data
|
||||||
}
|
}
|
||||||
return isValidKeyHex(key)
|
return isValidKeyHex(key)
|
||||||
|
|
@ -143,4 +143,4 @@ function formatCurrency(value, currency) {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
currency: currency
|
currency: currency
|
||||||
}).format(value)
|
}).format(value)
|
||||||
}
|
}
|
||||||
|
|
@ -11,11 +11,11 @@
|
||||||
<script src="/nostrmarket/static/market/js/nostr.bundle.js"></script>
|
<script src="/nostrmarket/static/market/js/nostr.bundle.js"></script>
|
||||||
<script src="/nostrmarket/static/market/js/bolt11-decoder.js"></script>
|
<script src="/nostrmarket/static/market/js/bolt11-decoder.js"></script>
|
||||||
<script src="/nostrmarket/static/market/js/utils.js"></script>
|
<script src="/nostrmarket/static/market/js/utils.js"></script>
|
||||||
<link rel="icon" type="image/png" sizes="128x128" href="/nostrmarket/static/market/icons/favicon-128x128.png">
|
<link rel=icon type=image/png sizes=128x128 href="/nostrmarket/static/market/icons/favicon-128x128.png">
|
||||||
<link rel="icon" type="image/png" sizes="96x96" href="/nostrmarket/static/market/icons/favicon-96x96.png">
|
<link rel=icon type=image/png sizes=96x96 href="/nostrmarket/static/market/icons/favicon-96x96.png">
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/nostrmarket/static/market/icons/favicon-32x32.png">
|
<link rel=icon type=image/png sizes=32x32 href="/nostrmarket/static/market/icons/favicon-32x32.png">
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="/nostrmarket/static/market/icons/favicon-16x16.png">
|
<link rel=icon type=image/png sizes=16x16 href="/nostrmarket/static/market/icons/favicon-16x16.png">
|
||||||
<link rel="icon" type="image/ico" href="/nostrmarket/static/market/favicon.ico">
|
<link rel=icon type=image/ico href="/nostrmarket/static/market/favicon.ico">
|
||||||
<script type="module" crossorigin src="/nostrmarket/static/market/assets/index.923cbbf9.js"></script>
|
<script type="module" crossorigin src="/nostrmarket/static/market/assets/index.923cbbf9.js"></script>
|
||||||
<link rel="stylesheet" href="/nostrmarket/static/market/assets/index.73d462e5.css">
|
<link rel="stylesheet" href="/nostrmarket/static/market/assets/index.73d462e5.css">
|
||||||
</head>
|
</head>
|
||||||
|
|
|
||||||
|
|
@ -1,43 +1,5 @@
|
||||||
var NostrTools = window.NostrTools
|
var NostrTools = window.NostrTools
|
||||||
|
|
||||||
;(function ensureRandomUUID() {
|
|
||||||
if (!globalThis.crypto) {
|
|
||||||
globalThis.crypto = {}
|
|
||||||
}
|
|
||||||
if (!globalThis.crypto.randomUUID) {
|
|
||||||
globalThis.crypto.randomUUID = function () {
|
|
||||||
const getRandomValues = globalThis.crypto.getRandomValues
|
|
||||||
if (getRandomValues) {
|
|
||||||
const bytes = new Uint8Array(16)
|
|
||||||
getRandomValues.call(globalThis.crypto, bytes)
|
|
||||||
bytes[6] = (bytes[6] & 0x0f) | 0x40
|
|
||||||
bytes[8] = (bytes[8] & 0x3f) | 0x80
|
|
||||||
const hex = Array.from(bytes, b =>
|
|
||||||
b.toString(16).padStart(2, '0')
|
|
||||||
).join('')
|
|
||||||
return (
|
|
||||||
hex.slice(0, 8) +
|
|
||||||
'-' +
|
|
||||||
hex.slice(8, 12) +
|
|
||||||
'-' +
|
|
||||||
hex.slice(12, 16) +
|
|
||||||
'-' +
|
|
||||||
hex.slice(16, 20) +
|
|
||||||
'-' +
|
|
||||||
hex.slice(20)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
let d = Date.now()
|
|
||||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
|
|
||||||
const r = (d + Math.random() * 16) % 16 | 0
|
|
||||||
d = Math.floor(d / 16)
|
|
||||||
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
|
|
||||||
var defaultRelays = [
|
var defaultRelays = [
|
||||||
'wss://relay.damus.io',
|
'wss://relay.damus.io',
|
||||||
'wss://relay.snort.social',
|
'wss://relay.snort.social',
|
||||||
|
|
@ -82,24 +44,13 @@ function confirm(message) {
|
||||||
|
|
||||||
|
|
||||||
async function hash(string) {
|
async function hash(string) {
|
||||||
const subtle = globalThis.crypto && globalThis.crypto.subtle
|
const utf8 = new TextEncoder().encode(string)
|
||||||
if (subtle && subtle.digest) {
|
const hashBuffer = await crypto.subtle.digest('SHA-256', utf8)
|
||||||
const utf8 = new TextEncoder().encode(string)
|
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
||||||
const hashBuffer = await subtle.digest('SHA-256', utf8)
|
const hashHex = hashArray
|
||||||
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
.map(bytes => bytes.toString(16).padStart(2, '0'))
|
||||||
return hashArray.map(bytes => bytes.toString(16).padStart(2, '0')).join('')
|
.join('')
|
||||||
}
|
return hashHex
|
||||||
|
|
||||||
// Fallback for non-secure contexts where crypto.subtle is unavailable.
|
|
||||||
return fallbackHash(string)
|
|
||||||
}
|
|
||||||
|
|
||||||
function fallbackHash(string) {
|
|
||||||
let hash = 5381
|
|
||||||
for (let i = 0; i < string.length; i++) {
|
|
||||||
hash = ((hash << 5) + hash) + string.charCodeAt(i)
|
|
||||||
}
|
|
||||||
return (hash >>> 0).toString(16).padStart(8, '0')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isJson(str) {
|
function isJson(str) {
|
||||||
|
|
|
||||||
37
tasks.py
|
|
@ -1,22 +1,18 @@
|
||||||
import asyncio
|
|
||||||
import time
|
|
||||||
from asyncio import Queue
|
from asyncio import Queue
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
from lnbits.core.models import Payment
|
from lnbits.core.models import Payment
|
||||||
from lnbits.tasks import register_invoice_listener
|
from lnbits.tasks import register_invoice_listener
|
||||||
from loguru import logger
|
|
||||||
|
|
||||||
from .nostr.nostr_client import NostrClient
|
from .nostr.nostr_client import NostrClient
|
||||||
from .services import (
|
from .services import (
|
||||||
handle_order_paid,
|
handle_order_paid,
|
||||||
process_nostr_message,
|
process_nostr_message,
|
||||||
resubscribe_to_all_merchants,
|
|
||||||
subscribe_to_all_merchants,
|
subscribe_to_all_merchants,
|
||||||
)
|
)
|
||||||
|
|
||||||
HEALTH_CHECK_INTERVAL = 30 # seconds between health checks
|
|
||||||
STALE_THRESHOLD = 120 # seconds without events before resubscribing
|
|
||||||
|
|
||||||
|
|
||||||
async def wait_for_paid_invoices():
|
async def wait_for_paid_invoices():
|
||||||
invoice_queue = Queue()
|
invoice_queue = Queue()
|
||||||
|
|
@ -40,38 +36,13 @@ async def on_invoice_paid(payment: Payment) -> None:
|
||||||
|
|
||||||
|
|
||||||
async def wait_for_nostr_events(nostr_client: NostrClient):
|
async def wait_for_nostr_events(nostr_client: NostrClient):
|
||||||
logger.info("[NOSTRMARKET] Starting wait_for_nostr_events task")
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
logger.info("[NOSTRMARKET] Subscribing to all merchants...")
|
|
||||||
await subscribe_to_all_merchants()
|
await subscribe_to_all_merchants()
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
message = await nostr_client.get_event()
|
message = await nostr_client.get_event()
|
||||||
await process_nostr_message(message)
|
await process_nostr_message(message)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[NOSTRMARKET] Subscription failed. Retrying in 10s: {e}")
|
logger.warning(f"Subcription failed. Will retry in one minute: {e}")
|
||||||
await asyncio.sleep(10)
|
await asyncio.sleep(10)
|
||||||
|
|
||||||
|
|
||||||
async def subscription_health_monitor(nostr_client: NostrClient):
|
|
||||||
"""
|
|
||||||
Periodically check if events are flowing. If no events have been
|
|
||||||
received for STALE_THRESHOLD seconds, force a resubscription with
|
|
||||||
overlap to catch any missed events.
|
|
||||||
"""
|
|
||||||
logger.info("[NOSTRMARKET] Starting subscription health monitor")
|
|
||||||
while True:
|
|
||||||
await asyncio.sleep(HEALTH_CHECK_INTERVAL)
|
|
||||||
try:
|
|
||||||
if not nostr_client.is_websocket_connected:
|
|
||||||
continue
|
|
||||||
|
|
||||||
elapsed = time.time() - nostr_client.last_event_at
|
|
||||||
if nostr_client.last_event_at > 0 and elapsed > STALE_THRESHOLD:
|
|
||||||
logger.warning(
|
|
||||||
f"[NOSTRMARKET] ⚠️ No events for {elapsed:.0f}s, resubscribing..."
|
|
||||||
)
|
|
||||||
await resubscribe_to_all_merchants()
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"[NOSTRMARKET] Health monitor error: {e}")
|
|
||||||
|
|
|
||||||
|
|
@ -1,213 +1,44 @@
|
||||||
<q-expansion-item
|
<q-card>
|
||||||
icon="help_outline"
|
<q-card-section>
|
||||||
label="What is Nostr?"
|
<p>
|
||||||
header-class="text-weight-medium"
|
Nostr Market<br />
|
||||||
>
|
<small>
|
||||||
<q-card>
|
Created by,
|
||||||
<q-card-section class="text-body2">
|
|
||||||
<p>
|
|
||||||
<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"
|
||||||
target="_blank"
|
>Tal Vasconcelos</a
|
||||||
class="text-decoration-none"
|
|
||||||
>
|
>
|
||||||
<q-chip clickable icon="person">Tal Vasconcelos</q-chip>
|
|
||||||
</a>
|
|
||||||
<a
|
<a
|
||||||
href="https://github.com/arcbtc"
|
class="text-secondary"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
class="text-decoration-none"
|
style="color: unset"
|
||||||
|
href="https://github.com/benarc"
|
||||||
|
>Ben Arc</a
|
||||||
>
|
>
|
||||||
<q-chip clickable icon="person">Ben Arc</q-chip>
|
|
||||||
</a>
|
|
||||||
<a
|
<a
|
||||||
|
class="text-secondary"
|
||||||
|
target="_blank"
|
||||||
|
style="color: unset"
|
||||||
href="https://github.com/motorina0"
|
href="https://github.com/motorina0"
|
||||||
target="_blank"
|
>motorina0</a
|
||||||
class="text-decoration-none"
|
></small
|
||||||
>
|
>
|
||||||
<q-chip clickable icon="person">motorina0</q-chip>
|
</p>
|
||||||
</a>
|
<a
|
||||||
<a
|
class="text-secondary"
|
||||||
href="https://github.com/BenGWeeks"
|
target="_blank"
|
||||||
target="_blank"
|
href="/docs#/nostrmarket"
|
||||||
class="text-decoration-none"
|
class="text-white"
|
||||||
>
|
>Swagger REST API Documentation</a
|
||||||
<q-chip clickable icon="person">Ben Weeks</q-chip>
|
>
|
||||||
</a>
|
</q-card-section>
|
||||||
</div>
|
<q-card-section>
|
||||||
</q-card-section>
|
<a class="text-secondary" target="_blank" href="/nostrmarket/market"
|
||||||
</q-card>
|
><q-tooltip>Visit the market client</q-tooltip
|
||||||
</q-expansion-item>
|
><q-icon name="storefront" class="q-mr-sm"></q-icon>Market client</a
|
||||||
|
>
|
||||||
<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>
|
|
||||||
|
|
|
||||||
|
|
@ -1,191 +0,0 @@
|
||||||
<div>
|
|
||||||
<q-card>
|
|
||||||
<q-expansion-item
|
|
||||||
icon="chat"
|
|
||||||
label="Messages"
|
|
||||||
header-class="text-grey"
|
|
||||||
expand-separator
|
|
||||||
default-opened
|
|
||||||
>
|
|
||||||
<q-card-section class="q-pb-none">
|
|
||||||
<div class="row items-center q-col-gutter-sm">
|
|
||||||
<div class="col-auto">
|
|
||||||
<q-badge v-if="unreadMessages" color="primary" outline
|
|
||||||
><span v-text="unreadMessages"></span> new</q-badge
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="col-auto q-ml-auto">
|
|
||||||
<q-btn
|
|
||||||
v-if="activePublicKey"
|
|
||||||
@click="showClientOrders"
|
|
||||||
unelevated
|
|
||||||
outline
|
|
||||||
size="sm"
|
|
||||||
>Client Orders</q-btn
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</q-card-section>
|
|
||||||
<q-card-section>
|
|
||||||
<div class="row q-col-gutter-sm items-end">
|
|
||||||
<div class="col" style="min-width: 0">
|
|
||||||
<q-select
|
|
||||||
v-model="activePublicKey"
|
|
||||||
:options="customers.map(c => ({label: buildCustomerLabel(c), value: c.public_key}))"
|
|
||||||
label="Select Customer"
|
|
||||||
emit-value
|
|
||||||
@input="selectActiveCustomer()"
|
|
||||||
:display-value="activePublicKey ? buildCustomerLabel(customers.find(c => c.public_key === activePublicKey)) : ''"
|
|
||||||
class="ellipsis"
|
|
||||||
>
|
|
||||||
<template v-slot:option="scope">
|
|
||||||
<q-item v-bind="scope.itemProps">
|
|
||||||
<q-item-section>
|
|
||||||
<q-item-label>
|
|
||||||
<span v-text="scope.opt.label.split('(')[0]"></span>
|
|
||||||
</q-item-label>
|
|
||||||
<q-item-label
|
|
||||||
caption
|
|
||||||
class="text-mono"
|
|
||||||
style="word-break: break-all"
|
|
||||||
>
|
|
||||||
<span v-text="scope.opt.value"></span>
|
|
||||||
</q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
</template>
|
|
||||||
</q-select>
|
|
||||||
</div>
|
|
||||||
<div class="col-auto">
|
|
||||||
<q-btn
|
|
||||||
label="ADD"
|
|
||||||
color="primary"
|
|
||||||
unelevated
|
|
||||||
@click="showAddPublicKey = true"
|
|
||||||
>
|
|
||||||
<q-tooltip> Add a public key to chat with </q-tooltip>
|
|
||||||
</q-btn>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</q-card-section>
|
|
||||||
<q-card-section>
|
|
||||||
<div class="chat-container" ref="chatCard">
|
|
||||||
<div class="chat-box">
|
|
||||||
<div class="chat-messages" style="height: 45vh">
|
|
||||||
<q-chat-message
|
|
||||||
v-for="(dm, index) in messagesAsJson"
|
|
||||||
:key="index"
|
|
||||||
:name="dm.incoming ? 'customer': 'me'"
|
|
||||||
:sent="!dm.incoming"
|
|
||||||
:stamp="dm.dateFrom"
|
|
||||||
:bg-color="dm.incoming ? 'white' : 'light-green-2'"
|
|
||||||
:class="'chat-mesage-index-'+index"
|
|
||||||
>
|
|
||||||
<div v-if="dm.isJson">
|
|
||||||
<div v-if="dm.message.type === 0">
|
|
||||||
<strong>New order:</strong>
|
|
||||||
</div>
|
|
||||||
<div v-else-if="dm.message.type === 1">
|
|
||||||
<strong>Reply sent for order: </strong>
|
|
||||||
</div>
|
|
||||||
<div v-else-if="dm.message.type === 2">
|
|
||||||
<q-badge v-if="dm.message.paid" color="green"
|
|
||||||
>Paid
|
|
||||||
</q-badge>
|
|
||||||
<q-badge v-if="dm.message.shipped" color="green"
|
|
||||||
>Shipped
|
|
||||||
</q-badge>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span v-text="dm.message.message"></span>
|
|
||||||
<q-badge color="orange">
|
|
||||||
<span
|
|
||||||
v-text="dm.message.id"
|
|
||||||
@click="showOrderDetails(dm.message.id, dm.event_id)"
|
|
||||||
class="cursor-pointer"
|
|
||||||
></span>
|
|
||||||
</q-badge>
|
|
||||||
</div>
|
|
||||||
<q-badge
|
|
||||||
@click="showMessageRawData(index)"
|
|
||||||
class="cursor-pointer"
|
|
||||||
>...</q-badge
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div v-else><span v-text="dm.message"></span></div>
|
|
||||||
</q-chat-message>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<q-card-section>
|
|
||||||
<q-form @submit="sendDirectMesage" class="full-width chat-input">
|
|
||||||
<q-input
|
|
||||||
ref="newMessage"
|
|
||||||
v-model="newMessage"
|
|
||||||
placeholder="Message"
|
|
||||||
class="full-width"
|
|
||||||
dense
|
|
||||||
outlined
|
|
||||||
>
|
|
||||||
<template>
|
|
||||||
<q-btn
|
|
||||||
round
|
|
||||||
dense
|
|
||||||
flat
|
|
||||||
type="submit"
|
|
||||||
icon="send"
|
|
||||||
color="primary"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</q-input>
|
|
||||||
</q-form>
|
|
||||||
</q-card-section>
|
|
||||||
</div>
|
|
||||||
</q-card-section>
|
|
||||||
</q-expansion-item>
|
|
||||||
</q-card>
|
|
||||||
<div>
|
|
||||||
<q-dialog v-model="showAddPublicKey" position="top">
|
|
||||||
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
|
|
||||||
<q-form @submit="addPublicKey" class="q-gutter-md">
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
v-model.trim="newPublicKey"
|
|
||||||
label="Public Key (hex or nsec)"
|
|
||||||
></q-input>
|
|
||||||
<div class="row q-mt-lg">
|
|
||||||
<q-btn
|
|
||||||
unelevated
|
|
||||||
color="primary"
|
|
||||||
:disable="!newPublicKey"
|
|
||||||
type="submit"
|
|
||||||
>Add</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="showRawMessage" position="top">
|
|
||||||
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
|
|
||||||
<q-form>
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
type="textarea"
|
|
||||||
rows="20"
|
|
||||||
v-model.trim="rawMessage"
|
|
||||||
label="Raw Data"
|
|
||||||
></q-input>
|
|
||||||
<div class="row q-mt-lg">
|
|
||||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
|
||||||
>Close</q-btn
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</q-form>
|
|
||||||
</q-card>
|
|
||||||
</q-dialog>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
<q-dialog v-model="show" position="top">
|
|
||||||
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
|
|
||||||
<q-form @submit="saveProfile" class="q-gutter-md">
|
|
||||||
<div class="text-h6 q-mb-md">Edit Profile</div>
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
v-model.trim="formData.name"
|
|
||||||
label="Name (username)"
|
|
||||||
></q-input>
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
v-model.trim="formData.display_name"
|
|
||||||
label="Display Name"
|
|
||||||
></q-input>
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
v-model.trim="formData.about"
|
|
||||||
label="About"
|
|
||||||
type="textarea"
|
|
||||||
rows="3"
|
|
||||||
></q-input>
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
v-model.trim="formData.picture"
|
|
||||||
label="Profile Picture URL"
|
|
||||||
></q-input>
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
v-model.trim="formData.banner"
|
|
||||||
label="Banner Image URL"
|
|
||||||
></q-input>
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
v-model.trim="formData.website"
|
|
||||||
label="Website"
|
|
||||||
></q-input>
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
v-model.trim="formData.nip05"
|
|
||||||
label="NIP-05 Identifier"
|
|
||||||
></q-input>
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
v-model.trim="formData.lud16"
|
|
||||||
label="Lightning Address (lud16)"
|
|
||||||
></q-input>
|
|
||||||
<div class="row q-mt-lg">
|
|
||||||
<q-btn
|
|
||||||
unelevated
|
|
||||||
color="primary"
|
|
||||||
type="submit"
|
|
||||||
:loading="saving"
|
|
||||||
icon="publish"
|
|
||||||
>Save & Publish</q-btn
|
|
||||||
>
|
|
||||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
|
|
||||||
</div>
|
|
||||||
</q-form>
|
|
||||||
</q-card>
|
|
||||||
</q-dialog>
|
|
||||||
|
|
@ -1,271 +0,0 @@
|
||||||
<div>
|
|
||||||
<div class="row q-col-gutter-md">
|
|
||||||
<div class="col-12 col-md-8">
|
|
||||||
<q-card v-if="publicKey" flat bordered>
|
|
||||||
<q-card-section>
|
|
||||||
<div class="row items-center no-wrap q-mb-md">
|
|
||||||
<div class="col">
|
|
||||||
<span class="text-subtitle1">Merchant Profile</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row q-mb-md q-gutter-sm">
|
|
||||||
<q-btn-dropdown
|
|
||||||
split
|
|
||||||
outline
|
|
||||||
color="primary"
|
|
||||||
icon="vpn_key"
|
|
||||||
label="Keys"
|
|
||||||
>
|
|
||||||
<q-list>
|
|
||||||
<q-item clickable v-close-popup @click="showKeysDialog = true">
|
|
||||||
<q-item-section avatar>
|
|
||||||
<q-icon name="vpn_key" color="primary"></q-icon>
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section>
|
|
||||||
<q-item-label>View Keys</q-item-label>
|
|
||||||
<q-item-label caption
|
|
||||||
>Show public/private keys</q-item-label
|
|
||||||
>
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
<q-separator></q-separator>
|
|
||||||
<q-item-label header>Saved Profiles</q-item-label>
|
|
||||||
<q-item>
|
|
||||||
<q-item-section avatar>
|
|
||||||
<q-icon name="check" color="primary"></q-icon>
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section>
|
|
||||||
<q-item-label
|
|
||||||
><span
|
|
||||||
v-text="merchantConfig?.display_name || merchantConfig?.name || 'Current Profile'"
|
|
||||||
></span
|
|
||||||
></q-item-label>
|
|
||||||
<q-item-label
|
|
||||||
caption
|
|
||||||
class="text-mono"
|
|
||||||
style="font-size: 10px"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
v-text="publicKey ? publicKey.slice(0, 16) + '...' : ''"
|
|
||||||
></span>
|
|
||||||
</q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section side>
|
|
||||||
<q-btn
|
|
||||||
flat
|
|
||||||
dense
|
|
||||||
round
|
|
||||||
icon="delete"
|
|
||||||
color="negative"
|
|
||||||
size="sm"
|
|
||||||
@click.stop="removeMerchant"
|
|
||||||
>
|
|
||||||
<q-tooltip>Remove profile</q-tooltip>
|
|
||||||
</q-btn>
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
</q-list>
|
|
||||||
</q-btn-dropdown>
|
|
||||||
<q-btn
|
|
||||||
outline
|
|
||||||
color="primary"
|
|
||||||
@click="showEditProfileDialog = true"
|
|
||||||
icon="edit"
|
|
||||||
label="Edit Profile"
|
|
||||||
></q-btn>
|
|
||||||
<q-btn-dropdown
|
|
||||||
split
|
|
||||||
outline
|
|
||||||
:color="merchantActive ? 'positive' : 'negative'"
|
|
||||||
:icon="merchantActive ? 'shopping_cart' : 'pause_circle'"
|
|
||||||
:label="merchantActive ? 'Orders On' : 'Orders Off'"
|
|
||||||
@click="toggleMerchantState"
|
|
||||||
>
|
|
||||||
<q-list>
|
|
||||||
<q-item>
|
|
||||||
<q-item-section avatar>
|
|
||||||
<q-icon
|
|
||||||
:name="merchantActive ? 'check_circle' : 'pause_circle'"
|
|
||||||
:color="merchantActive ? 'positive' : 'negative'"
|
|
||||||
></q-icon>
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section>
|
|
||||||
<q-item-label v-if="merchantActive"
|
|
||||||
>Accepting Orders</q-item-label
|
|
||||||
>
|
|
||||||
<q-item-label v-else>Orders Paused</q-item-label>
|
|
||||||
<q-item-label caption v-if="merchantActive">
|
|
||||||
New orders will be processed
|
|
||||||
</q-item-label>
|
|
||||||
<q-item-label caption v-else>
|
|
||||||
New orders will be ignored
|
|
||||||
</q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
<q-separator></q-separator>
|
|
||||||
<q-item clickable v-close-popup @click="toggleMerchantState">
|
|
||||||
<q-item-section avatar>
|
|
||||||
<q-icon
|
|
||||||
:name="merchantActive ? 'pause_circle' : 'play_circle'"
|
|
||||||
:color="merchantActive ? 'negative' : 'positive'"
|
|
||||||
></q-icon>
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section>
|
|
||||||
<q-item-label v-if="merchantActive"
|
|
||||||
>Pause Orders</q-item-label
|
|
||||||
>
|
|
||||||
<q-item-label v-else>Resume Orders</q-item-label>
|
|
||||||
<q-item-label caption v-if="merchantActive">
|
|
||||||
Stop accepting new orders
|
|
||||||
</q-item-label>
|
|
||||||
<q-item-label caption v-else>
|
|
||||||
Start accepting new orders
|
|
||||||
</q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
</q-list>
|
|
||||||
</q-btn-dropdown>
|
|
||||||
<q-btn
|
|
||||||
outline
|
|
||||||
color="primary"
|
|
||||||
icon="storefront"
|
|
||||||
label="Marketplace"
|
|
||||||
:href="marketClientUrl"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
></q-btn>
|
|
||||||
</div>
|
|
||||||
</q-card-section>
|
|
||||||
|
|
||||||
<!-- Banner Section -->
|
|
||||||
<div class="q-px-md">
|
|
||||||
<div
|
|
||||||
v-if="merchantConfig && merchantConfig.banner"
|
|
||||||
class="banner-container rounded-borders"
|
|
||||||
:style="{
|
|
||||||
height: '120px',
|
|
||||||
backgroundSize: 'cover',
|
|
||||||
backgroundPosition: 'center',
|
|
||||||
backgroundImage: 'url(' + merchantConfig.banner + ')'
|
|
||||||
}"
|
|
||||||
></div>
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="banner-placeholder bg-grey-3 text-center rounded-borders"
|
|
||||||
style="height: 120px"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Profile Section -->
|
|
||||||
<q-card-section class="q-pt-none q-ml-md" style="margin-top: -50px">
|
|
||||||
<div class="row">
|
|
||||||
<!-- Profile Image -->
|
|
||||||
<div class="col-auto">
|
|
||||||
<q-avatar size="100px" color="dark" class="profile-avatar">
|
|
||||||
<img
|
|
||||||
v-if="merchantConfig && merchantConfig.picture"
|
|
||||||
:src="merchantConfig.picture"
|
|
||||||
@error="handleImageError"
|
|
||||||
style="object-fit: cover"
|
|
||||||
/>
|
|
||||||
<q-icon
|
|
||||||
v-else
|
|
||||||
name="person"
|
|
||||||
size="60px"
|
|
||||||
color="grey-5"
|
|
||||||
></q-icon>
|
|
||||||
</q-avatar>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Name, About and NIP-05 -->
|
|
||||||
<div class="col q-pl-md" style="padding-top: 55px">
|
|
||||||
<div class="row items-center">
|
|
||||||
<div class="col">
|
|
||||||
<div
|
|
||||||
class="text-h6"
|
|
||||||
v-if="merchantConfig && merchantConfig.display_name"
|
|
||||||
>
|
|
||||||
<span v-text="merchantConfig.display_name"></span>
|
|
||||||
</div>
|
|
||||||
<div class="text-caption text-grey" v-else>
|
|
||||||
(No display name set)
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- TODO: Unhide when following/followers is implemented -->
|
|
||||||
<div v-if="false" class="col-auto q-mr-sm">
|
|
||||||
<div class="row q-gutter-md">
|
|
||||||
<div class="text-caption text-grey-6">
|
|
||||||
<span class="text-weight-bold">0</span> Following
|
|
||||||
<q-tooltip>Not implemented yet</q-tooltip>
|
|
||||||
</div>
|
|
||||||
<div class="text-caption text-grey-6">
|
|
||||||
<span class="text-weight-bold">0</span> Followers
|
|
||||||
<q-tooltip>Not implemented yet</q-tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="text-body2 text-grey-8 q-mt-xs"
|
|
||||||
v-if="merchantConfig && merchantConfig.about"
|
|
||||||
style="max-width: 400px"
|
|
||||||
>
|
|
||||||
<span v-text="merchantConfig.about"></span>
|
|
||||||
</div>
|
|
||||||
<div class="row q-mt-xs q-gutter-sm">
|
|
||||||
<div
|
|
||||||
class="text-caption text-grey-5"
|
|
||||||
v-if="merchantConfig && merchantConfig.nip05"
|
|
||||||
>
|
|
||||||
<q-icon name="verified" color="primary" size="14px"></q-icon>
|
|
||||||
<span v-text="merchantConfig.nip05" class="q-ml-xs"></span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="text-caption text-grey-5"
|
|
||||||
v-if="merchantConfig && merchantConfig.lud16"
|
|
||||||
>
|
|
||||||
<q-icon name="bolt" color="warning" size="14px"></q-icon>
|
|
||||||
<span v-text="merchantConfig.lud16" class="q-ml-xs"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</q-card-section>
|
|
||||||
|
|
||||||
<div class="row items-center q-px-md">
|
|
||||||
<q-separator class="col"></q-separator>
|
|
||||||
<q-btn fab icon="add" color="primary" class="q-ml-md" disable>
|
|
||||||
<q-tooltip>New Post (Coming soon)</q-tooltip>
|
|
||||||
</q-btn>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Feed Section (Not Implemented) -->
|
|
||||||
<q-card-section>
|
|
||||||
<div class="text-center q-pa-lg" style="opacity: 0.5">
|
|
||||||
<q-icon name="chat" size="48px" class="q-mb-sm text-grey"></q-icon>
|
|
||||||
<div class="text-subtitle2 text-grey">Coming Soon</div>
|
|
||||||
<div class="text-caption text-grey">
|
|
||||||
Merchant posts will appear here
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</q-card-section>
|
|
||||||
</q-card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Edit Profile Dialog -->
|
|
||||||
<edit-profile-dialog
|
|
||||||
v-model="showEditProfileDialog"
|
|
||||||
:merchant-id="merchantId"
|
|
||||||
:merchant-config="merchantConfig"
|
|
||||||
:adminkey="adminkey"
|
|
||||||
@profile-updated="$emit('profile-updated')"
|
|
||||||
></edit-profile-dialog>
|
|
||||||
|
|
||||||
<!-- Nostr Keys Dialog -->
|
|
||||||
<nostr-keys-dialog
|
|
||||||
v-model="showKeysDialog"
|
|
||||||
:public-key="publicKey"
|
|
||||||
:private-key="privateKey"
|
|
||||||
></nostr-keys-dialog>
|
|
||||||
</div>
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
<q-dialog v-model="show">
|
|
||||||
<q-card style="min-width: 400px; max-width: 450px">
|
|
||||||
<q-card-section>
|
|
||||||
<div class="text-h6">Nostr Keys</div>
|
|
||||||
</q-card-section>
|
|
||||||
<q-card-section>
|
|
||||||
<!-- QR Code for npub only -->
|
|
||||||
<div class="q-mx-auto q-mb-md text-center" style="max-width: 200px">
|
|
||||||
<lnbits-qrcode
|
|
||||||
:value="npub"
|
|
||||||
:options="{ width: 200 }"
|
|
||||||
:show-buttons="false"
|
|
||||||
class="rounded-borders"
|
|
||||||
></lnbits-qrcode>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Public Key (npub) -->
|
|
||||||
<div class="text-subtitle2 q-mb-xs">
|
|
||||||
<q-icon name="public" class="q-mr-xs"></q-icon>
|
|
||||||
Public Key (npub)
|
|
||||||
</div>
|
|
||||||
<q-input :model-value="npub" readonly dense outlined class="q-mb-md">
|
|
||||||
<template v-slot:append>
|
|
||||||
<q-btn
|
|
||||||
flat
|
|
||||||
dense
|
|
||||||
icon="content_copy"
|
|
||||||
@click="copyText(npub, 'npub copied!')"
|
|
||||||
>
|
|
||||||
<q-tooltip>Copy npub</q-tooltip>
|
|
||||||
</q-btn>
|
|
||||||
</template>
|
|
||||||
</q-input>
|
|
||||||
|
|
||||||
<!-- Private Key (nsec) -->
|
|
||||||
<div class="text-subtitle2 q-mb-xs text-warning">
|
|
||||||
<q-icon name="warning" class="q-mr-xs"></q-icon>
|
|
||||||
Private Key (nsec)
|
|
||||||
</div>
|
|
||||||
<q-input
|
|
||||||
:model-value="showNsec ? nsec : '••••••••••••••••••••••••••••••••••••••••••••••'"
|
|
||||||
readonly
|
|
||||||
dense
|
|
||||||
outlined
|
|
||||||
class="q-mb-xs"
|
|
||||||
>
|
|
||||||
<template v-slot:append>
|
|
||||||
<q-btn
|
|
||||||
flat
|
|
||||||
dense
|
|
||||||
:icon="showNsec ? 'visibility_off' : 'visibility'"
|
|
||||||
@click="showNsec = !showNsec"
|
|
||||||
>
|
|
||||||
<q-tooltip v-text="showNsec ? 'Hide' : 'Show'"></q-tooltip>
|
|
||||||
</q-btn>
|
|
||||||
<q-btn
|
|
||||||
flat
|
|
||||||
dense
|
|
||||||
icon="content_copy"
|
|
||||||
@click="copyText(nsec, 'nsec copied! Keep it secret!')"
|
|
||||||
>
|
|
||||||
<q-tooltip>Copy nsec</q-tooltip>
|
|
||||||
</q-btn>
|
|
||||||
</template>
|
|
||||||
</q-input>
|
|
||||||
<div class="text-caption text-negative">
|
|
||||||
<q-icon name="error" size="14px"></q-icon>
|
|
||||||
Never share your private key!
|
|
||||||
</div>
|
|
||||||
</q-card-section>
|
|
||||||
<q-card-actions align="right" class="q-pa-md">
|
|
||||||
<q-btn flat label="CLOSE" color="grey" v-close-popup></q-btn>
|
|
||||||
</q-card-actions>
|
|
||||||
</q-card>
|
|
||||||
</q-dialog>
|
|
||||||
|
|
@ -1,369 +0,0 @@
|
||||||
<div>
|
|
||||||
<div class="row q-mb-md">
|
|
||||||
<div class="col-md-4 col-sm-6 q-pr-lg">
|
|
||||||
<q-select
|
|
||||||
v-model="search.publicKey"
|
|
||||||
:options="customerOptions"
|
|
||||||
label="Customer"
|
|
||||||
emit-value
|
|
||||||
class="text-wrap"
|
|
||||||
>
|
|
||||||
</q-select>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-2 col-sm-6 q-pr-lg">
|
|
||||||
<q-select
|
|
||||||
v-model="search.isPaid"
|
|
||||||
:options="ternaryOptions"
|
|
||||||
label="Paid"
|
|
||||||
emit-value
|
|
||||||
>
|
|
||||||
</q-select>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-2 col-sm-6 q-pr-lg">
|
|
||||||
<q-select
|
|
||||||
v-model="search.isShipped"
|
|
||||||
:options="ternaryOptions"
|
|
||||||
label="Shipped"
|
|
||||||
emit-value
|
|
||||||
>
|
|
||||||
</q-select>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4 col-sm-6">
|
|
||||||
<q-btn-dropdown
|
|
||||||
@click="getOrders()"
|
|
||||||
:disable="search.restoring"
|
|
||||||
outline
|
|
||||||
unelevated
|
|
||||||
split
|
|
||||||
class="q-pt-md float-right"
|
|
||||||
:label="search.restoring ? 'Restoring Orders...' : 'Load Orders'"
|
|
||||||
>
|
|
||||||
<q-spinner
|
|
||||||
v-if="search.restoring"
|
|
||||||
color="primary"
|
|
||||||
size="2.55em"
|
|
||||||
class="q-pt-md float-right"
|
|
||||||
></q-spinner>
|
|
||||||
<q-item @click="restoreOrders" clickable v-close-popup>
|
|
||||||
<q-item-section>
|
|
||||||
<q-item-label>Restore Orders</q-item-label>
|
|
||||||
<q-item-label caption
|
|
||||||
>Restore previous orders from Nostr</q-item-label
|
|
||||||
>
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
</q-btn-dropdown>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row q-mt-md">
|
|
||||||
<div class="col">
|
|
||||||
<q-table
|
|
||||||
flat
|
|
||||||
dense
|
|
||||||
:rows="orders"
|
|
||||||
row-key="id"
|
|
||||||
:columns="ordersTable.columns"
|
|
||||||
v-model:pagination="ordersTable.pagination"
|
|
||||||
:filter="filter"
|
|
||||||
>
|
|
||||||
<template v-slot:body="props">
|
|
||||||
<q-tr :props="props">
|
|
||||||
<q-td auto-width>
|
|
||||||
<q-btn
|
|
||||||
size="sm"
|
|
||||||
color="primary"
|
|
||||||
round
|
|
||||||
dense
|
|
||||||
@click="props.row.expanded= !props.row.expanded"
|
|
||||||
:icon="props.row.expanded? 'remove' : 'add'"
|
|
||||||
/>
|
|
||||||
</q-td>
|
|
||||||
|
|
||||||
<q-td key="id" :props="props">
|
|
||||||
<span v-text="toShortId(props.row.id)"></span>
|
|
||||||
|
|
||||||
<q-badge v-if="props.row.isNew" color="orange">new</q-badge></q-td
|
|
||||||
>
|
|
||||||
<q-td key="total" :props="props">
|
|
||||||
<span v-text="satBtc(props.row.total)"></span>
|
|
||||||
</q-td>
|
|
||||||
<q-td key="fiat" :props="props">
|
|
||||||
<span v-if="props.row.extra.currency !== 'sat'">
|
|
||||||
<span v-text="orderTotal(props.row)"></span
|
|
||||||
><span v-text="props.row.extra.currency"></span>
|
|
||||||
</span>
|
|
||||||
</q-td>
|
|
||||||
|
|
||||||
<q-td key="paid" :props="props">
|
|
||||||
<q-checkbox
|
|
||||||
v-model="props.row.paid"
|
|
||||||
:label="props.row.paid ? 'Yes' : 'No'"
|
|
||||||
disable
|
|
||||||
readonly
|
|
||||||
size="sm"
|
|
||||||
></q-checkbox>
|
|
||||||
</q-td>
|
|
||||||
<q-td key="shipped" :props="props">
|
|
||||||
<q-checkbox
|
|
||||||
v-model="props.row.shipped"
|
|
||||||
@update:model-value="showShipOrderDialog(props.row)"
|
|
||||||
:label="props.row.shipped ? 'Yes' : 'No'"
|
|
||||||
size="sm"
|
|
||||||
></q-checkbox>
|
|
||||||
</q-td>
|
|
||||||
|
|
||||||
<q-td key="public_key" :props="props">
|
|
||||||
<span
|
|
||||||
@click="customerSelected(props.row.public_key)"
|
|
||||||
class="cursor-pointer"
|
|
||||||
>
|
|
||||||
<span v-text="toShortId(props.row.public_key)"></span>
|
|
||||||
</span>
|
|
||||||
</q-td>
|
|
||||||
<q-td key="event_created_at" :props="props">
|
|
||||||
<span v-text="formatDate(props.row.event_created_at)"></span>
|
|
||||||
</q-td>
|
|
||||||
</q-tr>
|
|
||||||
<q-tr v-if="props.row.expanded" :props="props">
|
|
||||||
<q-td colspan="100%">
|
|
||||||
<div class="row items-center no-wrap">
|
|
||||||
<div class="col-3 q-pr-lg">Products:</div>
|
|
||||||
<div class="col-8">
|
|
||||||
<div class="row items-center no-wrap q-mb-md">
|
|
||||||
<div class="col-1"><strong>Quantity</strong></div>
|
|
||||||
<div class="col-1"></div>
|
|
||||||
<div class="col-4"><strong>Name</strong></div>
|
|
||||||
<div class="col-2"><strong>Price</strong></div>
|
|
||||||
<div class="col-4"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-1"></div>
|
|
||||||
</div>
|
|
||||||
<div class="row items-center no-wrap q-mb-md">
|
|
||||||
<div class="col-3 q-pr-lg"></div>
|
|
||||||
<div class="col-8">
|
|
||||||
<div
|
|
||||||
v-for="item in props.row.items"
|
|
||||||
class="row items-center no-wrap q-mb-md"
|
|
||||||
>
|
|
||||||
<div class="col-1">
|
|
||||||
<span v-text="item.quantity"></span>
|
|
||||||
</div>
|
|
||||||
<div class="col-1">x</div>
|
|
||||||
<div class="col-4">
|
|
||||||
<p :title="productName(props.row, item.product_id)">
|
|
||||||
<span
|
|
||||||
v-text="shortLabel(productName(props.row, item.product_id))"
|
|
||||||
></span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="col-2">
|
|
||||||
<span
|
|
||||||
v-text="productPrice(props.row, item.product_id)"
|
|
||||||
></span>
|
|
||||||
</div>
|
|
||||||
<div class="col-4"></div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="props.row.extra.shipping_cost"
|
|
||||||
class="row items-center no-wrap q-mb-md"
|
|
||||||
>
|
|
||||||
<div class="col-1"></div>
|
|
||||||
<div class="col-1"></div>
|
|
||||||
<div class="col-4">Shipping Cost</div>
|
|
||||||
<div class="col-2">
|
|
||||||
<span v-text="props.row.extra.shipping_cost"></span>
|
|
||||||
<span v-text="props.row.extra.currency"></span>
|
|
||||||
</div>
|
|
||||||
<div class="col-4"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-1"></div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="props.row.extra.currency !== 'sat'"
|
|
||||||
class="row items-center no-wrap q-mb-md q-mt-md"
|
|
||||||
>
|
|
||||||
<div class="col-3 q-pr-lg">Exchange Rate (1 BTC):</div>
|
|
||||||
<div class="col-6 col-sm-8 q-pr-lg">
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
readonly
|
|
||||||
disabled
|
|
||||||
:value="formatFiat(props.row.extra.btc_price, props.row.extra.currency)"
|
|
||||||
type="text"
|
|
||||||
></q-input>
|
|
||||||
</div>
|
|
||||||
<div class="col-3 col-sm-1"></div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="props.row.extra.fail_message"
|
|
||||||
class="row items-center no-wrap q-mb-md"
|
|
||||||
>
|
|
||||||
<div class="col-3 q-pr-lg">Error:</div>
|
|
||||||
<div class="col-6 col-sm-8 q-pr-lg">
|
|
||||||
<q-badge color="pink"
|
|
||||||
><span v-text="props.row.extra.fail_message"></span
|
|
||||||
></q-badge>
|
|
||||||
</div>
|
|
||||||
<div class="col-3 col-sm-1"></div>
|
|
||||||
</div>
|
|
||||||
<div class="row items-center no-wrap q-mb-md q-mt-md">
|
|
||||||
<div class="col-3 q-pr-lg">Order ID:</div>
|
|
||||||
<div class="col-6 col-sm-8 q-pr-lg">
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
readonly
|
|
||||||
disabled
|
|
||||||
v-model.trim="props.row.id"
|
|
||||||
type="text"
|
|
||||||
></q-input>
|
|
||||||
</div>
|
|
||||||
<div class="col-3 col-sm-1"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row items-center no-wrap q-mb-md">
|
|
||||||
<div class="col-3 q-pr-lg">Customer Public Key:</div>
|
|
||||||
<div class="col-6 col-sm-8 q-pr-lg">
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
readonly
|
|
||||||
disabled
|
|
||||||
v-model.trim="props.row.public_key"
|
|
||||||
type="text"
|
|
||||||
></q-input>
|
|
||||||
</div>
|
|
||||||
<div class="col-3 col-sm-1"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="props.row.address"
|
|
||||||
class="row items-center no-wrap q-mb-md"
|
|
||||||
>
|
|
||||||
<div class="col-3 q-pr-lg">Address:</div>
|
|
||||||
<div class="col-6 col-sm-8 q-pr-lg">
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
readonly
|
|
||||||
disabled
|
|
||||||
v-model.trim="props.row.address"
|
|
||||||
type="text"
|
|
||||||
></q-input>
|
|
||||||
</div>
|
|
||||||
<div class="col-3 col-sm-1"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="props.row.contact.phone"
|
|
||||||
class="row items-center no-wrap q-mb-md"
|
|
||||||
>
|
|
||||||
<div class="col-3 q-pr-lg">Phone:</div>
|
|
||||||
<div class="col-6 col-sm-8 q-pr-lg">
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
readonly
|
|
||||||
disabled
|
|
||||||
v-model.trim="props.row.contact.phone"
|
|
||||||
type="text"
|
|
||||||
></q-input>
|
|
||||||
</div>
|
|
||||||
<div class="col-3 col-sm-1"></div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="props.row.contact.email"
|
|
||||||
class="row items-center no-wrap q-mb-md"
|
|
||||||
>
|
|
||||||
<div class="col-3 q-pr-lg">Email:</div>
|
|
||||||
<div class="col-6 col-sm-8 q-pr-lg">
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
readonly
|
|
||||||
disabled
|
|
||||||
v-model.trim="props.row.contact.email"
|
|
||||||
type="text"
|
|
||||||
></q-input>
|
|
||||||
</div>
|
|
||||||
<div class="col-3 col-sm-1"></div>
|
|
||||||
</div>
|
|
||||||
<div class="row items-center no-wrap q-mb-md">
|
|
||||||
<div class="col-3 q-pr-lg">Shipping Zone:</div>
|
|
||||||
<div class="col-6 col-sm-8 q-pr-lg">
|
|
||||||
<q-select
|
|
||||||
:options="getStallZones(props.row.stall_id)"
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
emit-value
|
|
||||||
v-model.trim="props.row.shipping_id"
|
|
||||||
label="Shipping Zones"
|
|
||||||
></q-select>
|
|
||||||
</div>
|
|
||||||
<div class="col-3 col-sm-1"></div>
|
|
||||||
</div>
|
|
||||||
<div class="row items-center no-wrap q-mb-md">
|
|
||||||
<div class="col-3 q-pr-lg">Invoice ID:</div>
|
|
||||||
<div class="col-6 col-sm-8 q-pr-lg">
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
readonly
|
|
||||||
disabled
|
|
||||||
v-model.trim="props.row.invoice_id"
|
|
||||||
type="text"
|
|
||||||
></q-input>
|
|
||||||
</div>
|
|
||||||
<div class="col-3"></div>
|
|
||||||
</div>
|
|
||||||
<div class="row items-center no-wrap q-mb-md">
|
|
||||||
<div class="col-3 q-pr-lg"></div>
|
|
||||||
|
|
||||||
<div class="col-9">
|
|
||||||
<q-btn
|
|
||||||
@click="reissueOrderInvoice(props.row)"
|
|
||||||
unelevated
|
|
||||||
color="primary"
|
|
||||||
type="submit"
|
|
||||||
class="float-left"
|
|
||||||
label="Reissue Invoice"
|
|
||||||
></q-btn>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</q-td>
|
|
||||||
</q-tr>
|
|
||||||
</template>
|
|
||||||
</q-table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<q-dialog v-model="showShipDialog" position="top">
|
|
||||||
<q-card v-if="selectedOrder" class="q-pa-lg q-pt-xl" style="width: 500px">
|
|
||||||
<q-form @submit="updateOrderShipped" class="q-gutter-md">
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
v-model.trim="shippingMessage"
|
|
||||||
label="Shipping Message"
|
|
||||||
type="textarea"
|
|
||||||
rows="4"
|
|
||||||
></q-input>
|
|
||||||
|
|
||||||
<div class="row q-mt-lg">
|
|
||||||
<q-btn
|
|
||||||
unelevated
|
|
||||||
color="primary"
|
|
||||||
type="submit"
|
|
||||||
:label="selectedOrder.shipped? 'Unship Order' : 'Ship Order'"
|
|
||||||
></q-btn>
|
|
||||||
|
|
||||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
|
||||||
>Cancel</q-btn
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</q-form>
|
|
||||||
</q-card>
|
|
||||||
</q-dialog>
|
|
||||||
</div>
|
|
||||||
|
|
@ -1,309 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,136 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,466 +0,0 @@
|
||||||
<div>
|
|
||||||
<q-tabs v-model="tab" no-caps class="bg-dark text-white shadow-2">
|
|
||||||
<q-tab name="info" label="Stall Info"></q-tab>
|
|
||||||
<q-tab name="products" label="Products"></q-tab>
|
|
||||||
<q-tab name="orders" label="Orders"></q-tab>
|
|
||||||
</q-tabs>
|
|
||||||
<q-tab-panels v-model="tab">
|
|
||||||
<q-tab-panel name="info">
|
|
||||||
<div v-if="stall">
|
|
||||||
<div class="row items-center no-wrap q-mb-md">
|
|
||||||
<div class="col-3 q-pr-lg">ID:</div>
|
|
||||||
<div class="col-6 col-sm-8 q-pr-lg">
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
readonly
|
|
||||||
disabled
|
|
||||||
v-model.trim="stall.id"
|
|
||||||
type="text"
|
|
||||||
></q-input>
|
|
||||||
</div>
|
|
||||||
<div class="col-3 col-sm-1"></div>
|
|
||||||
</div>
|
|
||||||
<div class="row items-center no-wrap q-mb-md">
|
|
||||||
<div class="col-3 q-pr-lg">Name:</div>
|
|
||||||
<div class="col-6 col-sm-8 q-pr-lg">
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
v-model.trim="stall.name"
|
|
||||||
type="text"
|
|
||||||
></q-input>
|
|
||||||
</div>
|
|
||||||
<div class="col-3 col-sm-1"></div>
|
|
||||||
</div>
|
|
||||||
<div class="row items-center no-wrap q-mb-md">
|
|
||||||
<div class="col-3 q-pr-lg">Description:</div>
|
|
||||||
<div class="col-6 col-sm-8 q-pr-lg">
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
v-model.trim="stall.config.description"
|
|
||||||
type="textarea"
|
|
||||||
rows="3"
|
|
||||||
label="Description"
|
|
||||||
></q-input>
|
|
||||||
</div>
|
|
||||||
<div class="col-3 col-sm-1"></div>
|
|
||||||
</div>
|
|
||||||
<div class="row items-center no-wrap q-mb-md">
|
|
||||||
<div class="col-3 q-pr-lg">Wallet:</div>
|
|
||||||
<div class="col-6 col-sm-8 q-pr-lg">
|
|
||||||
<q-select
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
emit-value
|
|
||||||
v-model="stall.wallet"
|
|
||||||
:options="walletOptions"
|
|
||||||
label="Wallet *"
|
|
||||||
>
|
|
||||||
</q-select>
|
|
||||||
</div>
|
|
||||||
<div class="col-3 col-sm-1"></div>
|
|
||||||
</div>
|
|
||||||
<div class="row items-center no-wrap q-mb-md">
|
|
||||||
<div class="col-3 q-pr-lg">Currency:</div>
|
|
||||||
<div class="col-6 col-sm-8 q-pr-lg">
|
|
||||||
<q-select
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
v-model="stall.currency"
|
|
||||||
type="text"
|
|
||||||
label="Unit"
|
|
||||||
:options="currencies"
|
|
||||||
></q-select>
|
|
||||||
</div>
|
|
||||||
<div class="col-3 col-sm-1"></div>
|
|
||||||
</div>
|
|
||||||
<div class="row items-center no-wrap q-mb-md">
|
|
||||||
<div class="col-3 q-pr-lg">Shipping Zones:</div>
|
|
||||||
<div class="col-6 col-sm-8 q-pr-lg">
|
|
||||||
<q-select
|
|
||||||
:options="filteredZoneOptions"
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
multiple
|
|
||||||
v-model.trim="stall.shipping_zones"
|
|
||||||
label="Shipping Zones"
|
|
||||||
></q-select>
|
|
||||||
</div>
|
|
||||||
<div class="col-3 col-sm-1"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row items-center q-mt-xl">
|
|
||||||
<div class="col-6 q-pr-lg">
|
|
||||||
<q-btn
|
|
||||||
outline
|
|
||||||
unelevated
|
|
||||||
class="float-left"
|
|
||||||
color="primary"
|
|
||||||
@click="updateStall()"
|
|
||||||
>Update Stall</q-btn
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="col-6">
|
|
||||||
<q-btn
|
|
||||||
outline
|
|
||||||
unelevated
|
|
||||||
icon="cancel"
|
|
||||||
class="float-right"
|
|
||||||
@click="deleteStall()"
|
|
||||||
>Delete Stall</q-btn
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</q-tab-panel>
|
|
||||||
<q-tab-panel name="products">
|
|
||||||
<div v-if="stall">
|
|
||||||
<div class="row items-center no-wrap q-mb-md">
|
|
||||||
<div class="col-3 q-pr-lg">
|
|
||||||
<q-btn-dropdown
|
|
||||||
@click="showNewProductDialog()"
|
|
||||||
outline
|
|
||||||
unelevated
|
|
||||||
split
|
|
||||||
class="float-left"
|
|
||||||
color="primary"
|
|
||||||
label="New Product"
|
|
||||||
>
|
|
||||||
<q-item @click="showNewProductDialog()" clickable v-close-popup>
|
|
||||||
<q-item-section>
|
|
||||||
<q-item-label>New Product</q-item-label>
|
|
||||||
<q-item-label caption>Create a new product</q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
<q-item
|
|
||||||
@click="openSelectPendingProductDialog"
|
|
||||||
clickable
|
|
||||||
v-close-popup
|
|
||||||
>
|
|
||||||
<q-item-section>
|
|
||||||
<q-item-label>Restore Product</q-item-label>
|
|
||||||
<q-item-label caption
|
|
||||||
>Restore existing product from Nostr</q-item-label
|
|
||||||
>
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
</q-btn-dropdown>
|
|
||||||
</div>
|
|
||||||
<div class="col-6 col-sm-8 q-pr-lg"></div>
|
|
||||||
<div class="col-3 col-sm-1"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row items-center no-wrap q-mb-md">
|
|
||||||
<div class="col-12">
|
|
||||||
<q-table
|
|
||||||
flat
|
|
||||||
dense
|
|
||||||
:rows="products"
|
|
||||||
row-key="id"
|
|
||||||
:columns="productsTable.columns"
|
|
||||||
v-model:pagination="productsTable.pagination"
|
|
||||||
:filter="productsFilter"
|
|
||||||
>
|
|
||||||
<template v-slot:body="props">
|
|
||||||
<q-tr :props="props">
|
|
||||||
<q-td auto-width>
|
|
||||||
<q-btn
|
|
||||||
size="sm"
|
|
||||||
color="grey"
|
|
||||||
dense
|
|
||||||
@click="deleteProduct(props.row.id)"
|
|
||||||
icon="delete"
|
|
||||||
/>
|
|
||||||
</q-td>
|
|
||||||
<q-td auto-width>
|
|
||||||
<q-btn
|
|
||||||
size="sm"
|
|
||||||
color="primary"
|
|
||||||
dense
|
|
||||||
@click="editProduct(props.row)"
|
|
||||||
icon="edit"
|
|
||||||
/>
|
|
||||||
</q-td>
|
|
||||||
<q-td auto-width>
|
|
||||||
<q-toggle
|
|
||||||
@update:model-value="updateProduct({ ...props.row, active: !props.row.active })"
|
|
||||||
size="xs"
|
|
||||||
checked-icon="check"
|
|
||||||
v-model="props.row.active"
|
|
||||||
color="green"
|
|
||||||
unchecked-icon="clear"
|
|
||||||
/>
|
|
||||||
</q-td>
|
|
||||||
|
|
||||||
<q-td key="id" :props="props"
|
|
||||||
><span v-text="props.row.id"></span>
|
|
||||||
</q-td>
|
|
||||||
<q-td key="name" :props="props">
|
|
||||||
<span v-text="shortLabel(props.row.name)"></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-tr>
|
|
||||||
</template>
|
|
||||||
</q-table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</q-tab-panel>
|
|
||||||
<q-tab-panel name="orders">
|
|
||||||
<div v-if="stall">
|
|
||||||
<order-list
|
|
||||||
:adminkey="adminkey"
|
|
||||||
:inkey="inkey"
|
|
||||||
:stall-id="stallId"
|
|
||||||
@customer-selected="customerSelectedForOrder"
|
|
||||||
></order-list>
|
|
||||||
</div>
|
|
||||||
</q-tab-panel>
|
|
||||||
</q-tab-panels>
|
|
||||||
<q-dialog v-model="productDialog.showDialog" position="top">
|
|
||||||
<q-card
|
|
||||||
v-if="stall && productDialog.data"
|
|
||||||
class="q-pa-lg q-pt-xl"
|
|
||||||
style="width: 500px"
|
|
||||||
>
|
|
||||||
<q-form @submit="sendProductFormData" class="q-gutter-md">
|
|
||||||
<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 (' + stall.currency + ') *'"
|
|
||||||
:step="stall.currency != 'sat' ? '0.01' : '1'"
|
|
||||||
:mask="stall.currency != 'sat' ? '#.##' : '#'"
|
|
||||||
fill-mask="0"
|
|
||||||
reverse-fill-mask
|
|
||||||
></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 producsts, make them easy to search."
|
|
||||||
>
|
|
||||||
<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
|
|
||||||
multiple
|
|
||||||
hide-dropdown-icon
|
|
||||||
input-debounce="0"
|
|
||||||
new-value-mode="add-unique"
|
|
||||||
label="Categories (Hit Enter to add)"
|
|
||||||
placeholder="crafts,robots,etc"
|
|
||||||
></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="addProductImage"
|
|
||||||
type="url"
|
|
||||||
label="Image URL"
|
|
||||||
>
|
|
||||||
<q-btn @click="addProductImage" dense flat icon="add"></q-btn
|
|
||||||
></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>
|
|
||||||
|
|
||||||
<q-expansion-item
|
|
||||||
group="advanced"
|
|
||||||
label="Custom Shipping Cost"
|
|
||||||
caption="Configure custom shipping costs for this product"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-for="zone of productDialog.data.config.shipping"
|
|
||||||
class="row q-mb-sm q-ml-lg q-mt-sm"
|
|
||||||
>
|
|
||||||
<div class="col">
|
|
||||||
<span v-text="zone.name"></span>
|
|
||||||
</div>
|
|
||||||
<div class="col q-pr-md">
|
|
||||||
<q-input
|
|
||||||
v-model="zone.cost"
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
type="number"
|
|
||||||
label="Extra cost"
|
|
||||||
>
|
|
||||||
</q-input>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</q-expansion-item>
|
|
||||||
<q-expansion-item
|
|
||||||
group="advanced"
|
|
||||||
label="Autoreply"
|
|
||||||
caption="Autoreply when paid"
|
|
||||||
>
|
|
||||||
<q-card>
|
|
||||||
<q-card-section>
|
|
||||||
<div class="row q-mb-sm">
|
|
||||||
<div class="col">
|
|
||||||
<q-checkbox
|
|
||||||
v-model="productDialog.data.config.use_autoreply"
|
|
||||||
dense
|
|
||||||
label="Send a direct message when paid"
|
|
||||||
class="q-ml-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row q-mb-sm q-ml-sm">
|
|
||||||
<div class="col">
|
|
||||||
<q-input
|
|
||||||
v-model="productDialog.data.config.autoreply_message"
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
type="textarea"
|
|
||||||
rows="5"
|
|
||||||
label="Autoreply message"
|
|
||||||
hint="It can include link to a digital asset"
|
|
||||||
>
|
|
||||||
</q-input>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</q-card-section>
|
|
||||||
</q-card>
|
|
||||||
</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.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>
|
|
||||||
<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-section class="float-right">
|
|
||||||
<q-btn
|
|
||||||
@click="deleteProduct(pendingProduct.id)"
|
|
||||||
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 products to be restored.</div>
|
|
||||||
<div class="row q-mt-lg">
|
|
||||||
<q-btn
|
|
||||||
@click="restoreAllPendingProducts"
|
|
||||||
v-close-popup
|
|
||||||
flat
|
|
||||||
color="green"
|
|
||||||
>Restore All</q-btn
|
|
||||||
>
|
|
||||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
|
|
||||||
</div>
|
|
||||||
</q-card>
|
|
||||||
</q-dialog>
|
|
||||||
</div>
|
|
||||||
|
|
@ -1,287 +0,0 @@
|
||||||
<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, currency..."
|
|
||||||
style="min-width: 250px"
|
|
||||||
>
|
|
||||||
<template v-slot:prepend>
|
|
||||||
<q-icon name="search"></q-icon>
|
|
||||||
</template>
|
|
||||||
</q-input>
|
|
||||||
</div>
|
|
||||||
<div class="col-auto">
|
|
||||||
<q-btn
|
|
||||||
@click="openSelectPendingStallDialog"
|
|
||||||
outline
|
|
||||||
color="primary"
|
|
||||||
icon="restore"
|
|
||||||
label="Restore Stall"
|
|
||||||
class="q-px-md"
|
|
||||||
></q-btn>
|
|
||||||
</div>
|
|
||||||
<div class="col-auto">
|
|
||||||
<q-btn
|
|
||||||
@click="openCreateStallDialog()"
|
|
||||||
unelevated
|
|
||||||
color="primary"
|
|
||||||
icon="add"
|
|
||||||
label="New Stall"
|
|
||||||
class="q-px-md"
|
|
||||||
></q-btn>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<q-table
|
|
||||||
flat
|
|
||||||
dense
|
|
||||||
:rows="stalls"
|
|
||||||
row-key="id"
|
|
||||||
:columns="stallsTable.columns"
|
|
||||||
v-model:pagination="stallsTable.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="currency" :props="props">
|
|
||||||
<span v-text="props.row.currency"></span>
|
|
||||||
</q-td>
|
|
||||||
<q-td key="description" :props="props">
|
|
||||||
<span v-text="shortLabel(props.row.config.description)"></span>
|
|
||||||
</q-td>
|
|
||||||
<q-td key="shippingZones" :props="props">
|
|
||||||
<span
|
|
||||||
v-text="shortLabel(props.row.shipping_zones.filter(z => !!z.name).map(z => z.name).join(', '))"
|
|
||||||
></span>
|
|
||||||
</q-td>
|
|
||||||
<q-td key="actions" :props="props">
|
|
||||||
<q-btn
|
|
||||||
size="sm"
|
|
||||||
color="primary"
|
|
||||||
dense
|
|
||||||
flat
|
|
||||||
icon="edit"
|
|
||||||
@click="openEditStallDialog(props.row)"
|
|
||||||
>
|
|
||||||
<q-tooltip>Edit stall</q-tooltip>
|
|
||||||
</q-btn>
|
|
||||||
<q-btn
|
|
||||||
size="sm"
|
|
||||||
color="secondary"
|
|
||||||
dense
|
|
||||||
flat
|
|
||||||
icon="inventory_2"
|
|
||||||
@click="goToProducts(props.row)"
|
|
||||||
>
|
|
||||||
<q-tooltip>View products</q-tooltip>
|
|
||||||
</q-btn>
|
|
||||||
<q-btn
|
|
||||||
size="sm"
|
|
||||||
color="accent"
|
|
||||||
dense
|
|
||||||
flat
|
|
||||||
icon="receipt"
|
|
||||||
@click="goToOrders(props.row)"
|
|
||||||
>
|
|
||||||
<q-tooltip>View orders</q-tooltip>
|
|
||||||
</q-btn>
|
|
||||||
<q-btn
|
|
||||||
size="sm"
|
|
||||||
color="negative"
|
|
||||||
dense
|
|
||||||
flat
|
|
||||||
icon="delete"
|
|
||||||
@click="confirmDeleteStall(props.row)"
|
|
||||||
>
|
|
||||||
<q-tooltip>Delete stall</q-tooltip>
|
|
||||||
</q-btn>
|
|
||||||
</q-td>
|
|
||||||
</q-tr>
|
|
||||||
</template>
|
|
||||||
</q-table>
|
|
||||||
|
|
||||||
<!-- Create Stall Dialog -->
|
|
||||||
<q-dialog v-model="stallDialog.show" position="top">
|
|
||||||
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
|
|
||||||
<q-form @submit="sendStallFormData" class="q-gutter-md">
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
v-model.trim="stallDialog.data.name"
|
|
||||||
label="Name"
|
|
||||||
></q-input>
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
v-model.trim="stallDialog.data.description"
|
|
||||||
type="textarea"
|
|
||||||
rows="3"
|
|
||||||
label="Description"
|
|
||||||
></q-input>
|
|
||||||
<q-select
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
emit-value
|
|
||||||
v-model="stallDialog.data.wallet"
|
|
||||||
:options="walletOptions"
|
|
||||||
label="Wallet *"
|
|
||||||
>
|
|
||||||
</q-select>
|
|
||||||
<q-select
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
v-model="stallDialog.data.currency"
|
|
||||||
type="text"
|
|
||||||
label="Unit"
|
|
||||||
:options="currencies"
|
|
||||||
></q-select>
|
|
||||||
<q-select
|
|
||||||
:options="filteredZoneOptions"
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
multiple
|
|
||||||
v-model.trim="stallDialog.data.shippingZones"
|
|
||||||
label="Shipping Zones"
|
|
||||||
></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>
|
|
||||||
|
|
||||||
<!-- 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>
|
|
||||||
|
|
@ -1,80 +1,78 @@
|
||||||
{% 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 col-lg-8 q-gutter-y-md">
|
<div class="col-12 col-md-7 q-gutter-y-md">
|
||||||
<div v-if="merchant && merchant.id">
|
<div v-if="merchant && merchant.id">
|
||||||
<q-banner
|
|
||||||
v-if="merchant.config && merchant.config.key_mismatch"
|
|
||||||
class="bg-warning text-white q-mb-md"
|
|
||||||
rounded
|
|
||||||
>
|
|
||||||
<template v-slot:avatar>
|
|
||||||
<q-icon name="warning" color="white"></q-icon>
|
|
||||||
</template>
|
|
||||||
Your account Nostr keypair has changed since this merchant was created.
|
|
||||||
The merchant is still using the old key. Migrate to republish your
|
|
||||||
stalls and products under the new identity.
|
|
||||||
<template v-slot:action>
|
|
||||||
<q-btn
|
|
||||||
flat
|
|
||||||
color="white"
|
|
||||||
label="Migrate Keys"
|
|
||||||
@click="migrateKeys"
|
|
||||||
></q-btn>
|
|
||||||
</template>
|
|
||||||
</q-banner>
|
|
||||||
<q-card>
|
<q-card>
|
||||||
<div class="row items-center no-wrap">
|
<q-card-section>
|
||||||
<q-tabs
|
<div class="row">
|
||||||
v-model="activeTab"
|
<div class="col-4">
|
||||||
class="text-grey col"
|
<merchant-details
|
||||||
active-color="primary"
|
:merchant-id="merchant.id"
|
||||||
indicator-color="primary"
|
:inkey="g.user.wallets[0].inkey"
|
||||||
align="left"
|
:adminkey="g.user.wallets[0].adminkey"
|
||||||
>
|
@show-keys="toggleMerchantKeys"
|
||||||
<q-tab
|
@merchant-deleted="handleMerchantDeleted"
|
||||||
name="orders"
|
></merchant-details>
|
||||||
label="Orders"
|
</div>
|
||||||
icon="receipt_long"
|
<div class="col-6">
|
||||||
style="min-width: 120px"
|
<q-toggle
|
||||||
></q-tab>
|
@input="toggleMerchantState()"
|
||||||
<q-tab
|
size="md"
|
||||||
name="merchant"
|
checked-icon="check"
|
||||||
label="Merchant"
|
v-model="merchant.config.active"
|
||||||
icon="person"
|
color="primary"
|
||||||
style="min-width: 120px"
|
unchecked-icon="clear"
|
||||||
></q-tab>
|
class="float-left"
|
||||||
<q-tab
|
/> <span v-text="merchant.config.active ? 'Accepting Orders': 'Orders Paused'"></span>
|
||||||
name="shipping"
|
</div>
|
||||||
label="Shipping"
|
<div class="col-2">
|
||||||
icon="local_shipping"
|
<shipping-zones
|
||||||
style="min-width: 120px"
|
:inkey="g.user.wallets[0].inkey"
|
||||||
></q-tab>
|
:adminkey="g.user.wallets[0].adminkey"
|
||||||
<q-tab
|
class="float-right"
|
||||||
name="stalls"
|
></shipping-zones>
|
||||||
label="Stalls"
|
</div>
|
||||||
icon="store"
|
</div>
|
||||||
style="min-width: 120px"
|
</q-card-section>
|
||||||
></q-tab>
|
<q-card-section v-if="showKeys">
|
||||||
<q-tab
|
<div class="row q-mb-md">
|
||||||
name="products"
|
<div class="col">
|
||||||
label="Products"
|
<q-btn
|
||||||
icon="inventory_2"
|
unelevated
|
||||||
style="min-width: 120px"
|
color="grey"
|
||||||
></q-tab>
|
outline
|
||||||
</q-tabs>
|
@click="showKeys = false"
|
||||||
</div>
|
class="float-left"
|
||||||
|
>Hide Keys</q-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<q-separator></q-separator>
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
<q-tab-panels v-model="activeTab" animated>
|
<key-pair
|
||||||
<!-- Orders Tab -->
|
:public-key="merchant.public_key"
|
||||||
<q-tab-panel name="orders">
|
:private-key="merchant.private_key"
|
||||||
<q-card-section>
|
></key-pair>
|
||||||
<div class="text-h6">Orders</div>
|
</div>
|
||||||
</q-card-section>
|
</div>
|
||||||
<q-separator></q-separator>
|
</q-card-section>
|
||||||
<q-card-section class="q-pt-none">
|
</q-card>
|
||||||
|
<q-card class="q-mt-lg">
|
||||||
|
<q-card-section>
|
||||||
|
<stall-list
|
||||||
|
:adminkey="g.user.wallets[0].adminkey"
|
||||||
|
:inkey="g.user.wallets[0].inkey"
|
||||||
|
:wallet-options="g.user.walletOptions"
|
||||||
|
@customer-selected-for-order="customerSelectedForOrder"
|
||||||
|
></stall-list>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
<q-card class="q-mt-lg">
|
||||||
|
<q-card-section>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
<order-list
|
<order-list
|
||||||
ref="orderListRef"
|
ref="orderListRef"
|
||||||
:adminkey="g.user.wallets[0].adminkey"
|
:adminkey="g.user.wallets[0].adminkey"
|
||||||
|
|
@ -82,244 +80,95 @@
|
||||||
:customer-pubkey-filter="orderPubkey"
|
:customer-pubkey-filter="orderPubkey"
|
||||||
@customer-selected="customerSelectedForOrder"
|
@customer-selected="customerSelectedForOrder"
|
||||||
></order-list>
|
></order-list>
|
||||||
</q-card-section>
|
</div>
|
||||||
</q-tab-panel>
|
</div>
|
||||||
|
</q-card-section>
|
||||||
<!-- Merchant Tab -->
|
|
||||||
<q-tab-panel name="merchant">
|
|
||||||
<merchant-tab
|
|
||||||
:merchant-id="merchant.id"
|
|
||||||
:inkey="g.user.wallets[0].inkey"
|
|
||||||
:adminkey="g.user.wallets[0].adminkey"
|
|
||||||
:show-keys="showKeys"
|
|
||||||
:merchant-active="merchant.config.active"
|
|
||||||
:public-key="merchant.public_key"
|
|
||||||
:private-key="merchant.private_key"
|
|
||||||
:is-admin="g.user.admin"
|
|
||||||
:merchant-config="merchant.config"
|
|
||||||
@toggle-show-keys="toggleShowKeys"
|
|
||||||
@hide-keys="showKeys = false"
|
|
||||||
@merchant-deleted="handleMerchantDeleted"
|
|
||||||
@toggle-merchant-state="toggleMerchantState"
|
|
||||||
@restart-nostr-connection="restartNostrConnection"
|
|
||||||
@profile-updated="getMerchant"
|
|
||||||
></merchant-tab>
|
|
||||||
</q-tab-panel>
|
|
||||||
|
|
||||||
<!-- Shipping Tab -->
|
|
||||||
<q-tab-panel name="shipping">
|
|
||||||
<shipping-zones-list
|
|
||||||
:inkey="g.user.wallets[0].inkey"
|
|
||||||
:adminkey="g.user.wallets[0].adminkey"
|
|
||||||
></shipping-zones-list>
|
|
||||||
</q-tab-panel>
|
|
||||||
|
|
||||||
<!-- Stalls Tab -->
|
|
||||||
<q-tab-panel name="stalls">
|
|
||||||
<stall-list
|
|
||||||
:adminkey="g.user.wallets[0].adminkey"
|
|
||||||
:inkey="g.user.wallets[0].inkey"
|
|
||||||
:wallet-options="g.user.walletOptions"
|
|
||||||
@customer-selected-for-order="customerSelectedForOrder"
|
|
||||||
@go-to-products="goToProducts"
|
|
||||||
@go-to-orders="goToOrders"
|
|
||||||
@stalls-updated="stallCount = $event"
|
|
||||||
></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-panels>
|
|
||||||
</q-card>
|
</q-card>
|
||||||
</div>
|
</div>
|
||||||
<q-card v-else>
|
<q-card v-else>
|
||||||
<q-card-section class="text-center q-pa-xl">
|
<q-card-section>
|
||||||
<q-spinner color="primary" size="3em" class="q-mb-md"></q-spinner>
|
<span class="text-h4">Welcome to Nostr Market!</span><br />
|
||||||
<div class="text-h6">Setting up Nostr Market...</div>
|
In Nostr Market, merchant and customer communicate via NOSTR relays, so
|
||||||
|
loss of money, product information, and reputation become far less
|
||||||
|
likely if attacked.
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section>
|
||||||
|
<span class="text-h4">Terms</span><br />
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<span class="text-bold">merchant</span> - seller of products with
|
||||||
|
NOSTR key-pair
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="text-bold">customer</span> - buyer of products with
|
||||||
|
NOSTR key-pair
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="text-bold">product</span> - item for sale by the
|
||||||
|
merchant
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="text-bold">stall</span> - list of products controlled
|
||||||
|
by merchant (a merchant can have multiple stalls)
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="text-bold">marketplace</span> - clientside software for
|
||||||
|
searching stalls and purchasing products
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<q-btn
|
||||||
|
@click="showImportKeysDialog"
|
||||||
|
label="Import Key"
|
||||||
|
color="primary"
|
||||||
|
class="float-left"
|
||||||
|
>
|
||||||
|
<q-tooltip> Use an existing private key (hex or npub) </q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
<q-btn
|
||||||
|
label="Generate New Key"
|
||||||
|
color="green"
|
||||||
|
@click="generateKeys"
|
||||||
|
class="float-right"
|
||||||
|
>
|
||||||
|
<q-tooltip> A new key pair will be generated for you </q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-12 col-md-5 col-lg-4 q-gutter-y-md">
|
<div class="col-12 col-md-5 q-gutter-y-md">
|
||||||
<div v-if="g.user.admin" class="col-12 q-mb-lg">
|
<div v-if="g.user.admin" class="col-12 q-mb-lg">
|
||||||
<q-card>
|
<q-card>
|
||||||
<q-card-section class="q-pa-md">
|
<q-card-section class="q-pa-md">
|
||||||
<div class="row items-center no-wrap q-col-gutter-sm">
|
<q-btn
|
||||||
<div class="col">
|
label="Restart Nostr Connection"
|
||||||
<q-btn-dropdown
|
color="grey"
|
||||||
:color="nostrStatusColor"
|
outline
|
||||||
:label="nostrStatusLabel"
|
@click="restartNostrConnection"
|
||||||
icon="sync"
|
>
|
||||||
split
|
<q-tooltip>
|
||||||
@click="restartNostrConnection"
|
Restart the connection to the nostrclient extension
|
||||||
>
|
</q-tooltip>
|
||||||
<q-list>
|
</q-btn>
|
||||||
<q-item
|
</q-card-section>
|
||||||
clickable
|
</q-card>
|
||||||
v-close-popup
|
</div>
|
||||||
@click="restartNostrConnection"
|
<div class="col-12">
|
||||||
>
|
<q-card>
|
||||||
<q-item-section avatar>
|
<q-card-section>
|
||||||
<q-icon name="refresh" color="primary"></q-icon>
|
<h6 class="text-subtitle1 q-my-none">
|
||||||
</q-item-section>
|
{{SITE_TITLE}} Nostr Market Extension
|
||||||
<q-item-section>
|
</h6>
|
||||||
<q-item-label>Restart Connection</q-item-label>
|
</q-card-section>
|
||||||
<q-item-label caption>
|
<q-card-section class="q-pa-none">
|
||||||
Reconnect to the nostrclient extension
|
<q-separator></q-separator>
|
||||||
</q-item-label>
|
<q-list> {% include "nostrmarket/_api_docs.html" %} </q-list>
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
<q-item
|
|
||||||
clickable
|
|
||||||
v-close-popup
|
|
||||||
@click="checkNostrStatus(true)"
|
|
||||||
>
|
|
||||||
<q-item-section avatar>
|
|
||||||
<q-icon name="wifi_find" color="primary"></q-icon>
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section>
|
|
||||||
<q-item-label>Check Status</q-item-label>
|
|
||||||
<q-item-label caption>
|
|
||||||
Check connection to nostrclient
|
|
||||||
</q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
<q-separator></q-separator>
|
|
||||||
<q-item>
|
|
||||||
<q-item-section>
|
|
||||||
<q-item-label caption>
|
|
||||||
<strong>Status:</strong>
|
|
||||||
<q-badge
|
|
||||||
:color="nostrStatus.connected ? 'green' : 'red'"
|
|
||||||
class="q-ml-xs"
|
|
||||||
v-text="nostrStatus.connected ? 'Connected' : 'Disconnected'"
|
|
||||||
></q-badge>
|
|
||||||
</q-item-label>
|
|
||||||
<q-item-label
|
|
||||||
v-if="nostrStatus.relays_total > 0"
|
|
||||||
caption
|
|
||||||
class="q-mt-xs"
|
|
||||||
>
|
|
||||||
<strong>Relays:</strong>
|
|
||||||
<span v-text="nostrStatus.relays_connected"></span>
|
|
||||||
of
|
|
||||||
<span v-text="nostrStatus.relays_total"></span>
|
|
||||||
connected
|
|
||||||
</q-item-label>
|
|
||||||
<q-item-label
|
|
||||||
v-if="nostrStatus.error"
|
|
||||||
caption
|
|
||||||
class="text-negative q-mt-xs"
|
|
||||||
v-text="nostrStatus.error"
|
|
||||||
></q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
</q-list>
|
|
||||||
</q-btn-dropdown>
|
|
||||||
</div>
|
|
||||||
<div class="col-auto">
|
|
||||||
<div class="inline-block">
|
|
||||||
<q-btn-dropdown
|
|
||||||
color="primary"
|
|
||||||
label="Publish"
|
|
||||||
icon="publish"
|
|
||||||
unelevated
|
|
||||||
:disable="stallCount === 0"
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
<q-tooltip v-if="stallCount === 0">
|
|
||||||
First create a stall and add products.
|
|
||||||
</q-tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -335,36 +184,34 @@
|
||||||
>
|
>
|
||||||
</direct-messages>
|
</direct-messages>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12">
|
|
||||||
<q-card>
|
|
||||||
<q-expansion-item
|
|
||||||
icon="info"
|
|
||||||
label="Details"
|
|
||||||
header-class="text-grey"
|
|
||||||
expand-separator
|
|
||||||
>
|
|
||||||
<q-img
|
|
||||||
src="/nostrmarket/static/market/images/nostr-cover.png"
|
|
||||||
:ratio="3"
|
|
||||||
fit="cover"
|
|
||||||
></q-img>
|
|
||||||
<q-card-section>
|
|
||||||
<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 class="q-pa-none">
|
|
||||||
<q-list> {% include "nostrmarket/_api_docs.html" %} </q-list>
|
|
||||||
</q-card-section>
|
|
||||||
</q-expansion-item>
|
|
||||||
</q-card>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<q-dialog v-model="importKeyDialog.show" position="top">
|
||||||
|
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
|
||||||
|
<q-form @submit="importKeys" class="q-gutter-md">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="importKeyDialog.data.privateKey"
|
||||||
|
label="Private Key (hex or nsec)"
|
||||||
|
></q-input>
|
||||||
|
<div class="row q-mt-lg">
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
color="primary"
|
||||||
|
:disable="!importKeyDialog.data.privateKey"
|
||||||
|
type="submit"
|
||||||
|
>Import</q-btn
|
||||||
|
>
|
||||||
|
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||||
|
>Cancel</q-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</q-form>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock%}{% block scripts %} {{ window_vars(user) }}
|
{% endblock%}{% block scripts %} {{ window_vars(user) }}
|
||||||
|
|
||||||
|
|
@ -382,66 +229,17 @@
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-avatar {
|
|
||||||
border: 3px solid var(--q-dark-page);
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-avatar .q-avatar__content {
|
|
||||||
overflow: hidden;
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<template id="nostr-keys-dialog"
|
|
||||||
>{% include("nostrmarket/components/nostr-keys-dialog.html") %}</template
|
|
||||||
>
|
|
||||||
<template id="edit-profile-dialog"
|
|
||||||
>{% include("nostrmarket/components/edit-profile-dialog.html") %}</template
|
|
||||||
>
|
|
||||||
<template id="shipping-zones"
|
|
||||||
>{% include("nostrmarket/components/shipping-zones.html") %}</template
|
|
||||||
>
|
|
||||||
<template id="stall-details"
|
|
||||||
>{% include("nostrmarket/components/stall-details.html") %}</template
|
|
||||||
>
|
|
||||||
<template id="stall-list"
|
|
||||||
>{% include("nostrmarket/components/stall-list.html") %}</template
|
|
||||||
>
|
|
||||||
<template id="order-list"
|
|
||||||
>{% include("nostrmarket/components/order-list.html") %}</template
|
|
||||||
><template id="direct-messages"
|
|
||||||
>{% include("nostrmarket/components/direct-messages.html") %}</template
|
|
||||||
>
|
|
||||||
|
|
||||||
<template id="merchant-details"
|
|
||||||
>{% include("nostrmarket/components/merchant-details.html") %}</template
|
|
||||||
>
|
|
||||||
<template id="merchant-tab"
|
|
||||||
>{% include("nostrmarket/components/merchant-tab.html") %}</template
|
|
||||||
>
|
|
||||||
<template id="shipping-zones-list"
|
|
||||||
>{% include("nostrmarket/components/shipping-zones-list.html") %}</template
|
|
||||||
>
|
|
||||||
<template id="product-list"
|
|
||||||
>{% include("nostrmarket/components/product-list.html") %}</template
|
|
||||||
>
|
|
||||||
|
|
||||||
<script src="{{ url_for('nostrmarket_static', path='js/nostr.bundle.js') }}"></script>
|
<script src="{{ url_for('nostrmarket_static', path='js/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>
|
||||||
|
<script src="{{ url_for('nostrmarket_static', path='components/key-pair/key-pair.js') }}"></script>
|
||||||
|
<script src="{{ url_for('nostrmarket_static', path='components/shipping-zones/shipping-zones.js') }}"></script>
|
||||||
|
<script src="{{ url_for('nostrmarket_static', path='components/stall-details/stall-details.js') }}"></script>
|
||||||
|
<script src="{{ url_for('nostrmarket_static', path='components/stall-list/stall-list.js') }}"></script>
|
||||||
|
<script src="{{ url_for('nostrmarket_static', path='components/order-list/order-list.js') }}"></script>
|
||||||
|
<script src="{{ url_for('nostrmarket_static', path='components/direct-messages/direct-messages.js') }}"></script>
|
||||||
|
<script src="{{ url_for('nostrmarket_static', path='components/merchant-details/merchant-details.js') }}"></script>
|
||||||
<script src="{{ url_for('nostrmarket_static', path='js/index.js') }}"></script>
|
<script src="{{ url_for('nostrmarket_static', path='js/index.js') }}"></script>
|
||||||
|
|
||||||
<script src="{{ static_url_for('nostrmarket/static', 'components/nostr-keys-dialog.js') }}"></script>
|
|
||||||
<script src="{{ static_url_for('nostrmarket/static', 'components/edit-profile-dialog.js') }}"></script>
|
|
||||||
<script src="{{ static_url_for('nostrmarket/static', 'components/shipping-zones.js') }}"></script>
|
|
||||||
<script src="{{ static_url_for('nostrmarket/static', 'components/stall-details.js') }}"></script>
|
|
||||||
<script src="{{ static_url_for('nostrmarket/static', 'components/stall-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/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 %}
|
||||||
|
|
|
||||||
|
|
@ -1,59 +1,36 @@
|
||||||
<!doctype html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
|
||||||
|
<head>
|
||||||
<title>Nostr Market App</title>
|
<title>Nostr Market App</title>
|
||||||
<meta charset="utf-8" />
|
<meta charset=utf-8>
|
||||||
<meta name="description" content="A Nostr marketplace" />
|
<meta name=description content="A Nostr marketplace">
|
||||||
<meta name="format-detection" content="telephone=no" />
|
<meta name=format-detection content="telephone=no">
|
||||||
<meta name="msapplication-tap-highlight" content="no" />
|
<meta name=msapplication-tap-highlight content=no>
|
||||||
<meta
|
<meta name=viewport content="user-scalable=no,initial-scale=1,maximum-scale=1,minimum-scale=1,width=device-width">
|
||||||
name="viewport"
|
|
||||||
content="user-scalable=no,initial-scale=1,maximum-scale=1,minimum-scale=1,width=device-width"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<script src="{{ url_for('nostrmarket_static', path='market/js/nostr.bundle.js') }}"></script>
|
<script src="{{ url_for('nostrmarket_static', path='market/js/nostr.bundle.js') }}"></script>
|
||||||
<script src="{{ url_for('nostrmarket_static', path='market/js/bolt11-decoder.js') }}"></script>
|
<script src="{{ url_for('nostrmarket_static', path='market/js/bolt11-decoder.js') }}"></script>
|
||||||
<script src="{{ url_for('nostrmarket_static', path='market/js/utils.js') }}"></script>
|
<script src="{{ url_for('nostrmarket_static', path='market/js/utils.js') }}"></script>
|
||||||
|
|
||||||
<link
|
<link rel=icon type=image/png sizes=128x128
|
||||||
rel="icon"
|
href="{{ url_for('nostrmarket_static', path='market/icons/favicon-128x128.png')}}">
|
||||||
type="image/png"
|
|
||||||
sizes="128x128"
|
|
||||||
href="{{ url_for('nostrmarket_static', path='market/icons/favicon-128x128.png')}}"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<link
|
<link rel=icon type=image/png sizes=128x128
|
||||||
rel="icon"
|
href="{{ url_for('nostrmarket_static', path='market/icons/favicon-96x96.png')}}">
|
||||||
type="image/png"
|
<link rel=icon type=image/png sizes=128x128
|
||||||
sizes="128x128"
|
href="{{ url_for('nostrmarket_static', path='market/icons/favicon-32x32.png')}}">
|
||||||
href="{{ url_for('nostrmarket_static', path='market/icons/favicon-96x96.png')}}"
|
<link rel=icon type=image/png sizes=128x128 href="{{ url_for('nostrmarket_static', path='market/favicon.ico')}}">
|
||||||
/>
|
|
||||||
<link
|
|
||||||
rel="icon"
|
|
||||||
type="image/png"
|
|
||||||
sizes="128x128"
|
|
||||||
href="{{ url_for('nostrmarket_static', path='market/icons/favicon-32x32.png')}}"
|
|
||||||
/>
|
|
||||||
<link
|
|
||||||
rel="icon"
|
|
||||||
type="image/png"
|
|
||||||
sizes="128x128"
|
|
||||||
href="{{ url_for('nostrmarket_static', path='market/favicon.ico')}}"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Note: the .js and .css build IDs must be updated when a new version si released for 'static/market/index.html'-->
|
<!-- Note: the .js and .css build IDs must be updated when a new version si released for 'static/market/index.html'-->
|
||||||
<script
|
<script type="module" crossorigin
|
||||||
type="module"
|
src="{{ url_for('nostrmarket_static', path='market/assets/index.923cbbf9.js')}}"></script>
|
||||||
crossorigin
|
<link rel="stylesheet" href="{{ url_for('nostrmarket_static', path='market/assets/index.73d462e5.css')}}">
|
||||||
src="{{ url_for('nostrmarket_static', path='market/assets/index.923cbbf9.js')}}"
|
</head>
|
||||||
></script>
|
|
||||||
<link
|
|
||||||
rel="stylesheet"
|
|
||||||
href="{{ url_for('nostrmarket_static', path='market/assets/index.73d462e5.css')}}"
|
|
||||||
/>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div id="q-app"></div>
|
<div id=q-app></div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
|
||||||
|
</html>
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
"""
|
|
||||||
Stub out the nostrmarket root package and all LNbits dependencies so that
|
|
||||||
nostr/* unit tests can run without the full LNbits environment.
|
|
||||||
|
|
||||||
pytest walks up from tests/ and tries to import the parent __init__.py,
|
|
||||||
which pulls in fastapi, lnbits, websocket, etc. We preemptively register
|
|
||||||
the parent package as a simple module so that import never happens.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sys
|
|
||||||
import types
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
# Register 'nostrmarket' as an already-imported namespace package
|
|
||||||
# pointing at the extension root, so pytest doesn't try to exec __init__.py
|
|
||||||
_ext_root = Path(__file__).resolve().parent.parent
|
|
||||||
_pkg = types.ModuleType("nostrmarket")
|
|
||||||
_pkg.__path__ = [str(_ext_root)]
|
|
||||||
_pkg.__package__ = "nostrmarket"
|
|
||||||
sys.modules["nostrmarket"] = _pkg
|
|
||||||
|
|
||||||
# Also ensure the nostr subpackage is importable
|
|
||||||
_nostr_dir = _ext_root / "nostr"
|
|
||||||
_nostr_pkg = types.ModuleType("nostrmarket.nostr")
|
|
||||||
_nostr_pkg.__path__ = [str(_nostr_dir)]
|
|
||||||
_nostr_pkg.__package__ = "nostrmarket.nostr"
|
|
||||||
sys.modules["nostrmarket.nostr"] = _nostr_pkg
|
|
||||||
|
|
@ -1,139 +0,0 @@
|
||||||
"""Tests for NIP-44 v2 encryption against official spec test vectors."""
|
|
||||||
|
|
||||||
import coincurve
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from nostr.nip44 import (
|
|
||||||
calc_padded_len,
|
|
||||||
decrypt,
|
|
||||||
encrypt,
|
|
||||||
get_conversation_key,
|
|
||||||
get_message_keys,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def pubkey_from_secret(secret_hex: str) -> str:
|
|
||||||
"""Derive x-only public key hex from secret key hex."""
|
|
||||||
sk = coincurve.PrivateKey(bytes.fromhex(secret_hex))
|
|
||||||
return sk.public_key.format(compressed=True)[1:].hex()
|
|
||||||
|
|
||||||
|
|
||||||
# --- Test vector from NIP-44 spec ---
|
|
||||||
|
|
||||||
SPEC_VECTOR = {
|
|
||||||
"sec1": "0000000000000000000000000000000000000000000000000000000000000001",
|
|
||||||
"sec2": "0000000000000000000000000000000000000000000000000000000000000002",
|
|
||||||
"conversation_key": "c41c775356fd92eadc63ff5a0dc1da211b268cbea22316767095b2871ea1412d",
|
|
||||||
"nonce": "0000000000000000000000000000000000000000000000000000000000000001",
|
|
||||||
"plaintext": "a",
|
|
||||||
"payload": "AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABee0G5VSK0/9YypIObAtDKfYEAjD35uVkHyB0F4DwrcNaCXlCWZKaArsGrY6M9wnuTMxWfp1RTN9Xga8no+kF5Vsb",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class TestConversationKey:
|
|
||||||
def test_spec_vector(self):
|
|
||||||
pub2 = pubkey_from_secret(SPEC_VECTOR["sec2"])
|
|
||||||
key = get_conversation_key(SPEC_VECTOR["sec1"], pub2)
|
|
||||||
assert key.hex() == SPEC_VECTOR["conversation_key"]
|
|
||||||
|
|
||||||
def test_symmetric(self):
|
|
||||||
"""conv(a, B) == conv(b, A)"""
|
|
||||||
pub1 = pubkey_from_secret(SPEC_VECTOR["sec1"])
|
|
||||||
pub2 = pubkey_from_secret(SPEC_VECTOR["sec2"])
|
|
||||||
key_ab = get_conversation_key(SPEC_VECTOR["sec1"], pub2)
|
|
||||||
key_ba = get_conversation_key(SPEC_VECTOR["sec2"], pub1)
|
|
||||||
assert key_ab == key_ba
|
|
||||||
|
|
||||||
|
|
||||||
class TestMessageKeys:
|
|
||||||
def test_returns_correct_lengths(self):
|
|
||||||
conv_key = bytes.fromhex(SPEC_VECTOR["conversation_key"])
|
|
||||||
nonce = bytes.fromhex(SPEC_VECTOR["nonce"])
|
|
||||||
chacha_key, chacha_nonce, hmac_key = get_message_keys(conv_key, nonce)
|
|
||||||
assert len(chacha_key) == 32
|
|
||||||
assert len(chacha_nonce) == 12
|
|
||||||
assert len(hmac_key) == 32
|
|
||||||
|
|
||||||
def test_rejects_bad_key_length(self):
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
get_message_keys(b"\x00" * 16, b"\x00" * 32)
|
|
||||||
|
|
||||||
def test_rejects_bad_nonce_length(self):
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
get_message_keys(b"\x00" * 32, b"\x00" * 16)
|
|
||||||
|
|
||||||
|
|
||||||
class TestPadding:
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"unpadded,expected",
|
|
||||||
[
|
|
||||||
(1, 32),
|
|
||||||
(2, 32),
|
|
||||||
(31, 32),
|
|
||||||
(32, 32),
|
|
||||||
(33, 64),
|
|
||||||
(64, 64),
|
|
||||||
(65, 96),
|
|
||||||
(256, 256),
|
|
||||||
(257, 320),
|
|
||||||
(1024, 1024),
|
|
||||||
(65535, 65536),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_calc_padded_len(self, unpadded, expected):
|
|
||||||
assert calc_padded_len(unpadded) == expected
|
|
||||||
|
|
||||||
def test_rejects_zero(self):
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
calc_padded_len(0)
|
|
||||||
|
|
||||||
|
|
||||||
class TestEncryptDecrypt:
|
|
||||||
def test_spec_vector(self):
|
|
||||||
conv_key = bytes.fromhex(SPEC_VECTOR["conversation_key"])
|
|
||||||
nonce = bytes.fromhex(SPEC_VECTOR["nonce"])
|
|
||||||
payload = encrypt(SPEC_VECTOR["plaintext"], conv_key, nonce)
|
|
||||||
assert payload == SPEC_VECTOR["payload"]
|
|
||||||
|
|
||||||
def test_spec_vector_decrypt(self):
|
|
||||||
conv_key = bytes.fromhex(SPEC_VECTOR["conversation_key"])
|
|
||||||
plaintext = decrypt(SPEC_VECTOR["payload"], conv_key)
|
|
||||||
assert plaintext == SPEC_VECTOR["plaintext"]
|
|
||||||
|
|
||||||
def test_round_trip_short(self):
|
|
||||||
pub2 = pubkey_from_secret(SPEC_VECTOR["sec2"])
|
|
||||||
conv_key = get_conversation_key(SPEC_VECTOR["sec1"], pub2)
|
|
||||||
msg = "x"
|
|
||||||
assert decrypt(encrypt(msg, conv_key), conv_key) == msg
|
|
||||||
|
|
||||||
def test_round_trip_long(self):
|
|
||||||
pub2 = pubkey_from_secret(SPEC_VECTOR["sec2"])
|
|
||||||
conv_key = get_conversation_key(SPEC_VECTOR["sec1"], pub2)
|
|
||||||
msg = "A" * 65535
|
|
||||||
assert decrypt(encrypt(msg, conv_key), conv_key) == msg
|
|
||||||
|
|
||||||
def test_round_trip_unicode(self):
|
|
||||||
pub2 = pubkey_from_secret(SPEC_VECTOR["sec2"])
|
|
||||||
conv_key = get_conversation_key(SPEC_VECTOR["sec1"], pub2)
|
|
||||||
msg = "hello world! \U0001f680\U0001f30e\U0001f4ac"
|
|
||||||
assert decrypt(encrypt(msg, conv_key), conv_key) == msg
|
|
||||||
|
|
||||||
def test_tampered_mac_rejected(self):
|
|
||||||
conv_key = bytes.fromhex(SPEC_VECTOR["conversation_key"])
|
|
||||||
payload = SPEC_VECTOR["payload"]
|
|
||||||
tampered = payload[:-1] + ("a" if payload[-1] != "a" else "b")
|
|
||||||
with pytest.raises(ValueError, match="invalid MAC"):
|
|
||||||
decrypt(tampered, conv_key)
|
|
||||||
|
|
||||||
def test_empty_plaintext_rejected(self):
|
|
||||||
conv_key = bytes.fromhex(SPEC_VECTOR["conversation_key"])
|
|
||||||
with pytest.raises(ValueError, match="invalid plaintext length"):
|
|
||||||
encrypt("", conv_key)
|
|
||||||
|
|
||||||
def test_unknown_version_rejected(self):
|
|
||||||
with pytest.raises(ValueError, match="unknown version"):
|
|
||||||
decrypt("#invalid", bytes(32))
|
|
||||||
|
|
||||||
def test_short_payload_rejected(self):
|
|
||||||
with pytest.raises(ValueError, match="invalid payload size"):
|
|
||||||
decrypt("AAAA", bytes(32))
|
|
||||||
|
|
@ -1,258 +0,0 @@
|
||||||
"""Tests for NIP-59 gift wrap protocol.
|
|
||||||
|
|
||||||
Post-aiolabs/nostrmarket#5: the merchant-identity crypto operations
|
|
||||||
(`create_seal`, `unseal`, `unwrap_gift_wrap`, `wrap_message`,
|
|
||||||
`unwrap_message`) are async + take a `NostrSigner`-shaped object
|
|
||||||
instead of a raw privkey. These tests use a local-privkey-backed
|
|
||||||
fake signer so the NIP-59 plumbing can be tested in isolation —
|
|
||||||
the real runtime uses `RemoteBunkerSigner` against nsecbunkerd.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import time
|
|
||||||
|
|
||||||
import coincurve
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from nostr.event import NostrEvent
|
|
||||||
from nostr.nip44 import decrypt as _nip44_decrypt
|
|
||||||
from nostr.nip44 import encrypt as _nip44_encrypt
|
|
||||||
from nostr.nip44 import get_conversation_key
|
|
||||||
from nostr.nip59 import (
|
|
||||||
create_gift_wrap,
|
|
||||||
create_rumor,
|
|
||||||
create_seal,
|
|
||||||
unseal,
|
|
||||||
unwrap_gift_wrap,
|
|
||||||
unwrap_message,
|
|
||||||
wrap_message,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _generate_keypair() -> tuple[str, str]:
|
|
||||||
"""Generate a (privkey_hex, pubkey_hex) pair."""
|
|
||||||
sk = coincurve.PrivateKey()
|
|
||||||
privkey = sk.secret.hex()
|
|
||||||
pubkey = sk.public_key.format(compressed=True)[1:].hex()
|
|
||||||
return privkey, pubkey
|
|
||||||
|
|
||||||
|
|
||||||
class _LocalSignerStub:
|
|
||||||
"""Stand-in for the lnbits `NostrSigner` ABC backed by a held privkey.
|
|
||||||
|
|
||||||
Provides just the surface the NIP-59 functions touch:
|
|
||||||
`pubkey`, `nip44_encrypt`, `nip44_decrypt`, `sign_event`. Useful for
|
|
||||||
unit-testing the NIP-59 plumbing without involving a bunker — the
|
|
||||||
crypto is identical, only the dispatch boundary differs.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, privkey_hex: str):
|
|
||||||
self._privkey = privkey_hex
|
|
||||||
sk = coincurve.PrivateKey(bytes.fromhex(privkey_hex))
|
|
||||||
self.pubkey = sk.public_key.format(compressed=True)[1:].hex()
|
|
||||||
|
|
||||||
async def nip44_encrypt(self, plaintext: str, peer_pubkey_hex: str) -> str:
|
|
||||||
return _nip44_encrypt(
|
|
||||||
plaintext, get_conversation_key(self._privkey, peer_pubkey_hex)
|
|
||||||
)
|
|
||||||
|
|
||||||
async def nip44_decrypt(self, ciphertext: str, peer_pubkey_hex: str) -> str:
|
|
||||||
return _nip44_decrypt(
|
|
||||||
ciphertext, get_conversation_key(self._privkey, peer_pubkey_hex)
|
|
||||||
)
|
|
||||||
|
|
||||||
async def sign_event(self, unsigned: dict) -> dict:
|
|
||||||
evt = NostrEvent(
|
|
||||||
pubkey=unsigned["pubkey"],
|
|
||||||
created_at=unsigned["created_at"],
|
|
||||||
kind=unsigned["kind"],
|
|
||||||
tags=unsigned["tags"],
|
|
||||||
content=unsigned["content"],
|
|
||||||
)
|
|
||||||
evt.id = evt.event_id
|
|
||||||
sk = coincurve.PrivateKey(bytes.fromhex(self._privkey))
|
|
||||||
sig = sk.sign_schnorr(bytes.fromhex(evt.id)).hex()
|
|
||||||
return {**unsigned, "id": evt.id, "sig": sig}
|
|
||||||
|
|
||||||
|
|
||||||
SENDER_PRIV, SENDER_PUB = _generate_keypair()
|
|
||||||
RECIPIENT_PRIV, RECIPIENT_PUB = _generate_keypair()
|
|
||||||
SENDER_SIGNER = _LocalSignerStub(SENDER_PRIV)
|
|
||||||
RECIPIENT_SIGNER = _LocalSignerStub(RECIPIENT_PRIV)
|
|
||||||
|
|
||||||
|
|
||||||
class TestCreateRumor:
|
|
||||||
def test_has_id_but_no_sig(self):
|
|
||||||
rumor = create_rumor(SENDER_PUB, "hello", kind=14)
|
|
||||||
assert rumor.id != ""
|
|
||||||
assert rumor.sig is None
|
|
||||||
|
|
||||||
def test_kind_and_content(self):
|
|
||||||
rumor = create_rumor(SENDER_PUB, "test message", kind=14, tags=[["p", RECIPIENT_PUB]])
|
|
||||||
assert rumor.kind == 14
|
|
||||||
assert rumor.content == "test message"
|
|
||||||
assert rumor.pubkey == SENDER_PUB
|
|
||||||
assert ["p", RECIPIENT_PUB] in rumor.tags
|
|
||||||
|
|
||||||
def test_custom_timestamp(self):
|
|
||||||
ts = 1700000000
|
|
||||||
rumor = create_rumor(SENDER_PUB, "hello", created_at=ts)
|
|
||||||
assert rumor.created_at == ts
|
|
||||||
|
|
||||||
|
|
||||||
class TestCreateSeal:
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_kind_13_with_empty_tags(self):
|
|
||||||
rumor = create_rumor(SENDER_PUB, "hello")
|
|
||||||
seal = await create_seal(rumor, SENDER_SIGNER, RECIPIENT_PUB)
|
|
||||||
assert seal.kind == 13
|
|
||||||
assert seal.tags == []
|
|
||||||
assert seal.pubkey == SENDER_PUB
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_is_signed(self):
|
|
||||||
rumor = create_rumor(SENDER_PUB, "hello")
|
|
||||||
seal = await create_seal(rumor, SENDER_SIGNER, RECIPIENT_PUB)
|
|
||||||
assert seal.sig is not None
|
|
||||||
assert len(seal.sig) == 128 # 64 bytes hex
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_content_is_encrypted(self):
|
|
||||||
rumor = create_rumor(SENDER_PUB, "hello")
|
|
||||||
seal = await create_seal(rumor, SENDER_SIGNER, RECIPIENT_PUB)
|
|
||||||
# Content should be base64 NIP-44 payload, not plaintext JSON
|
|
||||||
assert "hello" not in seal.content
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_timestamp_is_randomized(self):
|
|
||||||
rumor = create_rumor(SENDER_PUB, "hello")
|
|
||||||
seal = await create_seal(rumor, SENDER_SIGNER, RECIPIENT_PUB)
|
|
||||||
now = int(time.time())
|
|
||||||
# Seal timestamp should be in the past (up to 2 days)
|
|
||||||
assert seal.created_at <= now
|
|
||||||
assert seal.created_at >= now - (2 * 24 * 60 * 60 + 10)
|
|
||||||
|
|
||||||
|
|
||||||
class TestCreateGiftWrap:
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_kind_1059_with_p_tag(self):
|
|
||||||
rumor = create_rumor(SENDER_PUB, "hello")
|
|
||||||
seal = await create_seal(rumor, SENDER_SIGNER, RECIPIENT_PUB)
|
|
||||||
wrap = create_gift_wrap(seal, RECIPIENT_PUB)
|
|
||||||
assert wrap.kind == 1059
|
|
||||||
assert ["p", RECIPIENT_PUB] in wrap.tags
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_uses_ephemeral_key(self):
|
|
||||||
rumor = create_rumor(SENDER_PUB, "hello")
|
|
||||||
seal = await create_seal(rumor, SENDER_SIGNER, RECIPIENT_PUB)
|
|
||||||
wrap = create_gift_wrap(seal, RECIPIENT_PUB)
|
|
||||||
# Gift wrap pubkey should be neither sender nor recipient
|
|
||||||
assert wrap.pubkey != SENDER_PUB
|
|
||||||
assert wrap.pubkey != RECIPIENT_PUB
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_different_wraps_have_different_ephemeral_keys(self):
|
|
||||||
rumor = create_rumor(SENDER_PUB, "hello")
|
|
||||||
seal = await create_seal(rumor, SENDER_SIGNER, RECIPIENT_PUB)
|
|
||||||
wrap1 = create_gift_wrap(seal, RECIPIENT_PUB)
|
|
||||||
wrap2 = create_gift_wrap(seal, RECIPIENT_PUB)
|
|
||||||
assert wrap1.pubkey != wrap2.pubkey
|
|
||||||
|
|
||||||
|
|
||||||
class TestUnwrap:
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_unwrap_gift_wrap_returns_seal(self):
|
|
||||||
rumor = create_rumor(SENDER_PUB, "hello")
|
|
||||||
seal = await create_seal(rumor, SENDER_SIGNER, RECIPIENT_PUB)
|
|
||||||
wrap = create_gift_wrap(seal, RECIPIENT_PUB)
|
|
||||||
|
|
||||||
recovered_seal = await unwrap_gift_wrap(wrap, RECIPIENT_SIGNER)
|
|
||||||
assert recovered_seal.kind == 13
|
|
||||||
assert recovered_seal.pubkey == SENDER_PUB
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_unseal_returns_rumor(self):
|
|
||||||
rumor = create_rumor(SENDER_PUB, "hello world")
|
|
||||||
seal = await create_seal(rumor, SENDER_SIGNER, RECIPIENT_PUB)
|
|
||||||
|
|
||||||
recovered_rumor = await unseal(seal, RECIPIENT_SIGNER)
|
|
||||||
assert recovered_rumor.content == "hello world"
|
|
||||||
assert recovered_rumor.pubkey == SENDER_PUB
|
|
||||||
assert recovered_rumor.kind == 14
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_wrong_key_fails(self):
|
|
||||||
rumor = create_rumor(SENDER_PUB, "secret")
|
|
||||||
seal = await create_seal(rumor, SENDER_SIGNER, RECIPIENT_PUB)
|
|
||||||
wrap = create_gift_wrap(seal, RECIPIENT_PUB)
|
|
||||||
|
|
||||||
wrong_priv, _ = _generate_keypair()
|
|
||||||
wrong_signer = _LocalSignerStub(wrong_priv)
|
|
||||||
with pytest.raises(Exception):
|
|
||||||
await unwrap_message(wrap, wrong_signer)
|
|
||||||
|
|
||||||
|
|
||||||
class TestFullRoundTrip:
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_wrap_unwrap_message(self):
|
|
||||||
content = "Are you going to the party tonight?"
|
|
||||||
wrap = await wrap_message(content, SENDER_SIGNER, RECIPIENT_PUB)
|
|
||||||
|
|
||||||
assert wrap.kind == 1059
|
|
||||||
assert ["p", RECIPIENT_PUB] in wrap.tags
|
|
||||||
|
|
||||||
rumor = await unwrap_message(wrap, RECIPIENT_SIGNER)
|
|
||||||
assert rumor.content == content
|
|
||||||
assert rumor.pubkey == SENDER_PUB
|
|
||||||
assert rumor.kind == 14
|
|
||||||
assert rumor.sig is None
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_wrap_with_custom_kind_and_tags(self):
|
|
||||||
tags = [["p", RECIPIENT_PUB], ["subject", "test"]]
|
|
||||||
wrap = await wrap_message(
|
|
||||||
"order data",
|
|
||||||
SENDER_SIGNER,
|
|
||||||
RECIPIENT_PUB,
|
|
||||||
kind=14,
|
|
||||||
tags=tags,
|
|
||||||
)
|
|
||||||
|
|
||||||
rumor = await unwrap_message(wrap, RECIPIENT_SIGNER)
|
|
||||||
assert rumor.content == "order data"
|
|
||||||
assert rumor.kind == 14
|
|
||||||
assert ["subject", "test"] in rumor.tags
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_self_wrap_for_archival(self):
|
|
||||||
"""Merchant wraps a copy to self (same sender and recipient)."""
|
|
||||||
content = '{"type": 1, "payment_options": [{"type": "ln", "link": "lnbc..."}]}'
|
|
||||||
wrap = await wrap_message(content, SENDER_SIGNER, SENDER_PUB)
|
|
||||||
|
|
||||||
rumor = await unwrap_message(wrap, SENDER_SIGNER)
|
|
||||||
assert rumor.content == content
|
|
||||||
assert rumor.pubkey == SENDER_PUB
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_json_content_preserved(self):
|
|
||||||
"""Order JSON payloads survive the wrap/unwrap cycle."""
|
|
||||||
order = {
|
|
||||||
"type": 0,
|
|
||||||
"id": "test-order-123",
|
|
||||||
"items": [{"product_id": "abc", "quantity": 2}],
|
|
||||||
"shipping_id": "zone-1",
|
|
||||||
}
|
|
||||||
content = json.dumps(order)
|
|
||||||
wrap = await wrap_message(content, SENDER_SIGNER, RECIPIENT_PUB)
|
|
||||||
|
|
||||||
rumor = await unwrap_message(wrap, RECIPIENT_SIGNER)
|
|
||||||
recovered_order = json.loads(rumor.content)
|
|
||||||
assert recovered_order == order
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_unicode_content(self):
|
|
||||||
content = "Payment received! \u2705 Your order is being processed \U0001f4e6"
|
|
||||||
wrap = await wrap_message(content, SENDER_SIGNER, RECIPIENT_PUB)
|
|
||||||
rumor = await unwrap_message(wrap, RECIPIENT_SIGNER)
|
|
||||||
assert rumor.content == content
|
|
||||||
29
toc.md
|
|
@ -1,29 +0,0 @@
|
||||||
# Terms and Conditions for LNbits Extension
|
|
||||||
|
|
||||||
## 1. Acceptance of Terms
|
|
||||||
|
|
||||||
By installing and using the LNbits extension ("Extension"), you agree to be bound by these terms and conditions ("Terms"). If you do not agree to these Terms, do not use the Extension.
|
|
||||||
|
|
||||||
## 2. License
|
|
||||||
|
|
||||||
The Extension is free and open-source software, released under [specify the FOSS license here, e.g., GPL-3.0, MIT, etc.]. You are permitted to use, copy, modify, and distribute the Extension under the terms of that license.
|
|
||||||
|
|
||||||
## 3. No Warranty
|
|
||||||
|
|
||||||
The Extension is provided "as is" and with all faults, and the developer expressly disclaims all warranties of any kind, whether express, implied, statutory, or otherwise, including but not limited to warranties of merchantability, fitness for a particular purpose, non-infringement, and any warranties arising out of course of dealing or usage of trade. No advice or information, whether oral or written, obtained from the developer or elsewhere will create any warranty not expressly stated in this Terms.
|
|
||||||
|
|
||||||
## 4. Limitation of Liability
|
|
||||||
|
|
||||||
In no event will the developer be liable to you or any third party for any direct, indirect, incidental, special, consequential, or punitive damages, including lost profit, lost revenue, loss of data, or other damages arising out of or in connection with your use of the Extension, even if the developer has been advised of the possibility of such damages. The foregoing limitation of liability shall apply to the fullest extent permitted by law in the applicable jurisdiction.
|
|
||||||
|
|
||||||
## 5. Modification of Terms
|
|
||||||
|
|
||||||
The developer reserves the right to modify these Terms at any time. You are advised to review these Terms periodically for any changes. Changes to these Terms are effective when they are posted on the appropriate location within or associated with the Extension.
|
|
||||||
|
|
||||||
## 6. General Provisions
|
|
||||||
|
|
||||||
If any provision of these Terms is held to be invalid or unenforceable, that provision will be enforced to the maximum extent permissible, and the other provisions of these Terms will remain in full force and effect. These Terms constitute the entire agreement between you and the developer regarding the use of the Extension.
|
|
||||||
|
|
||||||
## 7. Contact Information
|
|
||||||
|
|
||||||
If you have any questions about these Terms, please contact the developer at [developer's contact information].
|
|
||||||
11
views.py
|
|
@ -1,8 +1,13 @@
|
||||||
from fastapi import Depends, Request
|
import json
|
||||||
|
from http import HTTPStatus
|
||||||
|
|
||||||
|
from fastapi import Depends, Query, Request
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from loguru import logger
|
||||||
|
from starlette.responses import HTMLResponse
|
||||||
|
|
||||||
from lnbits.core.models import User
|
from lnbits.core.models import User
|
||||||
from lnbits.decorators import check_user_exists
|
from lnbits.decorators import check_user_exists
|
||||||
from starlette.responses import HTMLResponse
|
|
||||||
|
|
||||||
from . import nostrmarket_ext, nostrmarket_renderer
|
from . import nostrmarket_ext, nostrmarket_renderer
|
||||||
|
|
||||||
|
|
@ -13,7 +18,7 @@ templates = Jinja2Templates(directory="templates")
|
||||||
async def index(request: Request, user: User = Depends(check_user_exists)):
|
async def index(request: Request, user: User = Depends(check_user_exists)):
|
||||||
return nostrmarket_renderer().TemplateResponse(
|
return nostrmarket_renderer().TemplateResponse(
|
||||||
"nostrmarket/index.html",
|
"nostrmarket/index.html",
|
||||||
{"request": request, "user": user.json()},
|
{"request": request, "user": user.dict()},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
439
views_api.py
|
|
@ -4,15 +4,16 @@ from typing import List, Optional
|
||||||
|
|
||||||
from fastapi import Depends
|
from fastapi import Depends
|
||||||
from fastapi.exceptions import HTTPException
|
from fastapi.exceptions import HTTPException
|
||||||
from lnbits.core.crud import get_account
|
from loguru import logger
|
||||||
|
|
||||||
from lnbits.core.services import websocket_updater
|
from lnbits.core.services import websocket_updater
|
||||||
from lnbits.decorators import (
|
from lnbits.decorators import (
|
||||||
WalletTypeInfo,
|
WalletTypeInfo,
|
||||||
|
get_key_type,
|
||||||
require_admin_key,
|
require_admin_key,
|
||||||
require_invoice_key,
|
require_invoice_key,
|
||||||
)
|
)
|
||||||
from lnbits.utils.exchange_rates import currencies
|
from lnbits.utils.exchange_rates import currencies
|
||||||
from loguru import logger
|
|
||||||
|
|
||||||
from . import nostr_client, nostrmarket_ext
|
from . import nostr_client, nostrmarket_ext
|
||||||
from .crud import (
|
from .crud import (
|
||||||
|
|
@ -38,7 +39,6 @@ from .crud import (
|
||||||
get_last_direct_messages_time,
|
get_last_direct_messages_time,
|
||||||
get_merchant_by_pubkey,
|
get_merchant_by_pubkey,
|
||||||
get_merchant_for_user,
|
get_merchant_for_user,
|
||||||
update_merchant_pubkey,
|
|
||||||
get_order,
|
get_order,
|
||||||
get_order_by_event_id,
|
get_order_by_event_id,
|
||||||
get_orders,
|
get_orders,
|
||||||
|
|
@ -61,18 +61,19 @@ from .crud import (
|
||||||
)
|
)
|
||||||
from .helpers import normalize_public_key
|
from .helpers import normalize_public_key
|
||||||
from .models import (
|
from .models import (
|
||||||
CreateMerchantRequest,
|
|
||||||
Customer,
|
Customer,
|
||||||
DirectMessage,
|
DirectMessage,
|
||||||
DirectMessageType,
|
DirectMessageType,
|
||||||
Merchant,
|
Merchant,
|
||||||
MerchantConfig,
|
|
||||||
Order,
|
Order,
|
||||||
OrderReissue,
|
OrderReissue,
|
||||||
OrderStatusUpdate,
|
OrderStatusUpdate,
|
||||||
PartialDirectMessage,
|
PartialDirectMessage,
|
||||||
PartialMerchant,
|
PartialMerchant,
|
||||||
PartialOrder,
|
PartialOrder,
|
||||||
|
PartialProduct,
|
||||||
|
PartialStall,
|
||||||
|
PartialZone,
|
||||||
PaymentOption,
|
PaymentOption,
|
||||||
PaymentRequest,
|
PaymentRequest,
|
||||||
Product,
|
Product,
|
||||||
|
|
@ -80,103 +81,76 @@ from .models import (
|
||||||
Zone,
|
Zone,
|
||||||
)
|
)
|
||||||
from .services import (
|
from .services import (
|
||||||
|
reply_to_structured_dm,
|
||||||
build_order_with_payment,
|
build_order_with_payment,
|
||||||
create_or_update_order_from_dm,
|
create_or_update_order_from_dm,
|
||||||
provision_merchant,
|
|
||||||
reply_to_structured_dm,
|
|
||||||
resubscribe_to_all_merchants,
|
resubscribe_to_all_merchants,
|
||||||
send_dm,
|
|
||||||
sign_and_send_to_nostr,
|
sign_and_send_to_nostr,
|
||||||
subscribe_to_all_merchants,
|
subscribe_to_all_merchants,
|
||||||
update_merchant_to_nostr,
|
update_merchant_to_nostr,
|
||||||
)
|
)
|
||||||
|
|
||||||
######################################## MERCHANT ######################################
|
######################################## MERCHANT ########################################
|
||||||
|
|
||||||
|
|
||||||
async def _auto_create_merchant(
|
|
||||||
wallet: WalletTypeInfo,
|
|
||||||
config: MerchantConfig | None = None,
|
|
||||||
) -> Merchant:
|
|
||||||
"""
|
|
||||||
Lazy fallback: provision a merchant from the user's account keypair when
|
|
||||||
the LNbits-side eager provisioning didn't run (e.g., older accounts, or
|
|
||||||
upstream LNbits without our signup hook).
|
|
||||||
|
|
||||||
Post-aiolabs/nostrmarket#5: the merchant identity IS the lnbits
|
|
||||||
account identity. No `private_key` is read here — signing routes
|
|
||||||
through the account's `NostrSigner` (which holds a
|
|
||||||
`RemoteBunkerSigner` in our target deployment, with the nsec
|
|
||||||
living entirely in the bunker). The only precondition is that the
|
|
||||||
account already has a `pubkey` — every post-#9 account does, since
|
|
||||||
`create_account` provisions one via the bunker on signup.
|
|
||||||
"""
|
|
||||||
account = await get_account(wallet.wallet.user)
|
|
||||||
assert account, "User account not found"
|
|
||||||
assert account.pubkey, (
|
|
||||||
"Account has no Nostr pubkey — must be migrated to RemoteBunkerSigner "
|
|
||||||
"before a merchant can be provisioned (see aiolabs/nostrmarket#5)"
|
|
||||||
)
|
|
||||||
|
|
||||||
merchant = await provision_merchant(
|
|
||||||
user_id=wallet.wallet.user,
|
|
||||||
wallet_id=wallet.wallet.id,
|
|
||||||
public_key=account.pubkey,
|
|
||||||
display_name=account.username,
|
|
||||||
config=config,
|
|
||||||
)
|
|
||||||
|
|
||||||
await resubscribe_to_all_merchants()
|
|
||||||
await nostr_client.merchant_temp_subscription(account.pubkey)
|
|
||||||
|
|
||||||
return merchant
|
|
||||||
|
|
||||||
|
|
||||||
@nostrmarket_ext.post("/api/v1/merchant")
|
@nostrmarket_ext.post("/api/v1/merchant")
|
||||||
async def api_create_merchant(
|
async def api_create_merchant(
|
||||||
data: CreateMerchantRequest,
|
data: PartialMerchant,
|
||||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||||
) -> Merchant:
|
) -> Merchant:
|
||||||
|
|
||||||
try:
|
try:
|
||||||
merchant = await get_merchant_for_user(wallet.wallet.user)
|
merchant = await get_merchant_by_pubkey(data.public_key)
|
||||||
assert merchant is None, "A merchant already exists for this user"
|
assert merchant == None, "A merchant already uses this public key"
|
||||||
|
|
||||||
return await _auto_create_merchant(wallet, data.config)
|
merchant = await get_merchant_for_user(wallet.wallet.user)
|
||||||
|
assert merchant == None, "A merchant already exists for this user"
|
||||||
|
|
||||||
|
merchant = await create_merchant(wallet.wallet.user, data)
|
||||||
|
|
||||||
|
await create_zone(
|
||||||
|
merchant.id,
|
||||||
|
PartialZone(
|
||||||
|
id=f"online-{merchant.public_key}",
|
||||||
|
name="Online",
|
||||||
|
currency="sat",
|
||||||
|
cost=0,
|
||||||
|
countries=["Free (digital)"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
await resubscribe_to_all_merchants()
|
||||||
|
|
||||||
|
await nostr_client.merchant_temp_subscription(data.public_key)
|
||||||
|
|
||||||
|
return merchant
|
||||||
except AssertionError as ex:
|
except AssertionError as ex:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
detail=str(ex),
|
detail=str(ex),
|
||||||
) from ex
|
)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
logger.warning(ex)
|
logger.warning(ex)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||||
detail="Cannot create merchant",
|
detail="Cannot create merchant",
|
||||||
) from ex
|
)
|
||||||
|
|
||||||
|
|
||||||
@nostrmarket_ext.get("/api/v1/merchant")
|
@nostrmarket_ext.get("/api/v1/merchant")
|
||||||
async def api_get_merchant(
|
async def api_get_merchant(
|
||||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
||||||
) -> Merchant:
|
) -> Optional[Merchant]:
|
||||||
|
|
||||||
try:
|
try:
|
||||||
merchant = await get_merchant_for_user(wallet.wallet.user)
|
merchant = await get_merchant_for_user(wallet.wallet.user)
|
||||||
if not merchant:
|
if not merchant:
|
||||||
# Auto-provision merchant from the user's account keypair
|
return
|
||||||
merchant = await _auto_create_merchant(wallet)
|
|
||||||
|
|
||||||
merchant = await touch_merchant(wallet.wallet.user, merchant.id)
|
merchant = await touch_merchant(wallet.wallet.user, merchant.id)
|
||||||
assert merchant
|
|
||||||
last_dm_time = await get_last_direct_messages_time(merchant.id)
|
last_dm_time = await get_last_direct_messages_time(merchant.id)
|
||||||
assert merchant.time
|
|
||||||
merchant.config.restore_in_progress = (merchant.time - last_dm_time) < 30
|
|
||||||
|
|
||||||
# Detect keypair rotation: account key no longer matches merchant key
|
merchant.config.restore_in_progress = (merchant.time - last_dm_time) < 30
|
||||||
account = await get_account(wallet.wallet.user)
|
|
||||||
if account and account.pubkey and account.pubkey != merchant.public_key:
|
|
||||||
merchant.config.key_mismatch = True
|
|
||||||
|
|
||||||
return merchant
|
return merchant
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
|
|
@ -184,7 +158,7 @@ async def api_get_merchant(
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||||
detail="Cannot get merchant",
|
detail="Cannot get merchant",
|
||||||
) from ex
|
)
|
||||||
|
|
||||||
|
|
||||||
@nostrmarket_ext.delete("/api/v1/merchant/{merchant_id}")
|
@nostrmarket_ext.delete("/api/v1/merchant/{merchant_id}")
|
||||||
|
|
@ -212,115 +186,16 @@ async def api_delete_merchant(
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
detail=str(ex),
|
detail=str(ex),
|
||||||
) from ex
|
)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
logger.warning(ex)
|
logger.warning(ex)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||||
detail="Cannot get merchant",
|
detail="Cannot get merchant",
|
||||||
) from ex
|
)
|
||||||
finally:
|
finally:
|
||||||
await subscribe_to_all_merchants()
|
await subscribe_to_all_merchants()
|
||||||
|
|
||||||
|
|
||||||
@nostrmarket_ext.post("/api/v1/merchant/{merchant_id}/migrate-keys")
|
|
||||||
async def api_migrate_merchant_keys(
|
|
||||||
merchant_id: str,
|
|
||||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
|
||||||
) -> Merchant:
|
|
||||||
"""
|
|
||||||
Migrate a merchant to the current account keypair.
|
|
||||||
|
|
||||||
When a user rotates their Nostr keypair, the merchant still holds the old
|
|
||||||
key. This endpoint updates the merchant's keys to match the account,
|
|
||||||
then republishes all stalls and products under the new identity.
|
|
||||||
|
|
||||||
Orders and DM history are preserved (they reference customer pubkeys,
|
|
||||||
not the merchant key). Old stall/product events on relays become
|
|
||||||
orphaned — clients following the new pubkey will see the fresh events.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
merchant = await get_merchant_for_user(wallet.wallet.user)
|
|
||||||
assert merchant, "Merchant cannot be found"
|
|
||||||
assert merchant.id == merchant_id, "Wrong merchant ID"
|
|
||||||
|
|
||||||
account = await get_account(wallet.wallet.user)
|
|
||||||
assert account and account.pubkey, "Account has no Nostr pubkey"
|
|
||||||
|
|
||||||
if account.pubkey == merchant.public_key:
|
|
||||||
return merchant # already in sync
|
|
||||||
|
|
||||||
# Check no other merchant is using the new pubkey
|
|
||||||
existing = await get_merchant_by_pubkey(account.pubkey)
|
|
||||||
assert existing is None, (
|
|
||||||
"Another merchant already uses this public key"
|
|
||||||
)
|
|
||||||
|
|
||||||
old_pubkey = merchant.public_key
|
|
||||||
|
|
||||||
# Post-aiolabs/nostrmarket#5: re-pointing only the pubkey; the
|
|
||||||
# signing nsec lives in the bunker and is keyed on account.id,
|
|
||||||
# which is unchanged. No private_key column to update.
|
|
||||||
merchant = await update_merchant_pubkey(
|
|
||||||
wallet.wallet.user, merchant.id, account.pubkey,
|
|
||||||
)
|
|
||||||
assert merchant
|
|
||||||
|
|
||||||
# Republish all stalls and products under the new key
|
|
||||||
merchant = await update_merchant_to_nostr(merchant)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"[NOSTRMARKET] Migrated merchant {merchant.id} "
|
|
||||||
f"from {old_pubkey[:16]}... to {account.pubkey[:16]}..."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Resubscribe with new pubkey
|
|
||||||
await resubscribe_to_all_merchants()
|
|
||||||
|
|
||||||
return merchant
|
|
||||||
|
|
||||||
except AssertionError as ex:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
|
||||||
detail=str(ex),
|
|
||||||
) from ex
|
|
||||||
except Exception as ex:
|
|
||||||
logger.warning(ex)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
|
||||||
detail="Cannot migrate merchant keys",
|
|
||||||
) from ex
|
|
||||||
|
|
||||||
|
|
||||||
@nostrmarket_ext.patch("/api/v1/merchant/{merchant_id}")
|
|
||||||
async def api_update_merchant(
|
|
||||||
merchant_id: str,
|
|
||||||
config: MerchantConfig,
|
|
||||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
merchant = await get_merchant_for_user(wallet.wallet.user)
|
|
||||||
assert merchant, "Merchant cannot be found"
|
|
||||||
assert merchant.id == merchant_id, "Wrong merchant ID"
|
|
||||||
|
|
||||||
updated_merchant = await update_merchant(
|
|
||||||
wallet.wallet.user, merchant_id, config
|
|
||||||
)
|
|
||||||
return updated_merchant
|
|
||||||
|
|
||||||
except AssertionError as ex:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
|
||||||
detail=str(ex),
|
|
||||||
) from ex
|
|
||||||
except Exception as ex:
|
|
||||||
logger.warning(ex)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
|
||||||
detail="Cannot update merchant",
|
|
||||||
) from ex
|
|
||||||
|
|
||||||
|
|
||||||
@nostrmarket_ext.put("/api/v1/merchant/{merchant_id}/nostr")
|
@nostrmarket_ext.put("/api/v1/merchant/{merchant_id}/nostr")
|
||||||
async def api_republish_merchant(
|
async def api_republish_merchant(
|
||||||
merchant_id: str,
|
merchant_id: str,
|
||||||
|
|
@ -338,14 +213,13 @@ async def api_republish_merchant(
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
detail=str(ex),
|
detail=str(ex),
|
||||||
) from ex
|
)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
logger.warning(ex)
|
logger.warning(ex)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||||
detail="Cannot republish to nostr",
|
detail="Cannot republish to nostr",
|
||||||
) from ex
|
)
|
||||||
|
|
||||||
|
|
||||||
@nostrmarket_ext.get("/api/v1/merchant/{merchant_id}/nostr")
|
@nostrmarket_ext.get("/api/v1/merchant/{merchant_id}/nostr")
|
||||||
async def api_refresh_merchant(
|
async def api_refresh_merchant(
|
||||||
|
|
@ -363,13 +237,13 @@ async def api_refresh_merchant(
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
detail=str(ex),
|
detail=str(ex),
|
||||||
) from ex
|
)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
logger.warning(ex)
|
logger.warning(ex)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||||
detail="Cannot refresh from nostr",
|
detail="Cannot refresh from nostr",
|
||||||
) from ex
|
)
|
||||||
|
|
||||||
|
|
||||||
@nostrmarket_ext.put("/api/v1/merchant/{merchant_id}/toggle")
|
@nostrmarket_ext.put("/api/v1/merchant/{merchant_id}/toggle")
|
||||||
|
|
@ -390,17 +264,17 @@ async def api_toggle_merchant(
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
detail=str(ex),
|
detail=str(ex),
|
||||||
) from ex
|
)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
logger.warning(ex)
|
logger.warning(ex)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||||
detail="Cannot get merchant",
|
detail="Cannot get merchant",
|
||||||
) from ex
|
)
|
||||||
|
|
||||||
|
|
||||||
@nostrmarket_ext.delete("/api/v1/merchant/{merchant_id}/nostr")
|
@nostrmarket_ext.delete("/api/v1/merchant/{merchant_id}/nostr")
|
||||||
async def api_delete_merchant_on_nostr(
|
async def api_delete_merchant(
|
||||||
merchant_id: str,
|
merchant_id: str,
|
||||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||||
):
|
):
|
||||||
|
|
@ -416,22 +290,20 @@ async def api_delete_merchant_on_nostr(
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
detail=str(ex),
|
detail=str(ex),
|
||||||
) from ex
|
)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
logger.warning(ex)
|
logger.warning(ex)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||||
detail="Cannot get merchant",
|
detail="Cannot get merchant",
|
||||||
) from ex
|
)
|
||||||
|
|
||||||
|
|
||||||
######################################## ZONES ########################################
|
######################################## ZONES ########################################
|
||||||
|
|
||||||
|
|
||||||
@nostrmarket_ext.get("/api/v1/zone")
|
@nostrmarket_ext.get("/api/v1/zone")
|
||||||
async def api_get_zones(
|
async def api_get_zones(wallet: WalletTypeInfo = Depends(get_key_type)) -> List[Zone]:
|
||||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
|
||||||
) -> List[Zone]:
|
|
||||||
try:
|
try:
|
||||||
merchant = await get_merchant_for_user(wallet.wallet.user)
|
merchant = await get_merchant_for_user(wallet.wallet.user)
|
||||||
assert merchant, "Merchant cannot be found"
|
assert merchant, "Merchant cannot be found"
|
||||||
|
|
@ -440,18 +312,18 @@ async def api_get_zones(
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
detail=str(ex),
|
detail=str(ex),
|
||||||
) from ex
|
)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
logger.warning(ex)
|
logger.warning(ex)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||||
detail="Cannot get zone",
|
detail="Cannot get zone",
|
||||||
) from ex
|
)
|
||||||
|
|
||||||
|
|
||||||
@nostrmarket_ext.post("/api/v1/zone")
|
@nostrmarket_ext.post("/api/v1/zone")
|
||||||
async def api_create_zone(
|
async def api_create_zone(
|
||||||
data: Zone, wallet: WalletTypeInfo = Depends(require_admin_key)
|
data: PartialZone, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
merchant = await get_merchant_for_user(wallet.wallet.user)
|
merchant = await get_merchant_for_user(wallet.wallet.user)
|
||||||
|
|
@ -462,13 +334,13 @@ async def api_create_zone(
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
detail=str(ex),
|
detail=str(ex),
|
||||||
) from ex
|
)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
logger.warning(ex)
|
logger.warning(ex)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||||
detail="Cannot create zone",
|
detail="Cannot create zone",
|
||||||
) from ex
|
)
|
||||||
|
|
||||||
|
|
||||||
@nostrmarket_ext.patch("/api/v1/zone/{zone_id}")
|
@nostrmarket_ext.patch("/api/v1/zone/{zone_id}")
|
||||||
|
|
@ -493,14 +365,15 @@ async def api_update_zone(
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
detail=str(ex),
|
detail=str(ex),
|
||||||
) from ex
|
)
|
||||||
|
except HTTPException as ex:
|
||||||
|
raise ex
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
logger.warning(ex)
|
logger.warning(ex)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||||
detail="Cannot update zone",
|
detail="Cannot update zone",
|
||||||
) from ex
|
)
|
||||||
|
|
||||||
|
|
||||||
@nostrmarket_ext.delete("/api/v1/zone/{zone_id}")
|
@nostrmarket_ext.delete("/api/v1/zone/{zone_id}")
|
||||||
|
|
@ -521,13 +394,13 @@ async def api_delete_zone(zone_id, wallet: WalletTypeInfo = Depends(require_admi
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
detail=str(ex),
|
detail=str(ex),
|
||||||
) from ex
|
)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
logger.warning(ex)
|
logger.warning(ex)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||||
detail="Cannot delete zone",
|
detail="Cannot delete zone",
|
||||||
) from ex
|
)
|
||||||
|
|
||||||
|
|
||||||
######################################## STALLS ########################################
|
######################################## STALLS ########################################
|
||||||
|
|
@ -535,7 +408,7 @@ async def api_delete_zone(zone_id, wallet: WalletTypeInfo = Depends(require_admi
|
||||||
|
|
||||||
@nostrmarket_ext.post("/api/v1/stall")
|
@nostrmarket_ext.post("/api/v1/stall")
|
||||||
async def api_create_stall(
|
async def api_create_stall(
|
||||||
data: Stall,
|
data: PartialStall,
|
||||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||||
) -> Stall:
|
) -> Stall:
|
||||||
try:
|
try:
|
||||||
|
|
@ -557,13 +430,13 @@ async def api_create_stall(
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
detail=str(ex),
|
detail=str(ex),
|
||||||
) from ex
|
)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
logger.warning(ex)
|
logger.warning(ex)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||||
detail="Cannot create stall",
|
detail="Cannot create stall",
|
||||||
) from ex
|
)
|
||||||
|
|
||||||
|
|
||||||
@nostrmarket_ext.put("/api/v1/stall/{stall_id}")
|
@nostrmarket_ext.put("/api/v1/stall/{stall_id}")
|
||||||
|
|
@ -586,24 +459,23 @@ async def api_update_stall(
|
||||||
await update_stall(merchant.id, stall)
|
await update_stall(merchant.id, stall)
|
||||||
|
|
||||||
return stall
|
return stall
|
||||||
|
except HTTPException as ex:
|
||||||
|
raise ex
|
||||||
except (ValueError, AssertionError) as ex:
|
except (ValueError, AssertionError) as ex:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
detail=str(ex),
|
detail=str(ex),
|
||||||
) from ex
|
)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
logger.warning(ex)
|
logger.warning(ex)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||||
detail="Cannot update stall",
|
detail="Cannot update stall",
|
||||||
) from ex
|
)
|
||||||
|
|
||||||
|
|
||||||
@nostrmarket_ext.get("/api/v1/stall/{stall_id}")
|
@nostrmarket_ext.get("/api/v1/stall/{stall_id}")
|
||||||
async def api_get_stall(
|
async def api_get_stall(stall_id: str, wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||||
stall_id: str, wallet: WalletTypeInfo = Depends(require_invoice_key)
|
|
||||||
):
|
|
||||||
try:
|
try:
|
||||||
merchant = await get_merchant_for_user(wallet.wallet.user)
|
merchant = await get_merchant_for_user(wallet.wallet.user)
|
||||||
assert merchant, "Merchant cannot be found"
|
assert merchant, "Merchant cannot be found"
|
||||||
|
|
@ -618,7 +490,7 @@ async def api_get_stall(
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
detail=str(ex),
|
detail=str(ex),
|
||||||
) from ex
|
)
|
||||||
except HTTPException as ex:
|
except HTTPException as ex:
|
||||||
raise ex
|
raise ex
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
|
|
@ -626,13 +498,12 @@ async def api_get_stall(
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||||
detail="Cannot get stall",
|
detail="Cannot get stall",
|
||||||
) from ex
|
)
|
||||||
|
|
||||||
|
|
||||||
@nostrmarket_ext.get("/api/v1/stall")
|
@nostrmarket_ext.get("/api/v1/stall")
|
||||||
async def api_get_stalls(
|
async def api_get_stalls(
|
||||||
pending: Optional[bool] = False,
|
pending: Optional[bool] = False, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
merchant = await get_merchant_for_user(wallet.wallet.user)
|
merchant = await get_merchant_for_user(wallet.wallet.user)
|
||||||
|
|
@ -643,13 +514,13 @@ async def api_get_stalls(
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
detail=str(ex),
|
detail=str(ex),
|
||||||
) from ex
|
)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
logger.warning(ex)
|
logger.warning(ex)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||||
detail="Cannot get stalls",
|
detail="Cannot get stalls",
|
||||||
) from ex
|
)
|
||||||
|
|
||||||
|
|
||||||
@nostrmarket_ext.get("/api/v1/stall/product/{stall_id}")
|
@nostrmarket_ext.get("/api/v1/stall/product/{stall_id}")
|
||||||
|
|
@ -667,13 +538,13 @@ async def api_get_stall_products(
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
detail=str(ex),
|
detail=str(ex),
|
||||||
) from ex
|
)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
logger.warning(ex)
|
logger.warning(ex)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||||
detail="Cannot get stall products",
|
detail="Cannot get stall products",
|
||||||
) from ex
|
)
|
||||||
|
|
||||||
|
|
||||||
@nostrmarket_ext.get("/api/v1/stall/order/{stall_id}")
|
@nostrmarket_ext.get("/api/v1/stall/order/{stall_id}")
|
||||||
|
|
@ -695,13 +566,13 @@ async def api_get_stall_orders(
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
detail=str(ex),
|
detail=str(ex),
|
||||||
) from ex
|
)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
logger.warning(ex)
|
logger.warning(ex)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||||
detail="Cannot get stall products",
|
detail="Cannot get stall products",
|
||||||
) from ex
|
)
|
||||||
|
|
||||||
|
|
||||||
@nostrmarket_ext.delete("/api/v1/stall/{stall_id}")
|
@nostrmarket_ext.delete("/api/v1/stall/{stall_id}")
|
||||||
|
|
@ -729,21 +600,23 @@ async def api_delete_stall(
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
detail=str(ex),
|
detail=str(ex),
|
||||||
) from ex
|
)
|
||||||
|
except HTTPException as ex:
|
||||||
|
raise ex
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
logger.warning(ex)
|
logger.warning(ex)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||||
detail="Cannot delete stall",
|
detail="Cannot delete stall",
|
||||||
) from ex
|
)
|
||||||
|
|
||||||
|
|
||||||
######################################## PRODUCTS ######################################
|
######################################## PRODUCTS ########################################
|
||||||
|
|
||||||
|
|
||||||
@nostrmarket_ext.post("/api/v1/product")
|
@nostrmarket_ext.post("/api/v1/product")
|
||||||
async def api_create_product(
|
async def api_create_product(
|
||||||
data: Product,
|
data: PartialProduct,
|
||||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||||
) -> Product:
|
) -> Product:
|
||||||
try:
|
try:
|
||||||
|
|
@ -754,21 +627,6 @@ async def api_create_product(
|
||||||
assert stall, "Stall missing for product"
|
assert stall, "Stall missing for product"
|
||||||
data.config.currency = stall.currency
|
data.config.currency = stall.currency
|
||||||
|
|
||||||
# Re-publish the parent stall before publishing the product. NIP-33
|
|
||||||
# parameterized replaceable events make this idempotent on relays.
|
|
||||||
# This guarantees the customer client never sees a product whose
|
|
||||||
# parent stall isn't on the relay (e.g., when the original stall
|
|
||||||
# publish failed transiently or never ran).
|
|
||||||
try:
|
|
||||||
stall_event = await sign_and_send_to_nostr(merchant, stall)
|
|
||||||
stall.event_id = stall_event.id
|
|
||||||
await update_stall(merchant.id, stall)
|
|
||||||
except Exception as ex:
|
|
||||||
logger.warning(
|
|
||||||
f"[NOSTRMARKET] Failed to refresh stall {stall.id} "
|
|
||||||
f"before product publish: {ex}"
|
|
||||||
)
|
|
||||||
|
|
||||||
product = await create_product(merchant.id, data=data)
|
product = await create_product(merchant.id, data=data)
|
||||||
|
|
||||||
event = await sign_and_send_to_nostr(merchant, product)
|
event = await sign_and_send_to_nostr(merchant, product)
|
||||||
|
|
@ -781,13 +639,13 @@ async def api_create_product(
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
detail=str(ex),
|
detail=str(ex),
|
||||||
) from ex
|
)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
logger.warning(ex)
|
logger.warning(ex)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||||
detail="Cannot create product",
|
detail="Cannot create product",
|
||||||
) from ex
|
)
|
||||||
|
|
||||||
|
|
||||||
@nostrmarket_ext.patch("/api/v1/product/{product_id}")
|
@nostrmarket_ext.patch("/api/v1/product/{product_id}")
|
||||||
|
|
@ -817,13 +675,13 @@ async def api_update_product(
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
detail=str(ex),
|
detail=str(ex),
|
||||||
) from ex
|
)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
logger.warning(ex)
|
logger.warning(ex)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||||
detail="Cannot update product",
|
detail="Cannot update product",
|
||||||
) from ex
|
)
|
||||||
|
|
||||||
|
|
||||||
@nostrmarket_ext.get("/api/v1/product/{product_id}")
|
@nostrmarket_ext.get("/api/v1/product/{product_id}")
|
||||||
|
|
@ -841,13 +699,13 @@ async def api_get_product(
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
detail=str(ex),
|
detail=str(ex),
|
||||||
) from ex
|
)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
logger.warning(ex)
|
logger.warning(ex)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||||
detail="Cannot get product",
|
detail="Cannot get product",
|
||||||
) from ex
|
)
|
||||||
|
|
||||||
|
|
||||||
@nostrmarket_ext.delete("/api/v1/product/{product_id}")
|
@nostrmarket_ext.delete("/api/v1/product/{product_id}")
|
||||||
|
|
@ -873,13 +731,15 @@ async def api_delete_product(
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
detail=str(ex),
|
detail=str(ex),
|
||||||
) from ex
|
)
|
||||||
|
except HTTPException as ex:
|
||||||
|
raise ex
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
logger.warning(ex)
|
logger.warning(ex)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||||
detail="Cannot delete product",
|
detail="Cannot delete product",
|
||||||
) from ex
|
)
|
||||||
|
|
||||||
|
|
||||||
######################################## ORDERS ########################################
|
######################################## ORDERS ########################################
|
||||||
|
|
@ -904,13 +764,15 @@ async def api_get_order(
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
detail=str(ex),
|
detail=str(ex),
|
||||||
) from ex
|
)
|
||||||
|
except HTTPException as ex:
|
||||||
|
raise ex
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
logger.warning(ex)
|
logger.warning(ex)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||||
detail="Cannot get order",
|
detail="Cannot get order",
|
||||||
) from ex
|
)
|
||||||
|
|
||||||
|
|
||||||
@nostrmarket_ext.get("/api/v1/order")
|
@nostrmarket_ext.get("/api/v1/order")
|
||||||
|
|
@ -918,7 +780,7 @@ async def api_get_orders(
|
||||||
paid: Optional[bool] = None,
|
paid: Optional[bool] = None,
|
||||||
shipped: Optional[bool] = None,
|
shipped: Optional[bool] = None,
|
||||||
pubkey: Optional[str] = None,
|
pubkey: Optional[str] = None,
|
||||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
wallet: WalletTypeInfo = Depends(get_key_type),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
merchant = await get_merchant_for_user(wallet.wallet.user)
|
merchant = await get_merchant_for_user(wallet.wallet.user)
|
||||||
|
|
@ -932,13 +794,13 @@ async def api_get_orders(
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
detail=str(ex),
|
detail=str(ex),
|
||||||
) from ex
|
)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
logger.warning(ex)
|
logger.warning(ex)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||||
detail="Cannot get orders",
|
detail="Cannot get orders",
|
||||||
) from ex
|
)
|
||||||
|
|
||||||
|
|
||||||
@nostrmarket_ext.patch("/api/v1/order/{order_id}")
|
@nostrmarket_ext.patch("/api/v1/order/{order_id}")
|
||||||
|
|
@ -947,7 +809,7 @@ async def api_update_order_status(
|
||||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||||
) -> Order:
|
) -> Order:
|
||||||
try:
|
try:
|
||||||
assert data.shipped is not None, "Shipped value is required for order"
|
assert data.shipped != None, "Shipped value is required for order"
|
||||||
merchant = await get_merchant_for_user(wallet.wallet.user)
|
merchant = await get_merchant_for_user(wallet.wallet.user)
|
||||||
assert merchant, "Merchant cannot be found for order {data.id}"
|
assert merchant, "Merchant cannot be found for order {data.id}"
|
||||||
|
|
||||||
|
|
@ -961,11 +823,27 @@ async def api_update_order_status(
|
||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
await send_dm(
|
dm_event = merchant.build_dm_event(dm_content, order.public_key)
|
||||||
merchant,
|
|
||||||
order.public_key,
|
dm = PartialDirectMessage(
|
||||||
DirectMessageType.ORDER_PAID_OR_SHIPPED.value,
|
event_id=dm_event.id,
|
||||||
dm_content,
|
event_created_at=dm_event.created_at,
|
||||||
|
message=dm_content,
|
||||||
|
public_key=order.public_key,
|
||||||
|
type=DirectMessageType.ORDER_PAID_OR_SHIPPED.value,
|
||||||
|
)
|
||||||
|
await create_direct_message(merchant.id, dm)
|
||||||
|
|
||||||
|
await nostr_client.publish_nostr_event(dm_event)
|
||||||
|
await websocket_updater(
|
||||||
|
merchant.id,
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"type": f"dm:{dm.type}",
|
||||||
|
"customerPubkey": order.public_key,
|
||||||
|
"dm": dm.dict(),
|
||||||
|
}
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
return order
|
return order
|
||||||
|
|
@ -974,20 +852,20 @@ async def api_update_order_status(
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
detail=str(ex),
|
detail=str(ex),
|
||||||
) from ex
|
)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
logger.warning(ex)
|
logger.warning(ex)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||||
detail="Cannot update order",
|
detail="Cannot update order",
|
||||||
) from ex
|
)
|
||||||
|
|
||||||
|
|
||||||
@nostrmarket_ext.put("/api/v1/order/restore/{event_id}")
|
@nostrmarket_ext.put("/api/v1/order/restore/{event_id}")
|
||||||
async def api_restore_order(
|
async def api_restore_order(
|
||||||
event_id: str,
|
event_id: str,
|
||||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||||
) -> Optional[Order]:
|
) -> Order:
|
||||||
try:
|
try:
|
||||||
merchant = await get_merchant_for_user(wallet.wallet.user)
|
merchant = await get_merchant_for_user(wallet.wallet.user)
|
||||||
assert merchant, "Merchant cannot be found"
|
assert merchant, "Merchant cannot be found"
|
||||||
|
|
@ -1003,13 +881,13 @@ async def api_restore_order(
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
detail=str(ex),
|
detail=str(ex),
|
||||||
) from ex
|
)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
logger.warning(ex)
|
logger.warning(ex)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||||
detail="Cannot restore order",
|
detail="Cannot restore order",
|
||||||
) from ex
|
)
|
||||||
|
|
||||||
|
|
||||||
@nostrmarket_ext.put("/api/v1/orders/restore")
|
@nostrmarket_ext.put("/api/v1/orders/restore")
|
||||||
|
|
@ -1028,20 +906,20 @@ async def api_restore_orders(
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Failed to restore order from event '{dm.event_id}': '{e!s}'."
|
f"Failed to restore order from event '{dm.event_id}': '{str(e)}'."
|
||||||
)
|
)
|
||||||
|
|
||||||
except AssertionError as ex:
|
except AssertionError as ex:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
detail=str(ex),
|
detail=str(ex),
|
||||||
) from ex
|
)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
logger.warning(ex)
|
logger.warning(ex)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||||
detail="Cannot restore orders",
|
detail="Cannot restore orders",
|
||||||
) from ex
|
)
|
||||||
|
|
||||||
|
|
||||||
@nostrmarket_ext.put("/api/v1/order/reissue")
|
@nostrmarket_ext.put("/api/v1/order/reissue")
|
||||||
|
|
@ -1077,9 +955,7 @@ async def api_reissue_order_invoice(
|
||||||
**order_update,
|
**order_update,
|
||||||
)
|
)
|
||||||
payment_req = PaymentRequest(
|
payment_req = PaymentRequest(
|
||||||
id=data.id,
|
id=data.id, payment_options=[PaymentOption(type="ln", link=invoice)], message=receipt
|
||||||
payment_options=[PaymentOption(type="ln", link=invoice)],
|
|
||||||
message=receipt,
|
|
||||||
)
|
)
|
||||||
response = {
|
response = {
|
||||||
"type": DirectMessageType.PAYMENT_REQUEST.value,
|
"type": DirectMessageType.PAYMENT_REQUEST.value,
|
||||||
|
|
@ -1099,25 +975,25 @@ async def api_reissue_order_invoice(
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
detail=str(ex),
|
detail=str(ex),
|
||||||
) from ex
|
)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
logger.warning(ex)
|
logger.warning(ex)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||||
detail="Cannot reissue order invoice",
|
detail="Cannot reissue order invoice",
|
||||||
) from ex
|
)
|
||||||
|
|
||||||
|
|
||||||
######################################## DIRECT MESSAGES ###############################
|
######################################## DIRECT MESSAGES ########################################
|
||||||
|
|
||||||
|
|
||||||
@nostrmarket_ext.get("/api/v1/message/{public_key}")
|
@nostrmarket_ext.get("/api/v1/message/{public_key}")
|
||||||
async def api_get_messages(
|
async def api_get_messages(
|
||||||
public_key: str, wallet: WalletTypeInfo = Depends(require_invoice_key)
|
public_key: str, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||||
) -> List[DirectMessage]:
|
) -> List[DirectMessage]:
|
||||||
try:
|
try:
|
||||||
merchant = await get_merchant_for_user(wallet.wallet.user)
|
merchant = await get_merchant_for_user(wallet.wallet.user)
|
||||||
assert merchant, "Merchant cannot be found"
|
assert merchant, f"Merchant cannot be found"
|
||||||
|
|
||||||
messages = await get_direct_messages(merchant.id, public_key)
|
messages = await get_direct_messages(merchant.id, public_key)
|
||||||
await update_customer_no_unread_messages(merchant.id, public_key)
|
await update_customer_no_unread_messages(merchant.id, public_key)
|
||||||
|
|
@ -1126,13 +1002,13 @@ async def api_get_messages(
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
detail=str(ex),
|
detail=str(ex),
|
||||||
) from ex
|
)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
logger.warning(ex)
|
logger.warning(ex)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||||
detail="Cannot get direct message",
|
detail="Cannot get direct message",
|
||||||
) from ex
|
)
|
||||||
|
|
||||||
|
|
||||||
@nostrmarket_ext.post("/api/v1/message")
|
@nostrmarket_ext.post("/api/v1/message")
|
||||||
|
|
@ -1141,51 +1017,52 @@ async def api_create_message(
|
||||||
) -> DirectMessage:
|
) -> DirectMessage:
|
||||||
try:
|
try:
|
||||||
merchant = await get_merchant_for_user(wallet.wallet.user)
|
merchant = await get_merchant_for_user(wallet.wallet.user)
|
||||||
assert merchant, "Merchant cannot be found"
|
assert merchant, f"Merchant cannot be found"
|
||||||
|
|
||||||
dm_reply = await send_dm(
|
dm_event = merchant.build_dm_event(data.message, data.public_key)
|
||||||
merchant,
|
data.event_id = dm_event.id
|
||||||
data.public_key,
|
data.event_created_at = dm_event.created_at
|
||||||
data.type,
|
|
||||||
data.message,
|
dm = await create_direct_message(merchant.id, data)
|
||||||
)
|
await nostr_client.publish_nostr_event(dm_event)
|
||||||
return dm_reply
|
|
||||||
|
return dm
|
||||||
except AssertionError as ex:
|
except AssertionError as ex:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
detail=str(ex),
|
detail=str(ex),
|
||||||
) from ex
|
)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
logger.warning(ex)
|
logger.warning(ex)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||||
detail="Cannot create message",
|
detail="Cannot create message",
|
||||||
) from ex
|
)
|
||||||
|
|
||||||
|
|
||||||
######################################## CUSTOMERS #####################################
|
######################################## CUSTOMERS ########################################
|
||||||
|
|
||||||
|
|
||||||
@nostrmarket_ext.get("/api/v1/customer")
|
@nostrmarket_ext.get("/api/v1/customer")
|
||||||
async def api_get_customers(
|
async def api_get_customers(
|
||||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
wallet: WalletTypeInfo = Depends(get_key_type),
|
||||||
) -> List[Customer]:
|
) -> List[Customer]:
|
||||||
try:
|
try:
|
||||||
merchant = await get_merchant_for_user(wallet.wallet.user)
|
merchant = await get_merchant_for_user(wallet.wallet.user)
|
||||||
assert merchant, "Merchant cannot be found"
|
assert merchant, f"Merchant cannot be found"
|
||||||
return await get_customers(merchant.id)
|
return await get_customers(merchant.id)
|
||||||
|
|
||||||
except AssertionError as ex:
|
except AssertionError as ex:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
detail=str(ex),
|
detail=str(ex),
|
||||||
) from ex
|
)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
logger.warning(ex)
|
logger.warning(ex)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||||
detail="Cannot create message",
|
detail="Cannot create message",
|
||||||
) from ex
|
)
|
||||||
|
|
||||||
|
|
||||||
@nostrmarket_ext.post("/api/v1/customer")
|
@nostrmarket_ext.post("/api/v1/customer")
|
||||||
|
|
@ -1202,7 +1079,7 @@ async def api_create_customer(
|
||||||
assert merchant.id == data.merchant_id, "Invalid merchant id for user"
|
assert merchant.id == data.merchant_id, "Invalid merchant id for user"
|
||||||
|
|
||||||
existing_customer = await get_customer(merchant.id, pubkey)
|
existing_customer = await get_customer(merchant.id, pubkey)
|
||||||
assert existing_customer is None, "This public key already exists"
|
assert existing_customer == None, "This public key already exists"
|
||||||
|
|
||||||
customer = await create_customer(
|
customer = await create_customer(
|
||||||
merchant.id, Customer(merchant_id=merchant.id, public_key=pubkey)
|
merchant.id, Customer(merchant_id=merchant.id, public_key=pubkey)
|
||||||
|
|
@ -1215,13 +1092,13 @@ async def api_create_customer(
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
detail=str(ex),
|
detail=str(ex),
|
||||||
) from ex
|
)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
logger.warning(ex)
|
logger.warning(ex)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||||
detail="Cannot create customer",
|
detail="Cannot create customer",
|
||||||
) from ex
|
)
|
||||||
|
|
||||||
|
|
||||||
######################################## OTHER ########################################
|
######################################## OTHER ########################################
|
||||||
|
|
|
||||||