Compare commits
78 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| adda751eb0 | |||
| 774c3586a1 | |||
| 14e7ea63eb | |||
| c677e1bb7d | |||
| c859b95521 | |||
| 50f87c9970 | |||
| 05ebf042ac | |||
| e481c9179d | |||
| 3cc798aab2 | |||
| 25023df8bd | |||
| 5c38947fc6 | |||
| 725944ae9c | |||
| 319d5eeb04 | |||
| d3229cd094 | |||
| 039a26d1df | |||
| 0b7523fbeb | |||
| c7f6b209dd | |||
| a8eeace36d | |||
| d4c1bc04ec | |||
| 0ebd5f642c | |||
|
|
125481bda8 |
||
|
|
77a7ab8153 |
||
|
|
ac879e29f2 | ||
|
|
754003eb52 | ||
|
|
75c6d388a5 | ||
|
|
94af43c3f5 | ||
|
|
216b53cb31 | ||
|
|
7771201c32 | ||
|
|
20dc241c89 |
||
|
|
1777b5df6d | ||
|
|
1a6d1aed10 |
||
|
|
d5c9e39b7b | ||
|
|
2bde92bef6 |
||
|
|
7aec14854c | ||
|
|
f466559b51 | ||
|
|
ce0cc3813e | ||
|
|
0d945d5bfd | ||
|
|
a21b5289c1 | ||
|
|
c3dea9f01d | ||
|
|
cd0bca1c85 | ||
|
|
19dbe64e7f | ||
|
|
a2931d675a | ||
|
|
053dcd1785 |
||
|
|
e2150edc10 |
||
|
|
5e22796198 |
||
|
|
05a23fae0b | ||
|
|
db550bc9dd |
||
|
|
3d503cefff | ||
|
|
18dd78b50f |
||
|
|
9b95ae22a9 |
||
|
|
34fde3dc13 |
||
|
|
9bdc54e57a |
||
|
|
3a5e0628bf |
||
|
|
1f9f037fb9 |
||
|
|
c7febeb9f5 |
||
|
|
65a6bb3786 | ||
|
|
c4f0eb4d91 | ||
|
|
0b8ed70350 | ||
|
|
7bfc687a87 | ||
|
|
e568d55760 | ||
|
|
0e2cad101d | ||
|
|
284608e73c | ||
|
|
bcdd001e1b | ||
|
|
3c16ebb2b7 | ||
|
|
4dad0a0029 | ||
|
|
d51a66cd69 | ||
|
|
dbd64f7faf | ||
|
|
1f708fff66 | ||
|
|
bd010ece6b | ||
|
|
0e8a8d3591 | ||
|
|
99b3ca6db7 | ||
|
|
dba3cf2165 | ||
|
|
b8c2c99175 | ||
|
|
d2755d7232 | ||
|
|
9911a03575 | ||
|
|
71f458b9b9 | ||
|
|
697fc1260d | ||
|
|
17d13dbe6b |
43 changed files with 5182 additions and 1137 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -22,4 +22,7 @@ node_modules
|
|||
*.swp
|
||||
*.pyo
|
||||
*.pyc
|
||||
*.env
|
||||
*.env
|
||||
|
||||
# Claude Code config
|
||||
CLAUDE.md
|
||||
104
CLAUDE.md
Normal file
104
CLAUDE.md
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Nostr Market is an LNbits extension implementing NIP-15 (decentralized marketplace protocol) on Nostr. It enables merchants to create webshops (stalls) and sell products with Lightning Network payments, featuring encrypted customer-merchant communication via NIP-04.
|
||||
|
||||
**Prerequisites:** Requires the LNbits [nostrclient](https://github.com/lnbits/nostrclient) extension to be installed and configured.
|
||||
|
||||
## Common Commands
|
||||
|
||||
All commands are in the Makefile:
|
||||
|
||||
```bash
|
||||
make format # Run prettier, black, and ruff formatters
|
||||
make check # Run mypy, pyright, black check, ruff check, prettier check
|
||||
make test # Run pytest with debug mode
|
||||
make all # Run format and check
|
||||
```
|
||||
|
||||
Individual tools:
|
||||
|
||||
```bash
|
||||
make black # Format Python files
|
||||
make ruff # Check and fix Python linting
|
||||
make mypy # Static type checking
|
||||
make pyright # Python static type checker
|
||||
make prettier # Format JS/HTML/CSS files
|
||||
```
|
||||
|
||||
## Local Development Setup
|
||||
|
||||
To run checks locally, install dependencies:
|
||||
|
||||
```bash
|
||||
# Install Python autotools dependencies (needed for secp256k1)
|
||||
sudo apt-get install -y automake autoconf libtool
|
||||
|
||||
# Install Python dependencies
|
||||
uv sync
|
||||
|
||||
# Install Node dependencies (for prettier)
|
||||
npm install
|
||||
|
||||
# Run all checks
|
||||
make check
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Core Layers
|
||||
|
||||
1. **API Layer** (`views_api.py`) - REST endpoints for merchants, stalls, products, zones, orders, direct messages
|
||||
2. **Business Logic** (`services.py`) - Order processing, Nostr event signing/publishing, message routing, invoice handling
|
||||
3. **Data Layer** (`crud.py`) - Async SQLite operations via LNbits db module
|
||||
4. **Models** (`models.py`) - Pydantic models for all entities
|
||||
|
||||
### Nostr Integration (`nostr/`)
|
||||
|
||||
- `nostr_client.py` - WebSocket client connecting to nostrclient extension for relay communication
|
||||
- `event.py` - Nostr event model, serialization, ID computation (SHA256), Schnorr signatures
|
||||
|
||||
### Background Tasks (`__init__.py`, `tasks.py`)
|
||||
|
||||
Three permanent async tasks:
|
||||
|
||||
- `wait_for_paid_invoices()` - Lightning payment listener
|
||||
- `wait_for_nostr_events()` - Incoming Nostr message processor
|
||||
- `_subscribe_to_nostr_client()` - WebSocket connection manager
|
||||
|
||||
### Frontend (`static/`, `templates/`)
|
||||
|
||||
- Merchant dashboard: `templates/nostrmarket/index.html`
|
||||
- Customer marketplace: `templates/nostrmarket/market.html` with Vue.js/Quasar in `static/market/`
|
||||
- Use Quasar UI components when possible: https://quasar.dev/components
|
||||
|
||||
### Key Data Models
|
||||
|
||||
- **Merchant** - Shop owner with Nostr keypair, handles event signing and DM encryption
|
||||
- **Stall** - Individual shop with products and shipping zones (kind 30017)
|
||||
- **Product** - Items for sale with categories, images, quantity (kind 30018)
|
||||
- **Zone** - Shipping configuration by region
|
||||
- **Order** - Customer purchases with Lightning invoice tracking
|
||||
- **DirectMessage** - Encrypted chat (NIP-04)
|
||||
- **Customer** - Buyer profile with Nostr pubkey
|
||||
|
||||
### Key Patterns
|
||||
|
||||
- **Nostrable Interface** - Base class for models convertible to Nostr events (`to_nostr_event()`, `to_nostr_delete_event()`)
|
||||
- **Parameterized Replaceable Events** - Stalls (kind 30017) and Products (kind 30018) per NIP-33
|
||||
- **AES-256 Encryption** - Customer-merchant DMs use shared secret from ECDH
|
||||
- **JSON Meta Fields** - Complex data (zones, items, config) stored as JSON in database
|
||||
|
||||
### Cryptography (`helpers.py`)
|
||||
|
||||
- Schnorr signatures for Nostr events
|
||||
- NIP-04 encryption/decryption
|
||||
- Key derivation and bech32 encoding (npub/nsec)
|
||||
|
||||
## Workflow
|
||||
|
||||
- Always check GitHub Actions after pushing to verify CI passes
|
||||
- Run `make check` locally before pushing to catch issues early
|
||||
17
README.md
17
README.md
|
|
@ -1,3 +1,13 @@
|
|||
<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>
|
||||
|
||||
<small>For more about LNBits extension check [this tutorial](https://github.com/lnbits/lnbits/wiki/LNbits-Extensions).</small>
|
||||
|
|
@ -147,3 +157,10 @@ 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).
|
||||
|
||||
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)
|
||||
|
|
|
|||
17
__init__.py
17
__init__.py
|
|
@ -27,7 +27,11 @@ def nostrmarket_renderer():
|
|||
nostr_client: NostrClient = NostrClient()
|
||||
|
||||
|
||||
from .tasks import wait_for_nostr_events, wait_for_paid_invoices # noqa
|
||||
from .tasks import ( # noqa
|
||||
subscription_health_monitor,
|
||||
wait_for_nostr_events,
|
||||
wait_for_paid_invoices,
|
||||
)
|
||||
from .views import * # noqa
|
||||
from .views_api import * # noqa
|
||||
|
||||
|
|
@ -65,4 +69,13 @@ def nostrmarket_start():
|
|||
task3 = create_permanent_unique_task(
|
||||
"ext_nostrmarket_wait_for_events", _wait_for_nostr_events
|
||||
)
|
||||
scheduled_tasks.extend([task1, task2, task3])
|
||||
|
||||
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])
|
||||
|
|
|
|||
18
config.json
18
config.json
|
|
@ -1,12 +1,15 @@
|
|||
{
|
||||
"name": "Nostr Market",
|
||||
"id": "nostrmarket",
|
||||
"version": "1.1.0",
|
||||
"name": "Nostr Market",
|
||||
"repo": "https://github.com/lnbits/nostrmarket",
|
||||
"short_description": "Nostr Webshop/market on LNbits",
|
||||
"description": "",
|
||||
"tile": "/nostrmarket/static/images/bitcoin-shop.png",
|
||||
"min_lnbits_version": "1.4.0",
|
||||
"contributors": [
|
||||
{
|
||||
"name": "motorina0",
|
||||
"name": "Vlad Stan",
|
||||
"uri": "https://github.com/motorina0",
|
||||
"role": "Contributor"
|
||||
},
|
||||
|
|
@ -19,6 +22,11 @@
|
|||
"name": "talvasconcelos",
|
||||
"uri": "https://github.com/talvasconcelos",
|
||||
"role": "Developer"
|
||||
},
|
||||
{
|
||||
"name": "BenGWeeks",
|
||||
"uri": "https://github.com/BenGWeeks",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"images": [
|
||||
|
|
@ -44,5 +52,9 @@
|
|||
],
|
||||
"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"
|
||||
"license": "MIT",
|
||||
"paid_features": "",
|
||||
"tags": ["Nostr", "Marketplace"],
|
||||
"donate": "",
|
||||
"hidden": false
|
||||
}
|
||||
|
|
|
|||
109
crud.py
109
crud.py
|
|
@ -1,4 +1,5 @@
|
|||
import json
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from lnbits.helpers import urlsafe_short_hash
|
||||
|
||||
|
|
@ -22,16 +23,19 @@ from .models import (
|
|||
|
||||
async def create_merchant(user_id: str, m: PartialMerchant) -> Merchant:
|
||||
merchant_id = urlsafe_short_hash()
|
||||
# Post-aiolabs/nostrmarket#5: no `private_key` column written. The
|
||||
# legacy column is dropped by `migrations_fork.m001_aio_drop_private_key`
|
||||
# for fresh installs and NULL-tolerated for the brief window between
|
||||
# this code change deploying and the fork-migration running.
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO nostrmarket.merchants
|
||||
(user_id, id, private_key, public_key, meta)
|
||||
VALUES (:user_id, :id, :private_key, :public_key, :meta)
|
||||
(user_id, id, public_key, meta)
|
||||
VALUES (:user_id, :id, :public_key, :meta)
|
||||
""",
|
||||
{
|
||||
"user_id": user_id,
|
||||
"id": merchant_id,
|
||||
"private_key": m.private_key,
|
||||
"public_key": m.public_key,
|
||||
"meta": json.dumps(dict(m.config)),
|
||||
},
|
||||
|
|
@ -43,7 +47,7 @@ async def create_merchant(user_id: str, m: PartialMerchant) -> Merchant:
|
|||
|
||||
async def update_merchant(
|
||||
user_id: str, merchant_id: str, config: MerchantConfig
|
||||
) -> Merchant | None:
|
||||
) -> Optional[Merchant]:
|
||||
await db.execute(
|
||||
f"""
|
||||
UPDATE nostrmarket.merchants SET meta = :meta, time = {db.timestamp_now}
|
||||
|
|
@ -54,7 +58,33 @@ async def update_merchant(
|
|||
return await get_merchant(user_id, merchant_id)
|
||||
|
||||
|
||||
async def touch_merchant(user_id: str, merchant_id: str) -> Merchant | None:
|
||||
async def update_merchant_pubkey(
|
||||
user_id: str, merchant_id: str, public_key: str
|
||||
) -> Optional[Merchant]:
|
||||
"""Re-point a merchant's identity to a new pubkey (e.g. after the
|
||||
account migrated to a fresh RemoteBunkerSigner keypair).
|
||||
|
||||
Post-aiolabs/nostrmarket#5: there is no `private_key` column to
|
||||
update — the merchant pubkey is the only stored identity material,
|
||||
and the signing nsec lives entirely in the bunker against
|
||||
`account.id` (== `merchant.user_id`) on the lnbits side.
|
||||
"""
|
||||
await db.execute(
|
||||
f"""
|
||||
UPDATE nostrmarket.merchants
|
||||
SET public_key = :public_key, time = {db.timestamp_now}
|
||||
WHERE id = :id AND user_id = :user_id
|
||||
""",
|
||||
{
|
||||
"public_key": public_key,
|
||||
"id": merchant_id,
|
||||
"user_id": user_id,
|
||||
},
|
||||
)
|
||||
return await get_merchant(user_id, merchant_id)
|
||||
|
||||
|
||||
async def touch_merchant(user_id: str, merchant_id: str) -> Optional[Merchant]:
|
||||
await db.execute(
|
||||
f"""
|
||||
UPDATE nostrmarket.merchants SET time = {db.timestamp_now}
|
||||
|
|
@ -65,7 +95,7 @@ async def touch_merchant(user_id: str, merchant_id: str) -> Merchant | None:
|
|||
return await get_merchant(user_id, merchant_id)
|
||||
|
||||
|
||||
async def get_merchant(user_id: str, merchant_id: str) -> Merchant | None:
|
||||
async def get_merchant(user_id: str, merchant_id: str) -> Optional[Merchant]:
|
||||
row: dict = await db.fetchone(
|
||||
"""SELECT * FROM nostrmarket.merchants WHERE user_id = :user_id AND id = :id""",
|
||||
{
|
||||
|
|
@ -77,7 +107,7 @@ async def get_merchant(user_id: str, merchant_id: str) -> Merchant | None:
|
|||
return Merchant.from_row(row) if row else None
|
||||
|
||||
|
||||
async def get_merchant_by_pubkey(public_key: str) -> Merchant | None:
|
||||
async def get_merchant_by_pubkey(public_key: str) -> Optional[Merchant]:
|
||||
row: dict = await db.fetchone(
|
||||
"""SELECT * FROM nostrmarket.merchants WHERE public_key = :public_key""",
|
||||
{"public_key": public_key},
|
||||
|
|
@ -86,7 +116,7 @@ async def get_merchant_by_pubkey(public_key: str) -> Merchant | None:
|
|||
return Merchant.from_row(row) if row else None
|
||||
|
||||
|
||||
async def get_merchants_ids_with_pubkeys() -> list[tuple[str, str]]:
|
||||
async def get_merchants_ids_with_pubkeys() -> List[Tuple[str, str]]:
|
||||
rows: list[dict] = await db.fetchall(
|
||||
"""SELECT id, public_key FROM nostrmarket.merchants""",
|
||||
)
|
||||
|
|
@ -94,7 +124,7 @@ async def get_merchants_ids_with_pubkeys() -> list[tuple[str, str]]:
|
|||
return [(row["id"], row["public_key"]) for row in rows]
|
||||
|
||||
|
||||
async def get_merchant_for_user(user_id: str) -> Merchant | None:
|
||||
async def get_merchant_for_user(user_id: str) -> Optional[Merchant]:
|
||||
row: dict = await db.fetchone(
|
||||
"""SELECT * FROM nostrmarket.merchants WHERE user_id = :user_id """,
|
||||
{"user_id": user_id},
|
||||
|
|
@ -137,7 +167,7 @@ async def create_zone(merchant_id: str, data: Zone) -> Zone:
|
|||
return zone
|
||||
|
||||
|
||||
async def update_zone(merchant_id: str, z: Zone) -> Zone | None:
|
||||
async def update_zone(merchant_id: str, z: Zone) -> Optional[Zone]:
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE nostrmarket.zones
|
||||
|
|
@ -156,7 +186,7 @@ async def update_zone(merchant_id: str, z: Zone) -> Zone | None:
|
|||
return await get_zone(merchant_id, z.id)
|
||||
|
||||
|
||||
async def get_zone(merchant_id: str, zone_id: str) -> Zone | None:
|
||||
async def get_zone(merchant_id: str, zone_id: str) -> Optional[Zone]:
|
||||
row: dict = await db.fetchone(
|
||||
"SELECT * FROM nostrmarket.zones WHERE merchant_id = :merchant_id AND id = :id",
|
||||
{
|
||||
|
|
@ -167,7 +197,7 @@ async def get_zone(merchant_id: str, zone_id: str) -> Zone | None:
|
|||
return Zone.from_row(row) if row else None
|
||||
|
||||
|
||||
async def get_zones(merchant_id: str) -> list[Zone]:
|
||||
async def get_zones(merchant_id: str) -> List[Zone]:
|
||||
rows: list[dict] = await db.fetchall(
|
||||
"SELECT * FROM nostrmarket.zones WHERE merchant_id = :merchant_id",
|
||||
{"merchant_id": merchant_id},
|
||||
|
|
@ -234,7 +264,7 @@ async def create_stall(merchant_id: str, data: Stall) -> Stall:
|
|||
return stall
|
||||
|
||||
|
||||
async def get_stall(merchant_id: str, stall_id: str) -> Stall | None:
|
||||
async def get_stall(merchant_id: str, stall_id: str) -> Optional[Stall]:
|
||||
row: dict = await db.fetchone(
|
||||
"""
|
||||
SELECT * FROM nostrmarket.stalls
|
||||
|
|
@ -248,7 +278,7 @@ async def get_stall(merchant_id: str, stall_id: str) -> Stall | None:
|
|||
return Stall.from_row(row) if row else None
|
||||
|
||||
|
||||
async def get_stalls(merchant_id: str, pending: bool | None = False) -> list[Stall]:
|
||||
async def get_stalls(merchant_id: str, pending: Optional[bool] = False) -> List[Stall]:
|
||||
rows: list[dict] = await db.fetchall(
|
||||
"""
|
||||
SELECT * FROM nostrmarket.stalls
|
||||
|
|
@ -273,7 +303,7 @@ async def get_last_stall_update_time() -> int:
|
|||
return row["event_created_at"] or 0 if row else 0
|
||||
|
||||
|
||||
async def update_stall(merchant_id: str, stall: Stall) -> Stall | None:
|
||||
async def update_stall(merchant_id: str, stall: Stall) -> Optional[Stall]:
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE nostrmarket.stalls
|
||||
|
|
@ -397,7 +427,9 @@ async def update_product(merchant_id: str, product: Product) -> Product:
|
|||
return updated_product
|
||||
|
||||
|
||||
async def update_product_quantity(product_id: str, new_quantity: int) -> Product | None:
|
||||
async def update_product_quantity(
|
||||
product_id: str, new_quantity: int
|
||||
) -> Optional[Product]:
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE nostrmarket.products SET quantity = :quantity
|
||||
|
|
@ -412,7 +444,7 @@ async def update_product_quantity(product_id: str, new_quantity: int) -> Product
|
|||
return Product.from_row(row) if row else None
|
||||
|
||||
|
||||
async def get_product(merchant_id: str, product_id: str) -> Product | None:
|
||||
async def get_product(merchant_id: str, product_id: str) -> Optional[Product]:
|
||||
row: dict = await db.fetchone(
|
||||
"""
|
||||
SELECT * FROM nostrmarket.products
|
||||
|
|
@ -428,8 +460,8 @@ async def get_product(merchant_id: str, product_id: str) -> Product | None:
|
|||
|
||||
|
||||
async def get_products(
|
||||
merchant_id: str, stall_id: str, pending: bool | None = False
|
||||
) -> list[Product]:
|
||||
merchant_id: str, stall_id: str, pending: Optional[bool] = False
|
||||
) -> List[Product]:
|
||||
rows: list[dict] = await db.fetchall(
|
||||
"""
|
||||
SELECT * FROM nostrmarket.products
|
||||
|
|
@ -442,8 +474,8 @@ async def get_products(
|
|||
|
||||
|
||||
async def get_products_by_ids(
|
||||
merchant_id: str, product_ids: list[str]
|
||||
) -> list[Product]:
|
||||
merchant_id: str, product_ids: List[str]
|
||||
) -> List[Product]:
|
||||
# todo: revisit
|
||||
|
||||
keys = []
|
||||
|
|
@ -464,7 +496,7 @@ async def get_products_by_ids(
|
|||
return [Product.from_row(row) for row in rows]
|
||||
|
||||
|
||||
async def get_wallet_for_product(product_id: str) -> str | None:
|
||||
async def get_wallet_for_product(product_id: str) -> Optional[str]:
|
||||
row: dict = await db.fetchone(
|
||||
"""
|
||||
SELECT s.wallet as wallet FROM nostrmarket.products p
|
||||
|
|
@ -571,7 +603,7 @@ async def create_order(merchant_id: str, o: Order) -> Order:
|
|||
return order
|
||||
|
||||
|
||||
async def get_order(merchant_id: str, order_id: str) -> Order | None:
|
||||
async def get_order(merchant_id: str, order_id: str) -> Optional[Order]:
|
||||
row: dict = await db.fetchone(
|
||||
"""
|
||||
SELECT * FROM nostrmarket.orders
|
||||
|
|
@ -585,7 +617,7 @@ async def get_order(merchant_id: str, order_id: str) -> Order | None:
|
|||
return Order.from_row(row) if row else None
|
||||
|
||||
|
||||
async def get_order_by_event_id(merchant_id: str, event_id: str) -> Order | None:
|
||||
async def get_order_by_event_id(merchant_id: str, event_id: str) -> Optional[Order]:
|
||||
row: dict = await db.fetchone(
|
||||
"""
|
||||
SELECT * FROM nostrmarket.orders
|
||||
|
|
@ -599,7 +631,7 @@ async def get_order_by_event_id(merchant_id: str, event_id: str) -> Order | None
|
|||
return Order.from_row(row) if row else None
|
||||
|
||||
|
||||
async def get_orders(merchant_id: str, **kwargs) -> list[Order]:
|
||||
async def get_orders(merchant_id: str, **kwargs) -> List[Order]:
|
||||
q = " AND ".join(
|
||||
[
|
||||
f"{field[0]} = :{field[0]}"
|
||||
|
|
@ -616,7 +648,7 @@ async def get_orders(merchant_id: str, **kwargs) -> list[Order]:
|
|||
rows: list[dict] = await db.fetchall(
|
||||
f"""
|
||||
SELECT * FROM nostrmarket.orders
|
||||
WHERE merchant_id = :merchant_id {q}
|
||||
WHERE merchant_id = :merchant_id {('AND ' + q) if q else ''}
|
||||
ORDER BY event_created_at DESC
|
||||
""",
|
||||
values,
|
||||
|
|
@ -626,7 +658,7 @@ async def get_orders(merchant_id: str, **kwargs) -> list[Order]:
|
|||
|
||||
async def get_orders_for_stall(
|
||||
merchant_id: str, stall_id: str, **kwargs
|
||||
) -> list[Order]:
|
||||
) -> List[Order]:
|
||||
q = " AND ".join(
|
||||
[
|
||||
f"{field[0]} = :{field[0]}"
|
||||
|
|
@ -640,10 +672,11 @@ async def get_orders_for_stall(
|
|||
continue
|
||||
values[field[0]] = field[1]
|
||||
|
||||
q_clause = f"AND {q}" if q else ""
|
||||
rows: list[dict] = await db.fetchall(
|
||||
f"""
|
||||
SELECT * FROM nostrmarket.orders
|
||||
WHERE merchant_id = :merchant_id AND stall_id = :stall_id {q}
|
||||
WHERE merchant_id = :merchant_id AND stall_id = :stall_id {q_clause}
|
||||
ORDER BY time DESC
|
||||
""",
|
||||
values,
|
||||
|
|
@ -651,7 +684,7 @@ async def get_orders_for_stall(
|
|||
return [Order.from_row(row) for row in rows]
|
||||
|
||||
|
||||
async def update_order(merchant_id: str, order_id: str, **kwargs) -> Order | None:
|
||||
async def update_order(merchant_id: str, order_id: str, **kwargs) -> Optional[Order]:
|
||||
q = ", ".join(
|
||||
[
|
||||
f"{field[0]} = :{field[0]}"
|
||||
|
|
@ -675,7 +708,7 @@ async def update_order(merchant_id: str, order_id: str, **kwargs) -> Order | Non
|
|||
return await get_order(merchant_id, order_id)
|
||||
|
||||
|
||||
async def update_order_paid_status(order_id: str, paid: bool) -> Order | None:
|
||||
async def update_order_paid_status(order_id: str, paid: bool) -> Optional[Order]:
|
||||
await db.execute(
|
||||
"UPDATE nostrmarket.orders SET paid = :paid WHERE id = :id",
|
||||
{"paid": paid, "id": order_id},
|
||||
|
|
@ -689,7 +722,7 @@ async def update_order_paid_status(order_id: str, paid: bool) -> Order | None:
|
|||
|
||||
async def update_order_shipped_status(
|
||||
merchant_id: str, order_id: str, shipped: bool
|
||||
) -> Order | None:
|
||||
) -> Optional[Order]:
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE nostrmarket.orders
|
||||
|
|
@ -753,7 +786,7 @@ async def create_direct_message(
|
|||
return msg
|
||||
|
||||
|
||||
async def get_direct_message(merchant_id: str, dm_id: str) -> DirectMessage | None:
|
||||
async def get_direct_message(merchant_id: str, dm_id: str) -> Optional[DirectMessage]:
|
||||
row: dict = await db.fetchone(
|
||||
"""
|
||||
SELECT * FROM nostrmarket.direct_messages
|
||||
|
|
@ -769,7 +802,7 @@ async def get_direct_message(merchant_id: str, dm_id: str) -> DirectMessage | No
|
|||
|
||||
async def get_direct_message_by_event_id(
|
||||
merchant_id: str, event_id: str
|
||||
) -> DirectMessage | None:
|
||||
) -> Optional[DirectMessage]:
|
||||
row: dict = await db.fetchone(
|
||||
"""
|
||||
SELECT * FROM nostrmarket.direct_messages
|
||||
|
|
@ -783,7 +816,7 @@ async def get_direct_message_by_event_id(
|
|||
return DirectMessage.from_row(row) if row else None
|
||||
|
||||
|
||||
async def get_direct_messages(merchant_id: str, public_key: str) -> list[DirectMessage]:
|
||||
async def get_direct_messages(merchant_id: str, public_key: str) -> List[DirectMessage]:
|
||||
rows: list[dict] = await db.fetchall(
|
||||
"""
|
||||
SELECT * FROM nostrmarket.direct_messages
|
||||
|
|
@ -795,7 +828,7 @@ async def get_direct_messages(merchant_id: str, public_key: str) -> list[DirectM
|
|||
return [DirectMessage.from_row(row) for row in rows]
|
||||
|
||||
|
||||
async def get_orders_from_direct_messages(merchant_id: str) -> list[DirectMessage]:
|
||||
async def get_orders_from_direct_messages(merchant_id: str) -> List[DirectMessage]:
|
||||
rows: list[dict] = await db.fetchall(
|
||||
"""
|
||||
SELECT * FROM nostrmarket.direct_messages
|
||||
|
|
@ -856,7 +889,7 @@ async def create_customer(merchant_id: str, data: Customer) -> Customer:
|
|||
return customer
|
||||
|
||||
|
||||
async def get_customer(merchant_id: str, public_key: str) -> Customer | None:
|
||||
async def get_customer(merchant_id: str, public_key: str) -> Optional[Customer]:
|
||||
row: dict = await db.fetchone(
|
||||
"""
|
||||
SELECT * FROM nostrmarket.customers
|
||||
|
|
@ -870,7 +903,7 @@ async def get_customer(merchant_id: str, public_key: str) -> Customer | None:
|
|||
return Customer.from_row(row) if row else None
|
||||
|
||||
|
||||
async def get_customers(merchant_id: str) -> list[Customer]:
|
||||
async def get_customers(merchant_id: str) -> List[Customer]:
|
||||
rows: list[dict] = await db.fetchall(
|
||||
"SELECT * FROM nostrmarket.customers WHERE merchant_id = :merchant_id",
|
||||
{"merchant_id": merchant_id},
|
||||
|
|
@ -878,7 +911,7 @@ async def get_customers(merchant_id: str) -> list[Customer]:
|
|||
return [Customer.from_row(row) for row in rows]
|
||||
|
||||
|
||||
async def get_all_unique_customers() -> list[Customer]:
|
||||
async def get_all_unique_customers() -> List[Customer]:
|
||||
q = """
|
||||
SELECT public_key, MAX(merchant_id) as merchant_id, MAX(event_created_at)
|
||||
FROM nostrmarket.customers
|
||||
|
|
|
|||
|
|
@ -1,12 +1,10 @@
|
|||
> IMPORTANT: Nostr market needs the nostr-client extension installed.
|
||||
Buy and sell products over Nostr using the NIP-15 marketplace protocol.
|
||||
|
||||
Buy and sell things over Nostr, using NIP15 https://github.com/nostr-protocol/nips/blob/master/15.md
|
||||
Its functions include:
|
||||
|
||||
Nostr was partly based on the the previous version of this extension "Diagon Alley", so lends itself very well to buying and sellinng over Nostr.
|
||||
- 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)
|
||||
|
||||
The Nostr Market extension includes:
|
||||
|
||||
- A merchant client to manage products, sales and communication with customers.
|
||||
- A customer client to find and order products from merchants, communicate with merchants and track status of ordered products.
|
||||
|
||||
All communication happens over NIP04 encrypted DMs.
|
||||
A decentralized commerce solution for merchants and buyers who want to trade goods and services over Nostr with end-to-end encrypted communication.
|
||||
|
|
|
|||
70
helpers.py
70
helpers.py
|
|
@ -1,71 +1,9 @@
|
|||
import base64
|
||||
import secrets
|
||||
|
||||
import coincurve
|
||||
from bech32 import bech32_decode, convertbits
|
||||
from cryptography.hazmat.primitives import padding
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
|
||||
|
||||
def get_shared_secret(privkey: str, pubkey: str):
|
||||
pk = coincurve.PublicKey(bytes.fromhex("02" + pubkey))
|
||||
sk = coincurve.PrivateKey(bytes.fromhex(privkey))
|
||||
shared_point = pk.multiply(sk.secret)
|
||||
|
||||
shared_point_bytes = shared_point.format(compressed=False)
|
||||
x_coord = shared_point_bytes[1:33]
|
||||
return x_coord
|
||||
|
||||
|
||||
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: bytes | None = 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()
|
||||
|
||||
base64_message = base64.b64encode(encrypted_message).decode()
|
||||
base64_iv = base64.b64encode(iv).decode()
|
||||
return f"{base64_message}?iv={base64_iv}"
|
||||
|
||||
|
||||
def sign_message_hash(private_key: str, hash_: bytes) -> str:
|
||||
privkey = coincurve.PrivateKey(bytes.fromhex(private_key))
|
||||
sig = privkey.sign_schnorr(hash_)
|
||||
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}'"
|
||||
# NOTE (aiolabs/nostrmarket#5): `sign_message_hash` is gone. All merchant
|
||||
# signing routes through the lnbits `NostrSigner` ABC via
|
||||
# `services._resolve_merchant_signer(merchant)`. The nsec lives in the
|
||||
# bunker, never in this process.
|
||||
|
||||
|
||||
def normalize_public_key(pubkey: str) -> str:
|
||||
|
|
|
|||
77
migrations_fork.py
Normal file
77
migrations_fork.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
"""
|
||||
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")
|
||||
320
misc-docs/ORDER-DISCOVERY-ANALYSIS.md
Normal file
320
misc-docs/ORDER-DISCOVERY-ANALYSIS.md
Normal file
|
|
@ -0,0 +1,320 @@
|
|||
# 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_
|
||||
189
models.py
189
models.py
|
|
@ -2,17 +2,11 @@ import json
|
|||
import time
|
||||
from abc import abstractmethod
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
from typing import Any, List, Optional, Tuple
|
||||
|
||||
from lnbits.utils.exchange_rates import btc_price, fiat_amount_as_satoshis
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .helpers import (
|
||||
decrypt_message,
|
||||
encrypt_message,
|
||||
get_shared_secret,
|
||||
sign_message_hash,
|
||||
)
|
||||
from .nostr.event import NostrEvent
|
||||
|
||||
######################################## NOSTR ########################################
|
||||
|
|
@ -32,52 +26,47 @@ class Nostrable:
|
|||
|
||||
|
||||
class MerchantProfile(BaseModel):
|
||||
name: str | None = None
|
||||
about: str | None = None
|
||||
picture: str | None = None
|
||||
name: Optional[str] = None
|
||||
display_name: Optional[str] = None
|
||||
about: Optional[str] = None
|
||||
picture: Optional[str] = None
|
||||
banner: Optional[str] = None
|
||||
website: Optional[str] = None
|
||||
nip05: Optional[str] = None
|
||||
lud16: Optional[str] = None
|
||||
|
||||
|
||||
class MerchantConfig(MerchantProfile):
|
||||
event_id: str | None = None
|
||||
event_id: Optional[str] = None
|
||||
sync_from_nostr: bool = False
|
||||
active: bool = False
|
||||
restore_in_progress: bool | None = False
|
||||
# TODO: switched to True for AIO demo; determine if we leave this as True
|
||||
active: bool = True
|
||||
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):
|
||||
private_key: str
|
||||
public_key: str
|
||||
config: MerchantConfig = MerchantConfig()
|
||||
|
||||
|
||||
class Merchant(PartialMerchant, Nostrable):
|
||||
id: str
|
||||
time: int | None = 0
|
||||
user_id: str
|
||||
time: Optional[int] = 0
|
||||
|
||||
def sign_hash(self, hash_: bytes) -> str:
|
||||
return sign_message_hash(self.private_key, hash_)
|
||||
|
||||
def decrypt_message(self, encrypted_message: str, public_key: str) -> str:
|
||||
encryption_key = get_shared_secret(self.private_key, public_key)
|
||||
return decrypt_message(encrypted_message, encryption_key)
|
||||
|
||||
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
|
||||
# NOTE (aiolabs/nostrmarket#5): no `sign_hash` / `encrypt_message` /
|
||||
# `decrypt_message` methods on Merchant anymore. Signing + NIP-44 crypto
|
||||
# for a merchant goes through the lnbits `NostrSigner` abstraction
|
||||
# (`resolve_signer(account)`); merchant is now pure metadata pointing
|
||||
# at its owning account via `user_id`. The bunker (`RemoteBunkerSigner`)
|
||||
# holds the merchant's nsec — lnbits never has it server-side.
|
||||
# See `services._resolve_merchant_signer()` for the resolution helper.
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: dict) -> "Merchant":
|
||||
|
|
@ -86,11 +75,23 @@ class Merchant(PartialMerchant, Nostrable):
|
|||
return merchant
|
||||
|
||||
def to_nostr_event(self, pubkey: str) -> NostrEvent:
|
||||
content = {
|
||||
"name": self.config.name,
|
||||
"about": self.config.about,
|
||||
"picture": self.config.picture,
|
||||
}
|
||||
content: dict[str, str] = {}
|
||||
if self.config.name:
|
||||
content["name"] = self.config.name
|
||||
if self.config.display_name:
|
||||
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(
|
||||
pubkey=pubkey,
|
||||
created_at=round(time.time()),
|
||||
|
|
@ -122,11 +123,11 @@ class Merchant(PartialMerchant, Nostrable):
|
|||
|
||||
######################################## ZONES ########################################
|
||||
class Zone(BaseModel):
|
||||
id: str | None = None
|
||||
name: str | None = None
|
||||
id: Optional[str] = None
|
||||
name: Optional[str] = None
|
||||
currency: str
|
||||
cost: float
|
||||
countries: list[str] = []
|
||||
countries: List[str] = []
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: dict) -> "Zone":
|
||||
|
|
@ -139,22 +140,22 @@ class Zone(BaseModel):
|
|||
|
||||
|
||||
class StallConfig(BaseModel):
|
||||
image_url: str | None = None
|
||||
description: str | None = None
|
||||
image_url: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class Stall(BaseModel, Nostrable):
|
||||
id: str | None = None
|
||||
id: Optional[str] = None
|
||||
wallet: str
|
||||
name: str
|
||||
currency: str = "sat"
|
||||
shipping_zones: list[Zone] = []
|
||||
shipping_zones: List[Zone] = []
|
||||
config: StallConfig = StallConfig()
|
||||
pending: bool = False
|
||||
|
||||
"""Last published nostr event for this Stall"""
|
||||
event_id: str | None = None
|
||||
event_created_at: int | None = None
|
||||
event_id: Optional[str] = None
|
||||
event_created_at: Optional[int] = None
|
||||
|
||||
def validate_stall(self):
|
||||
for z in self.shipping_zones:
|
||||
|
|
@ -212,19 +213,19 @@ class ProductShippingCost(BaseModel):
|
|||
|
||||
|
||||
class ProductConfig(BaseModel):
|
||||
description: str | None = None
|
||||
currency: str | None = None
|
||||
use_autoreply: bool | None = False
|
||||
autoreply_message: str | None = None
|
||||
shipping: list[ProductShippingCost] = []
|
||||
description: Optional[str] = None
|
||||
currency: Optional[str] = None
|
||||
use_autoreply: Optional[bool] = False
|
||||
autoreply_message: Optional[str] = None
|
||||
shipping: List[ProductShippingCost] = []
|
||||
|
||||
|
||||
class Product(BaseModel, Nostrable):
|
||||
id: str | None = None
|
||||
id: Optional[str] = None
|
||||
stall_id: str
|
||||
name: str
|
||||
categories: list[str] = []
|
||||
images: list[str] = []
|
||||
categories: List[str] = []
|
||||
images: List[str] = []
|
||||
price: float
|
||||
quantity: int
|
||||
active: bool = True
|
||||
|
|
@ -232,8 +233,8 @@ class Product(BaseModel, Nostrable):
|
|||
config: ProductConfig = ProductConfig()
|
||||
|
||||
"""Last published nostr event for this Product"""
|
||||
event_id: str | None = None
|
||||
event_created_at: int | None = None
|
||||
event_id: Optional[str] = None
|
||||
event_created_at: Optional[int] = None
|
||||
|
||||
def to_nostr_event(self, pubkey: str) -> NostrEvent:
|
||||
content = {
|
||||
|
|
@ -290,7 +291,7 @@ class ProductOverview(BaseModel):
|
|||
id: str
|
||||
name: str
|
||||
price: float
|
||||
product_shipping_cost: float | None = None
|
||||
product_shipping_cost: Optional[float] = None
|
||||
|
||||
@classmethod
|
||||
def from_product(cls, p: Product) -> "ProductOverview":
|
||||
|
|
@ -307,21 +308,21 @@ class OrderItem(BaseModel):
|
|||
|
||||
|
||||
class OrderContact(BaseModel):
|
||||
nostr: str | None = None
|
||||
phone: str | None = None
|
||||
email: str | None = None
|
||||
nostr: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
|
||||
|
||||
class OrderExtra(BaseModel):
|
||||
products: list[ProductOverview]
|
||||
products: List[ProductOverview]
|
||||
currency: str
|
||||
btc_price: str
|
||||
shipping_cost: float = 0
|
||||
shipping_cost_sat: float = 0
|
||||
fail_message: str | None = None
|
||||
fail_message: Optional[str] = None
|
||||
|
||||
@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"
|
||||
exchange_rate = (
|
||||
await btc_price(currency) if currency and currency != "sat" else 1
|
||||
|
|
@ -337,19 +338,19 @@ class OrderExtra(BaseModel):
|
|||
|
||||
class PartialOrder(BaseModel):
|
||||
id: str
|
||||
event_id: str | None = None
|
||||
event_created_at: int | None = None
|
||||
event_id: Optional[str] = None
|
||||
event_created_at: Optional[int] = None
|
||||
public_key: str
|
||||
merchant_public_key: str
|
||||
shipping_id: str
|
||||
items: list[OrderItem]
|
||||
contact: OrderContact | None = None
|
||||
address: str | None = None
|
||||
items: List[OrderItem]
|
||||
contact: Optional[OrderContact] = None
|
||||
address: Optional[str] = None
|
||||
|
||||
def validate_order(self):
|
||||
assert len(self.items) != 0, f"Order has no items. Order: '{self.id}'"
|
||||
|
||||
def validate_order_items(self, product_list: list[Product]):
|
||||
def validate_order_items(self, product_list: List[Product]):
|
||||
assert len(self.items) != 0, f"Order has no items. Order: '{self.id}'"
|
||||
assert (
|
||||
len(product_list) != 0
|
||||
|
|
@ -370,8 +371,8 @@ class PartialOrder(BaseModel):
|
|||
)
|
||||
|
||||
async def costs_in_sats(
|
||||
self, products: list[Product], shipping_id: str, stall_shipping_cost: float
|
||||
) -> tuple[float, float]:
|
||||
self, products: List[Product], shipping_id: str, stall_shipping_cost: float
|
||||
) -> Tuple[float, float]:
|
||||
product_prices = {}
|
||||
for p in products:
|
||||
product_shipping_cost = next(
|
||||
|
|
@ -400,7 +401,7 @@ class PartialOrder(BaseModel):
|
|||
return product_cost, stall_shipping_cost
|
||||
|
||||
def receipt(
|
||||
self, products: list[Product], shipping_id: str, stall_shipping_cost: float
|
||||
self, products: List[Product], shipping_id: str, stall_shipping_cost: float
|
||||
) -> str:
|
||||
if len(products) == 0:
|
||||
return "[No Products]"
|
||||
|
|
@ -449,7 +450,7 @@ class Order(PartialOrder):
|
|||
total: float
|
||||
paid: bool = False
|
||||
shipped: bool = False
|
||||
time: int | None = None
|
||||
time: Optional[int] = None
|
||||
extra: OrderExtra
|
||||
|
||||
@classmethod
|
||||
|
|
@ -463,14 +464,14 @@ class Order(PartialOrder):
|
|||
|
||||
class OrderStatusUpdate(BaseModel):
|
||||
id: str
|
||||
message: str | None = None
|
||||
paid: bool | None = False
|
||||
shipped: bool | None = None
|
||||
message: Optional[str] = None
|
||||
paid: Optional[bool] = False
|
||||
shipped: Optional[bool] = None
|
||||
|
||||
|
||||
class OrderReissue(BaseModel):
|
||||
id: str
|
||||
shipping_id: str | None = None
|
||||
shipping_id: Optional[str] = None
|
||||
|
||||
|
||||
class PaymentOption(BaseModel):
|
||||
|
|
@ -480,8 +481,8 @@ class PaymentOption(BaseModel):
|
|||
|
||||
class PaymentRequest(BaseModel):
|
||||
id: str
|
||||
message: str | None = None
|
||||
payment_options: list[PaymentOption]
|
||||
message: Optional[str] = None
|
||||
payment_options: List[PaymentOption]
|
||||
|
||||
|
||||
######################################## MESSAGE #######################################
|
||||
|
|
@ -497,16 +498,16 @@ class DirectMessageType(Enum):
|
|||
|
||||
|
||||
class PartialDirectMessage(BaseModel):
|
||||
event_id: str | None = None
|
||||
event_created_at: int | None = None
|
||||
event_id: Optional[str] = None
|
||||
event_created_at: Optional[int] = None
|
||||
message: str
|
||||
public_key: str
|
||||
type: int = DirectMessageType.PLAIN_TEXT.value
|
||||
incoming: bool = False
|
||||
time: int | None = None
|
||||
time: Optional[int] = None
|
||||
|
||||
@classmethod
|
||||
def parse_message(cls, msg) -> tuple[DirectMessageType, Any | None]:
|
||||
def parse_message(cls, msg) -> Tuple[DirectMessageType, Optional[Any]]:
|
||||
try:
|
||||
msg_json = json.loads(msg)
|
||||
if "type" in msg_json:
|
||||
|
|
@ -529,15 +530,15 @@ class DirectMessage(PartialDirectMessage):
|
|||
|
||||
|
||||
class CustomerProfile(BaseModel):
|
||||
name: str | None = None
|
||||
about: str | None = None
|
||||
name: Optional[str] = None
|
||||
about: Optional[str] = None
|
||||
|
||||
|
||||
class Customer(BaseModel):
|
||||
merchant_id: str
|
||||
public_key: str
|
||||
event_created_at: int | None = None
|
||||
profile: CustomerProfile | None = None
|
||||
event_created_at: Optional[int] = None
|
||||
profile: Optional[CustomerProfile] = None
|
||||
unread_messages: int = 0
|
||||
|
||||
@classmethod
|
||||
|
|
|
|||
180
nostr/nip44.py
Normal file
180
nostr/nip44.py
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
"""
|
||||
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
Normal file
231
nostr/nip59.py
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
"""
|
||||
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,6 +1,8 @@
|
|||
import asyncio
|
||||
import json
|
||||
import time
|
||||
from asyncio import Queue
|
||||
from collections import OrderedDict
|
||||
from threading import Thread
|
||||
from typing import Callable, List, Optional
|
||||
|
||||
|
|
@ -12,6 +14,8 @@ from lnbits.helpers import encrypt_internal_message, urlsafe_short_hash
|
|||
|
||||
from .event import NostrEvent
|
||||
|
||||
MAX_SEEN_EVENTS = 1000
|
||||
|
||||
|
||||
class NostrClient:
|
||||
def __init__(self):
|
||||
|
|
@ -20,6 +24,8 @@ class NostrClient:
|
|||
self.ws: Optional[WebSocketApp] = None
|
||||
self.subscription_id = "nostrmarket-" + urlsafe_short_hash()[:32]
|
||||
self.running = False
|
||||
self._seen_events: OrderedDict[str, None] = OrderedDict()
|
||||
self.last_event_at: float = 0
|
||||
|
||||
@property
|
||||
def is_websocket_connected(self):
|
||||
|
|
@ -31,9 +37,11 @@ class NostrClient:
|
|||
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}"
|
||||
|
||||
on_open, on_message, on_error, on_close = self._ws_handlers()
|
||||
ws = WebSocketApp(
|
||||
f"ws://localhost:{settings.port}/nostrclient/api/v1/{relay_endpoint}",
|
||||
ws_url,
|
||||
on_message=on_message,
|
||||
on_open=on_open,
|
||||
on_close=on_close,
|
||||
|
|
@ -62,10 +70,21 @@ class NostrClient:
|
|||
logger.warning(ex)
|
||||
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):
|
||||
value = await self.recieve_event_queue.get()
|
||||
if isinstance(value, ValueError):
|
||||
logger.error(f"[NOSTRMARKET] ❌ Queue returned error: {value}")
|
||||
raise value
|
||||
self.last_event_at = time.time()
|
||||
return value
|
||||
|
||||
async def publish_nostr_event(self, e: NostrEvent):
|
||||
|
|
@ -91,10 +110,6 @@ class NostrClient:
|
|||
self.subscription_id = "nostrmarket-" + urlsafe_short_hash()[:32]
|
||||
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):
|
||||
dm_filters = self._filters_for_direct_messages([pk], 0)
|
||||
stall_filters = self._filters_for_stall_events([pk], 0)
|
||||
|
|
@ -135,13 +150,16 @@ class NostrClient:
|
|||
logger.debug(ex)
|
||||
|
||||
def _filters_for_direct_messages(self, public_keys: List[str], since: int) -> List:
|
||||
in_messages_filter = {"kinds": [4], "#p": public_keys}
|
||||
out_messages_filter = {"kinds": [4], "authors": public_keys}
|
||||
if since and since != 0:
|
||||
in_messages_filter["since"] = since
|
||||
out_messages_filter["since"] = since
|
||||
|
||||
return [in_messages_filter, out_messages_filter]
|
||||
# NIP-17/NIP-59: subscribe to kind 1059 gift wraps addressed to our merchants.
|
||||
# With gift wrapping, outgoing messages are self-wrapped (same p-tag filter).
|
||||
#
|
||||
# Do NOT apply `since` here. Per NIP-59, gift wraps use randomized past
|
||||
# timestamps (up to 2 days back) to defeat metadata correlation, so a
|
||||
# `since` derived from the latest DM in our DB will reject fresh wraps
|
||||
# whose randomized created_at is older than that window. Server-side
|
||||
# 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:
|
||||
stall_filter = {"kinds": [30017], "authors": public_keys}
|
||||
|
|
@ -175,16 +193,21 @@ class NostrClient:
|
|||
|
||||
def _ws_handlers(self):
|
||||
def on_open(_):
|
||||
logger.info("Connected to 'nostrclient' websocket")
|
||||
logger.debug("[NOSTRMARKET DEBUG] ✅ Connected to 'nostrclient' websocket successfully")
|
||||
|
||||
def on_message(_, message):
|
||||
self.recieve_event_queue.put_nowait(message)
|
||||
logger.debug(f"[NOSTRMARKET DEBUG] 📨 Received websocket message: {message[:200]}...")
|
||||
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):
|
||||
logger.warning(error)
|
||||
logger.warning(f"[NOSTRMARKET] ❌ Websocket error: {error}")
|
||||
|
||||
def on_close(x, status_code, message):
|
||||
logger.warning(f"Websocket closed: {x}: '{status_code}' '{message}'")
|
||||
logger.warning(f"[NOSTRMARKET] 🔌 Websocket closed: {x}: '{status_code}' '{message}'")
|
||||
# force re-subscribe
|
||||
self.recieve_event_queue.put_nowait(ValueError("Websocket close."))
|
||||
|
||||
|
|
|
|||
363
services.py
363
services.py
|
|
@ -1,9 +1,12 @@
|
|||
import asyncio
|
||||
import json
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from bolt11 import decode
|
||||
from lnbits.core.crud import get_wallet
|
||||
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 . import nostr_client
|
||||
|
|
@ -11,9 +14,11 @@ from .crud import (
|
|||
CustomerProfile,
|
||||
create_customer,
|
||||
create_direct_message,
|
||||
create_merchant,
|
||||
create_order,
|
||||
create_product,
|
||||
create_stall,
|
||||
create_zone,
|
||||
get_customer,
|
||||
get_last_direct_messages_created_at,
|
||||
get_last_product_update_time,
|
||||
|
|
@ -41,6 +46,7 @@ from .models import (
|
|||
DirectMessage,
|
||||
DirectMessageType,
|
||||
Merchant,
|
||||
MerchantConfig,
|
||||
Nostrable,
|
||||
Order,
|
||||
OrderContact,
|
||||
|
|
@ -48,22 +54,26 @@ from .models import (
|
|||
OrderItem,
|
||||
OrderStatusUpdate,
|
||||
PartialDirectMessage,
|
||||
PartialMerchant,
|
||||
PartialOrder,
|
||||
PaymentOption,
|
||||
PaymentRequest,
|
||||
Product,
|
||||
Stall,
|
||||
Zone,
|
||||
)
|
||||
from .nostr.event import NostrEvent
|
||||
from .nostr.nip59 import unwrap_message, wrap_message
|
||||
|
||||
|
||||
async def create_new_order(
|
||||
merchant_public_key: str, data: PartialOrder
|
||||
) -> PaymentRequest | None:
|
||||
) -> Optional[PaymentRequest]:
|
||||
merchant = await get_merchant_by_pubkey(merchant_public_key)
|
||||
assert merchant, "Cannot find merchant for order!"
|
||||
|
||||
if await get_order(merchant.id, data.id):
|
||||
existing_order = await get_order(merchant.id, data.id)
|
||||
if existing_order:
|
||||
return None
|
||||
if data.event_id and await get_order_by_event_id(merchant.id, data.event_id):
|
||||
return None
|
||||
|
|
@ -73,20 +83,24 @@ async def create_new_order(
|
|||
)
|
||||
await create_order(merchant.id, order)
|
||||
|
||||
return PaymentRequest(
|
||||
payment_request = PaymentRequest(
|
||||
id=data.id,
|
||||
payment_options=[PaymentOption(type="ln", link=invoice)],
|
||||
message=receipt,
|
||||
)
|
||||
return payment_request
|
||||
|
||||
|
||||
async def build_order_with_payment(
|
||||
merchant_id: str, merchant_public_key: str, data: PartialOrder
|
||||
):
|
||||
|
||||
products = await get_products_by_ids(
|
||||
merchant_id, [p.product_id for p in data.items]
|
||||
)
|
||||
|
||||
data.validate_order_items(products)
|
||||
|
||||
shipping_zone = await get_zone(merchant_id, data.shipping_id)
|
||||
assert shipping_zone, f"Shipping zone not found for order '{data.id}'"
|
||||
|
||||
|
|
@ -94,6 +108,7 @@ async def build_order_with_payment(
|
|||
product_cost_sat, shipping_cost_sat = await data.costs_in_sats(
|
||||
products, shipping_zone.id, shipping_zone.cost
|
||||
)
|
||||
|
||||
receipt = data.receipt(products, shipping_zone.id, shipping_zone.cost)
|
||||
|
||||
wallet_id = await get_wallet_for_product(data.items[0].product_id)
|
||||
|
|
@ -104,11 +119,13 @@ async def build_order_with_payment(
|
|||
merchant_id, product_ids, data.items
|
||||
)
|
||||
if not success:
|
||||
logger.error(f"[NOSTRMARKET] ❌ Product quantity check failed: {message}")
|
||||
raise ValueError(message)
|
||||
|
||||
total_amount_sat = round(product_cost_sat + shipping_cost_sat)
|
||||
payment = await create_invoice(
|
||||
wallet_id=wallet_id,
|
||||
amount=round(product_cost_sat + shipping_cost_sat),
|
||||
amount=total_amount_sat,
|
||||
memo=f"Order '{data.id}' for pubkey '{data.public_key}'",
|
||||
extra={
|
||||
"tag": "nostrmarket",
|
||||
|
|
@ -136,7 +153,7 @@ async def update_merchant_to_nostr(
|
|||
merchant: Merchant, delete_merchant=False
|
||||
) -> Merchant:
|
||||
stalls = await get_stalls(merchant.id)
|
||||
event: NostrEvent | None = None
|
||||
event: Optional[NostrEvent] = None
|
||||
for stall in stalls:
|
||||
assert stall.id
|
||||
products = await get_products(merchant.id, stall.id)
|
||||
|
|
@ -149,28 +166,180 @@ async def update_merchant_to_nostr(
|
|||
stall.event_id = event.id
|
||||
stall.event_created_at = event.created_at
|
||||
await update_stall(merchant.id, stall)
|
||||
if delete_merchant:
|
||||
# merchant profile updates not supported yet
|
||||
event = await sign_and_send_to_nostr(merchant, merchant, delete_merchant)
|
||||
# Always publish merchant profile (kind 0)
|
||||
event = await sign_and_send_to_nostr(merchant, merchant, delete_merchant)
|
||||
assert event
|
||||
merchant.config.event_id = event.id
|
||||
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(
|
||||
merchant: Merchant, n: Nostrable, delete=False
|
||||
) -> 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 = (
|
||||
n.to_nostr_delete_event(merchant.public_key)
|
||||
if delete
|
||||
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)
|
||||
|
||||
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):
|
||||
try:
|
||||
order = await update_order_paid_status(order_id, True)
|
||||
|
|
@ -221,7 +390,7 @@ async def notify_client_of_order_status(
|
|||
|
||||
async def update_products_for_order(
|
||||
merchant: Merchant, order: Order
|
||||
) -> tuple[bool, str]:
|
||||
) -> Tuple[bool, str]:
|
||||
product_ids = [i.product_id for i in order.items]
|
||||
success, products, message = await compute_products_new_quantity(
|
||||
merchant.id, product_ids, order.items
|
||||
|
|
@ -262,19 +431,37 @@ async def send_dm(
|
|||
other_pubkey: str,
|
||||
type_: int,
|
||||
dm_content: str,
|
||||
):
|
||||
dm_event = merchant.build_dm_event(dm_content, other_pubkey)
|
||||
) -> DirectMessage:
|
||||
# Post-#5: nsec stays in the bunker; both the to-recipient wrap and
|
||||
# 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(
|
||||
event_id=dm_event.id,
|
||||
event_created_at=dm_event.created_at,
|
||||
event_id=gift_wrap.id,
|
||||
event_created_at=gift_wrap.created_at,
|
||||
message=dm_content,
|
||||
public_key=other_pubkey,
|
||||
type=type_,
|
||||
)
|
||||
dm_reply = await create_direct_message(merchant.id, dm)
|
||||
|
||||
await nostr_client.publish_nostr_event(dm_event)
|
||||
await nostr_client.publish_nostr_event(gift_wrap)
|
||||
|
||||
# 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(
|
||||
merchant.id,
|
||||
|
|
@ -287,11 +474,13 @@ async def send_dm(
|
|||
),
|
||||
)
|
||||
|
||||
return dm_reply
|
||||
|
||||
|
||||
async def compute_products_new_quantity(
|
||||
merchant_id: str, product_ids: list[str], items: list[OrderItem]
|
||||
) -> tuple[bool, list[Product], str]:
|
||||
products: list[Product] = await get_products_by_ids(merchant_id, product_ids)
|
||||
merchant_id: str, product_ids: List[str], items: List[OrderItem]
|
||||
) -> Tuple[bool, List[Product], str]:
|
||||
products: List[Product] = await get_products_by_ids(merchant_id, product_ids)
|
||||
|
||||
for p in products:
|
||||
required_quantity = next(
|
||||
|
|
@ -314,23 +503,38 @@ async def compute_products_new_quantity(
|
|||
|
||||
async def process_nostr_message(msg: str):
|
||||
try:
|
||||
type_, *rest = json.loads(msg)
|
||||
parsed_msg = json.loads(msg)
|
||||
type_, *rest = parsed_msg
|
||||
|
||||
|
||||
if type_.upper() == "EVENT":
|
||||
if len(rest) < 2:
|
||||
logger.warning(f"[NOSTRMARKET] ⚠️ EVENT message missing data: {rest}")
|
||||
return
|
||||
_, event = rest
|
||||
event = NostrEvent(**event)
|
||||
|
||||
# Deduplicate events (overlap resubscriptions may deliver duplicates)
|
||||
if nostr_client.is_duplicate_event(event.id):
|
||||
return
|
||||
|
||||
if event.kind == 0:
|
||||
await _handle_customer_profile_update(event)
|
||||
elif event.kind == 4:
|
||||
await _handle_nip04_message(event)
|
||||
elif event.kind == 1059:
|
||||
await _handle_gift_wrap(event)
|
||||
elif event.kind == 30017:
|
||||
await _handle_stall(event)
|
||||
elif event.kind == 30018:
|
||||
await _handle_product(event)
|
||||
else:
|
||||
logger.info(f"[NOSTRMARKET] ❓ Unhandled event kind: {event.kind} - event: {event.id}")
|
||||
return
|
||||
else:
|
||||
logger.info(f"[NOSTRMARKET] 🔄 Non-EVENT message type: {type_}")
|
||||
|
||||
except Exception as ex:
|
||||
logger.debug(ex)
|
||||
logger.error(f"[NOSTRMARKET] ❌ Error processing nostr message: {ex}")
|
||||
logger.error(f"[NOSTRMARKET] 📄 Raw message that failed: {msg}")
|
||||
|
||||
|
||||
async def create_or_update_order_from_dm(
|
||||
|
|
@ -411,29 +615,42 @@ async def extract_customer_order_from_dm(
|
|||
return order
|
||||
|
||||
|
||||
async def _handle_nip04_message(event: NostrEvent):
|
||||
merchant_public_key = event.pubkey
|
||||
merchant = await get_merchant_by_pubkey(merchant_public_key)
|
||||
async def _handle_gift_wrap(event: NostrEvent):
|
||||
"""Handle an incoming kind 1059 gift wrap event (NIP-59/NIP-17)."""
|
||||
|
||||
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:
|
||||
p_tags = event.tag_values("p")
|
||||
if len(p_tags) and p_tags[0]:
|
||||
merchant_public_key = p_tags[0]
|
||||
merchant = await get_merchant_by_pubkey(merchant_public_key)
|
||||
|
||||
assert merchant, f"Merchant not found for public key '{merchant_public_key}'"
|
||||
|
||||
if event.pubkey == merchant_public_key:
|
||||
assert len(event.tag_values("p")) != 0, "Outgong message has no 'p' tag"
|
||||
clear_text_msg = merchant.decrypt_message(
|
||||
event.content, event.tag_values("p")[0]
|
||||
logger.warning(
|
||||
f"[NOSTRMARKET] ⚠️ No merchant found for gift wrap recipient: {recipient_pubkey}"
|
||||
)
|
||||
await _handle_outgoing_dms(event, merchant, clear_text_msg)
|
||||
elif event.has_tag_value("p", merchant_public_key):
|
||||
clear_text_msg = merchant.decrypt_message(event.content, event.pubkey)
|
||||
await _handle_incoming_dms(event, merchant, clear_text_msg)
|
||||
else:
|
||||
logger.warning(f"Bad NIP04 event: '{event.id}'")
|
||||
return
|
||||
|
||||
try:
|
||||
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 sender_pubkey == merchant.public_key:
|
||||
# This is a self-addressed wrap (outgoing message archive)
|
||||
# Extract the actual recipient from the rumor's p-tags
|
||||
rumor_p_tags = rumor.tag_values("p")
|
||||
if rumor_p_tags:
|
||||
await _handle_outgoing_dms(rumor, merchant, rumor.content)
|
||||
return
|
||||
|
||||
# Incoming message from a customer
|
||||
await _handle_incoming_dms(rumor, merchant, rumor.content)
|
||||
|
||||
|
||||
async def _handle_incoming_dms(
|
||||
|
|
@ -483,17 +700,18 @@ async def _handle_outgoing_dms(
|
|||
|
||||
async def _handle_incoming_structured_dm(
|
||||
merchant: Merchant, dm: DirectMessage, json_data: dict
|
||||
) -> tuple[DirectMessageType, str | None]:
|
||||
) -> Tuple[DirectMessageType, Optional[str]]:
|
||||
try:
|
||||
if dm.type == DirectMessageType.CUSTOMER_ORDER.value and merchant.config.active:
|
||||
json_resp = await _handle_new_order(
|
||||
merchant.id, merchant.public_key, dm, json_data
|
||||
)
|
||||
|
||||
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:
|
||||
logger.warning(ex)
|
||||
logger.error(f"[NOSTRMARKET] Error in _handle_incoming_structured_dm: {ex}")
|
||||
|
||||
return DirectMessageType.PLAIN_TEXT, None
|
||||
|
||||
|
|
@ -532,16 +750,21 @@ async def _persist_dm(
|
|||
async def reply_to_structured_dm(
|
||||
merchant: Merchant, customer_pubkey: str, dm_type: int, dm_reply: str
|
||||
):
|
||||
dm_event = merchant.build_dm_event(dm_reply, customer_pubkey)
|
||||
signer = await _resolve_merchant_signer(merchant)
|
||||
gift_wrap = await wrap_message(
|
||||
dm_reply,
|
||||
signer,
|
||||
customer_pubkey,
|
||||
)
|
||||
dm = PartialDirectMessage(
|
||||
event_id=dm_event.id,
|
||||
event_created_at=dm_event.created_at,
|
||||
event_id=gift_wrap.id,
|
||||
event_created_at=gift_wrap.created_at,
|
||||
message=dm_reply,
|
||||
public_key=customer_pubkey,
|
||||
type=dm_type,
|
||||
)
|
||||
await create_direct_message(merchant.id, dm)
|
||||
await nostr_client.publish_nostr_event(dm_event)
|
||||
await nostr_client.publish_nostr_event(gift_wrap)
|
||||
|
||||
await websocket_updater(
|
||||
merchant.id,
|
||||
|
|
@ -574,9 +797,31 @@ async def _handle_new_order(
|
|||
wallet = await get_wallet(wallet_id)
|
||||
assert wallet, f"Cannot find wallet for product id: {first_product_id}"
|
||||
|
||||
|
||||
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:
|
||||
logger.debug(e)
|
||||
logger.error(f"[NOSTRMARKET] Error creating order: {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(
|
||||
merchant_id,
|
||||
merchant_public_key,
|
||||
|
|
@ -584,12 +829,17 @@ async def _handle_new_order(
|
|||
json_data,
|
||||
"Order received, but cannot be processed. Please contact merchant.",
|
||||
)
|
||||
assert payment_req
|
||||
|
||||
if not payment_req:
|
||||
logger.error(f"[NOSTRMARKET] No payment request returned for order: {partial_order.id}")
|
||||
return ""
|
||||
|
||||
response = {
|
||||
"type": DirectMessageType.PAYMENT_REQUEST.value,
|
||||
**payment_req.dict(),
|
||||
}
|
||||
return json.dumps(response, separators=(",", ":"), ensure_ascii=False)
|
||||
response_json = json.dumps(response, separators=(",", ":"), ensure_ascii=False)
|
||||
return response_json
|
||||
|
||||
|
||||
async def create_new_failed_order(
|
||||
|
|
@ -622,8 +872,11 @@ async def subscribe_to_all_merchants():
|
|||
last_stall_time = await get_last_stall_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
|
||||
lenient_dm_time = max(0, last_dm_time - 300) if last_dm_time > 0 else 0
|
||||
|
||||
await nostr_client.subscribe_merchants(
|
||||
public_keys, last_dm_time, last_stall_time, last_prod_time, 0
|
||||
public_keys, lenient_dm_time, last_stall_time, last_prod_time, 0
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
91
static/components/edit-profile-dialog.js
Normal file
91
static/components/edit-profile-dialog.js
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
window.app.component('key-pair', {
|
||||
name: 'key-pair',
|
||||
template: '#key-pair',
|
||||
delimiters: ['${', '}'],
|
||||
props: ['public-key', 'private-key'],
|
||||
data: function () {
|
||||
return {
|
||||
showPrivateKey: false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
copyText: function (text, message, position) {
|
||||
var notify = this.$q.notify
|
||||
Quasar.copyToClipboard(text).then(function () {
|
||||
notify({
|
||||
message: message || 'Copied to clipboard!',
|
||||
position: position || 'bottom'
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
102
static/components/merchant-tab.js
Normal file
102
static/components/merchant-tab.js
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
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'
|
||||
}
|
||||
}
|
||||
})
|
||||
56
static/components/nostr-keys-dialog.js
Normal file
56
static/components/nostr-keys-dialog.js
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
261
static/components/product-list.js
Normal file
261
static/components/product-list.js
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
window.app.component('product-list', {
|
||||
name: 'product-list',
|
||||
template: '#product-list',
|
||||
delimiters: ['${', '}'],
|
||||
props: ['adminkey', 'inkey', 'stall-filter'],
|
||||
data: function () {
|
||||
return {
|
||||
filter: '',
|
||||
stalls: [],
|
||||
products: [],
|
||||
pendingProducts: [],
|
||||
selectedStall: null,
|
||||
productDialog: {
|
||||
showDialog: false,
|
||||
showRestore: false,
|
||||
data: null
|
||||
},
|
||||
productsTable: {
|
||||
columns: [
|
||||
{name: 'name', align: 'left', label: 'Name', field: 'name'},
|
||||
{name: 'stall', align: 'left', label: 'Stall', field: 'stall_id'},
|
||||
{name: 'price', align: 'left', label: 'Price', field: 'price'},
|
||||
{
|
||||
name: 'quantity',
|
||||
align: 'left',
|
||||
label: 'Quantity',
|
||||
field: 'quantity'
|
||||
},
|
||||
{name: 'actions', align: 'right', label: 'Actions', field: ''}
|
||||
],
|
||||
pagination: {
|
||||
rowsPerPage: 10
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
stallOptions: function () {
|
||||
return this.stalls.map(s => ({
|
||||
label: s.name,
|
||||
value: s.id
|
||||
}))
|
||||
},
|
||||
filteredProducts: function () {
|
||||
if (!this.selectedStall) {
|
||||
return this.products
|
||||
}
|
||||
return this.products.filter(p => p.stall_id === this.selectedStall)
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
stallFilter: {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
if (newVal) {
|
||||
this.selectedStall = newVal
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getStalls: async function () {
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
'/nostrmarket/api/v1/stall?pending=false',
|
||||
this.inkey
|
||||
)
|
||||
this.stalls = data
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
},
|
||||
getProducts: async function () {
|
||||
try {
|
||||
// Fetch products from all stalls
|
||||
const allProducts = []
|
||||
for (const stall of this.stalls) {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
`/nostrmarket/api/v1/stall/product/${stall.id}?pending=false`,
|
||||
this.inkey
|
||||
)
|
||||
allProducts.push(...data)
|
||||
}
|
||||
this.products = allProducts
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
},
|
||||
getPendingProducts: async function () {
|
||||
try {
|
||||
// Fetch pending products from all stalls
|
||||
const allPending = []
|
||||
for (const stall of this.stalls) {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
`/nostrmarket/api/v1/stall/product/${stall.id}?pending=true`,
|
||||
this.inkey
|
||||
)
|
||||
allPending.push(...data)
|
||||
}
|
||||
this.pendingProducts = allPending
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
},
|
||||
getStallName: function (stallId) {
|
||||
const stall = this.stalls.find(s => s.id === stallId)
|
||||
return stall ? stall.name : 'Unknown'
|
||||
},
|
||||
getStallCurrency: function (stallId) {
|
||||
const stall = this.stalls.find(s => s.id === stallId)
|
||||
return stall ? stall.currency : 'sat'
|
||||
},
|
||||
getStall: function (stallId) {
|
||||
return this.stalls.find(s => s.id === stallId)
|
||||
},
|
||||
newEmptyProductData: function () {
|
||||
return {
|
||||
id: null,
|
||||
stall_id: this.stalls.length ? this.stalls[0].id : null,
|
||||
name: '',
|
||||
categories: [],
|
||||
images: [],
|
||||
image: null,
|
||||
price: 0,
|
||||
quantity: 0,
|
||||
config: {
|
||||
description: '',
|
||||
use_autoreply: false,
|
||||
autoreply_message: ''
|
||||
}
|
||||
}
|
||||
},
|
||||
showNewProductDialog: function () {
|
||||
this.productDialog.data = this.newEmptyProductData()
|
||||
this.productDialog.showDialog = true
|
||||
},
|
||||
editProduct: function (product) {
|
||||
this.productDialog.data = {...product, image: null}
|
||||
if (!this.productDialog.data.config) {
|
||||
this.productDialog.data.config = {description: ''}
|
||||
}
|
||||
this.productDialog.showDialog = true
|
||||
},
|
||||
sendProductFormData: async function () {
|
||||
const data = {
|
||||
stall_id: this.productDialog.data.stall_id,
|
||||
id: this.productDialog.data.id,
|
||||
name: this.productDialog.data.name,
|
||||
images: this.productDialog.data.images || [],
|
||||
price: this.productDialog.data.price,
|
||||
quantity: this.productDialog.data.quantity,
|
||||
categories: this.productDialog.data.categories || [],
|
||||
config: this.productDialog.data.config
|
||||
}
|
||||
this.productDialog.showDialog = false
|
||||
|
||||
if (this.productDialog.data.id) {
|
||||
data.pending = false
|
||||
await this.updateProduct(data)
|
||||
} else {
|
||||
await this.createProduct(data)
|
||||
}
|
||||
},
|
||||
createProduct: async function (payload) {
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'POST',
|
||||
'/nostrmarket/api/v1/product',
|
||||
this.adminkey,
|
||||
payload
|
||||
)
|
||||
this.products.unshift(data)
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message: 'Product Created'
|
||||
})
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
},
|
||||
updateProduct: async function (product) {
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'PATCH',
|
||||
'/nostrmarket/api/v1/product/' + product.id,
|
||||
this.adminkey,
|
||||
product
|
||||
)
|
||||
const index = this.products.findIndex(p => p.id === product.id)
|
||||
if (index !== -1) {
|
||||
this.products.splice(index, 1, data)
|
||||
} else {
|
||||
this.products.unshift(data)
|
||||
}
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message: 'Product Updated'
|
||||
})
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
},
|
||||
deleteProduct: function (product) {
|
||||
LNbits.utils
|
||||
.confirmDialog(`Are you sure you want to delete "${product.name}"?`)
|
||||
.onOk(async () => {
|
||||
try {
|
||||
await LNbits.api.request(
|
||||
'DELETE',
|
||||
'/nostrmarket/api/v1/product/' + product.id,
|
||||
this.adminkey
|
||||
)
|
||||
this.products = this.products.filter(p => p.id !== product.id)
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message: 'Product Deleted'
|
||||
})
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
})
|
||||
},
|
||||
toggleProductActive: async function (product) {
|
||||
await this.updateProduct({...product, active: !product.active})
|
||||
},
|
||||
addProductImage: function () {
|
||||
if (!this.productDialog.data.image) return
|
||||
if (!this.productDialog.data.images) {
|
||||
this.productDialog.data.images = []
|
||||
}
|
||||
this.productDialog.data.images.push(this.productDialog.data.image)
|
||||
this.productDialog.data.image = null
|
||||
},
|
||||
removeProductImage: function (imageUrl) {
|
||||
const index = this.productDialog.data.images.indexOf(imageUrl)
|
||||
if (index !== -1) {
|
||||
this.productDialog.data.images.splice(index, 1)
|
||||
}
|
||||
},
|
||||
openSelectPendingProductDialog: async function () {
|
||||
await this.getPendingProducts()
|
||||
this.productDialog.showRestore = true
|
||||
},
|
||||
openRestoreProductDialog: function (pendingProduct) {
|
||||
pendingProduct.pending = true
|
||||
this.productDialog.data = {...pendingProduct, image: null}
|
||||
this.productDialog.showDialog = true
|
||||
},
|
||||
shortLabel: function (value = '') {
|
||||
if (value.length <= 44) return value
|
||||
return value.substring(0, 40) + '...'
|
||||
}
|
||||
},
|
||||
created: async function () {
|
||||
await this.getStalls()
|
||||
await this.getProducts()
|
||||
}
|
||||
})
|
||||
209
static/components/shipping-zones-list.js
Normal file
209
static/components/shipping-zones-list.js
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
window.app.component('shipping-zones-list', {
|
||||
name: 'shipping-zones-list',
|
||||
props: ['adminkey', 'inkey'],
|
||||
template: '#shipping-zones-list',
|
||||
delimiters: ['${', '}'],
|
||||
data: function () {
|
||||
return {
|
||||
zones: [],
|
||||
filter: '',
|
||||
zoneDialog: {
|
||||
showDialog: false,
|
||||
data: {
|
||||
id: null,
|
||||
name: '',
|
||||
countries: [],
|
||||
cost: 0,
|
||||
currency: 'sat'
|
||||
}
|
||||
},
|
||||
currencies: [],
|
||||
shippingZoneOptions: [
|
||||
'Free (digital)',
|
||||
'Worldwide',
|
||||
'Europe',
|
||||
'Australia',
|
||||
'Austria',
|
||||
'Belgium',
|
||||
'Brazil',
|
||||
'Canada',
|
||||
'China',
|
||||
'Denmark',
|
||||
'Finland',
|
||||
'France',
|
||||
'Germany',
|
||||
'Greece',
|
||||
'Hong Kong',
|
||||
'Hungary',
|
||||
'Indonesia',
|
||||
'Ireland',
|
||||
'Israel',
|
||||
'Italy',
|
||||
'Japan',
|
||||
'Kazakhstan',
|
||||
'Korea',
|
||||
'Luxembourg',
|
||||
'Malaysia',
|
||||
'Mexico',
|
||||
'Netherlands',
|
||||
'New Zealand',
|
||||
'Norway',
|
||||
'Poland',
|
||||
'Portugal',
|
||||
'Romania',
|
||||
'Russia',
|
||||
'Saudi Arabia',
|
||||
'Singapore',
|
||||
'Spain',
|
||||
'Sweden',
|
||||
'Switzerland',
|
||||
'Thailand',
|
||||
'Turkey',
|
||||
'Ukraine',
|
||||
'United Kingdom',
|
||||
'United States',
|
||||
'Vietnam'
|
||||
],
|
||||
zonesTable: {
|
||||
columns: [
|
||||
{
|
||||
name: 'name',
|
||||
align: 'left',
|
||||
label: 'Name',
|
||||
field: 'name',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'countries',
|
||||
align: 'left',
|
||||
label: 'Countries',
|
||||
field: 'countries',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'cost',
|
||||
align: 'left',
|
||||
label: 'Cost',
|
||||
field: 'cost',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'currency',
|
||||
align: 'left',
|
||||
label: 'Currency',
|
||||
field: 'currency',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'actions',
|
||||
align: 'right',
|
||||
label: 'Actions',
|
||||
field: ''
|
||||
}
|
||||
],
|
||||
pagination: {
|
||||
rowsPerPage: 10,
|
||||
sortBy: 'name',
|
||||
descending: false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
openZoneDialog: function (data) {
|
||||
data = data || {
|
||||
id: null,
|
||||
name: '',
|
||||
countries: [],
|
||||
cost: 0,
|
||||
currency: 'sat'
|
||||
}
|
||||
this.zoneDialog.data = {...data}
|
||||
this.zoneDialog.showDialog = true
|
||||
},
|
||||
getZones: async function () {
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
'/nostrmarket/api/v1/zone',
|
||||
this.inkey
|
||||
)
|
||||
this.zones = data
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
},
|
||||
sendZoneFormData: async function () {
|
||||
this.zoneDialog.showDialog = false
|
||||
if (this.zoneDialog.data.id) {
|
||||
await this.updateShippingZone(this.zoneDialog.data)
|
||||
} else {
|
||||
await this.createShippingZone(this.zoneDialog.data)
|
||||
}
|
||||
await this.getZones()
|
||||
},
|
||||
createShippingZone: async function (newZone) {
|
||||
try {
|
||||
await LNbits.api.request(
|
||||
'POST',
|
||||
'/nostrmarket/api/v1/zone',
|
||||
this.adminkey,
|
||||
newZone
|
||||
)
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message: 'Zone created!'
|
||||
})
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
},
|
||||
updateShippingZone: async function (updatedZone) {
|
||||
try {
|
||||
await LNbits.api.request(
|
||||
'PATCH',
|
||||
`/nostrmarket/api/v1/zone/${updatedZone.id}`,
|
||||
this.adminkey,
|
||||
updatedZone
|
||||
)
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message: 'Zone updated!'
|
||||
})
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
},
|
||||
confirmDeleteZone: function (zone) {
|
||||
LNbits.utils
|
||||
.confirmDialog(`Are you sure you want to delete zone "${zone.name}"?`)
|
||||
.onOk(async () => {
|
||||
await this.deleteShippingZone(zone.id)
|
||||
})
|
||||
},
|
||||
deleteShippingZone: async function (zoneId) {
|
||||
try {
|
||||
await LNbits.api.request(
|
||||
'DELETE',
|
||||
`/nostrmarket/api/v1/zone/${zoneId}`,
|
||||
this.adminkey
|
||||
)
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message: 'Zone deleted!'
|
||||
})
|
||||
await this.getZones()
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
},
|
||||
getCurrencies() {
|
||||
const currencies = window.g.allowedCurrencies || []
|
||||
this.currencies = ['sat', ...currencies]
|
||||
}
|
||||
},
|
||||
created: async function () {
|
||||
await this.getZones()
|
||||
this.getCurrencies()
|
||||
}
|
||||
})
|
||||
|
|
@ -19,7 +19,6 @@ window.app.component('shipping-zones', {
|
|||
currencies: [],
|
||||
shippingZoneOptions: [
|
||||
'Free (digital)',
|
||||
'Flat rate',
|
||||
'Worldwide',
|
||||
'Europe',
|
||||
'Australia',
|
||||
|
|
@ -27,6 +26,7 @@ window.app.component('shipping-zones', {
|
|||
'Belgium',
|
||||
'Brazil',
|
||||
'Canada',
|
||||
'China',
|
||||
'Denmark',
|
||||
'Finland',
|
||||
'France',
|
||||
|
|
@ -34,8 +34,8 @@ window.app.component('shipping-zones', {
|
|||
'Greece',
|
||||
'Hong Kong',
|
||||
'Hungary',
|
||||
'Ireland',
|
||||
'Indonesia',
|
||||
'Ireland',
|
||||
'Israel',
|
||||
'Italy',
|
||||
'Japan',
|
||||
|
|
@ -59,10 +59,9 @@ window.app.component('shipping-zones', {
|
|||
'Thailand',
|
||||
'Turkey',
|
||||
'Ukraine',
|
||||
'United Kingdom**',
|
||||
'United States***',
|
||||
'Vietnam',
|
||||
'China'
|
||||
'United Kingdom',
|
||||
'United States',
|
||||
'Vietnam'
|
||||
]
|
||||
}
|
||||
},
|
||||
|
|
@ -162,22 +161,13 @@ window.app.component('shipping-zones', {
|
|||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
},
|
||||
async getCurrencies() {
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
'/nostrmarket/api/v1/currencies',
|
||||
this.inkey
|
||||
)
|
||||
|
||||
this.currencies = ['sat', ...data]
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
getCurrencies() {
|
||||
const currencies = window.g.allowedCurrencies || []
|
||||
this.currencies = ['sat', ...currencies]
|
||||
}
|
||||
},
|
||||
created: async function () {
|
||||
await this.getZones()
|
||||
await this.getCurrencies()
|
||||
this.getCurrencies()
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ window.app.component('stall-list', {
|
|||
name: 'stall-list',
|
||||
template: '#stall-list',
|
||||
delimiters: ['${', '}'],
|
||||
props: [`adminkey`, 'inkey', 'wallet-options'],
|
||||
props: ['adminkey', 'inkey', 'wallet-options'],
|
||||
data: function () {
|
||||
return {
|
||||
filter: '',
|
||||
|
|
@ -20,21 +20,21 @@ window.app.component('stall-list', {
|
|||
shippingZones: []
|
||||
}
|
||||
},
|
||||
editDialog: {
|
||||
show: false,
|
||||
data: {
|
||||
id: '',
|
||||
name: '',
|
||||
description: '',
|
||||
wallet: null,
|
||||
currency: 'sat',
|
||||
shippingZones: []
|
||||
}
|
||||
},
|
||||
zoneOptions: [],
|
||||
stallsTable: {
|
||||
columns: [
|
||||
{
|
||||
name: '',
|
||||
align: 'left',
|
||||
label: '',
|
||||
field: ''
|
||||
},
|
||||
{
|
||||
name: 'id',
|
||||
align: 'left',
|
||||
label: 'Name',
|
||||
field: 'id'
|
||||
},
|
||||
{name: 'name', align: 'left', label: 'Name', field: 'name'},
|
||||
{
|
||||
name: 'currency',
|
||||
align: 'left',
|
||||
|
|
@ -45,14 +45,15 @@ window.app.component('stall-list', {
|
|||
name: 'description',
|
||||
align: 'left',
|
||||
label: 'Description',
|
||||
field: 'description'
|
||||
field: row => row.config?.description || ''
|
||||
},
|
||||
{
|
||||
name: 'shippingZones',
|
||||
align: 'left',
|
||||
label: 'Shipping Zones',
|
||||
field: 'shippingZones'
|
||||
}
|
||||
field: row => row.shipping_zones?.map(z => z.name).join(', ') || ''
|
||||
},
|
||||
{name: 'actions', align: 'right', label: 'Actions', field: ''}
|
||||
],
|
||||
pagination: {
|
||||
rowsPerPage: 10
|
||||
|
|
@ -65,9 +66,17 @@ window.app.component('stall-list', {
|
|||
return this.zoneOptions.filter(
|
||||
z => z.currency === this.stallDialog.data.currency
|
||||
)
|
||||
},
|
||||
editFilteredZoneOptions: function () {
|
||||
return this.zoneOptions.filter(
|
||||
z => z.currency === this.editDialog.data.currency
|
||||
)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
emitStallCount: function () {
|
||||
this.$emit('stalls-updated', this.stalls.length)
|
||||
},
|
||||
sendStallFormData: async function () {
|
||||
const stallData = {
|
||||
name: this.stallDialog.data.name,
|
||||
|
|
@ -94,8 +103,8 @@ window.app.component('stall-list', {
|
|||
stall
|
||||
)
|
||||
this.stallDialog.show = false
|
||||
data.expanded = false
|
||||
this.stalls.unshift(data)
|
||||
this.emitStallCount()
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message: 'Stall created!'
|
||||
|
|
@ -114,8 +123,8 @@ window.app.component('stall-list', {
|
|||
stallData
|
||||
)
|
||||
this.stallDialog.show = false
|
||||
data.expanded = false
|
||||
this.stalls.unshift(data)
|
||||
this.emitStallCount()
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message: 'Stall restored!'
|
||||
|
|
@ -124,44 +133,68 @@ window.app.component('stall-list', {
|
|||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
},
|
||||
deleteStall: async function (pendingStall) {
|
||||
LNbits.utils
|
||||
.confirmDialog(
|
||||
`
|
||||
Are you sure you want to delete this pending stall '${pendingStall.name}'?
|
||||
`
|
||||
)
|
||||
.onOk(async () => {
|
||||
try {
|
||||
await LNbits.api.request(
|
||||
'DELETE',
|
||||
'/nostrmarket/api/v1/stall/' + pendingStall.id,
|
||||
this.adminkey
|
||||
)
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message: 'Pending Stall Deleted',
|
||||
timeout: 5000
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn(error)
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
})
|
||||
},
|
||||
getCurrencies: async function () {
|
||||
updateStall: async function () {
|
||||
try {
|
||||
const stallData = {
|
||||
id: this.editDialog.data.id,
|
||||
name: this.editDialog.data.name,
|
||||
wallet: this.editDialog.data.wallet,
|
||||
currency: this.editDialog.data.currency,
|
||||
shipping_zones: this.editDialog.data.shippingZones,
|
||||
config: {
|
||||
description: this.editDialog.data.description
|
||||
}
|
||||
}
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
'/nostrmarket/api/v1/currencies',
|
||||
this.inkey
|
||||
'PUT',
|
||||
`/nostrmarket/api/v1/stall/${stallData.id}`,
|
||||
this.adminkey,
|
||||
stallData
|
||||
)
|
||||
|
||||
return ['sat', ...data]
|
||||
this.editDialog.show = false
|
||||
const index = this.stalls.findIndex(s => s.id === data.id)
|
||||
if (index !== -1) {
|
||||
this.stalls.splice(index, 1, data)
|
||||
}
|
||||
this.emitStallCount()
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message: 'Stall updated!'
|
||||
})
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
return []
|
||||
},
|
||||
deleteStall: async function (stall) {
|
||||
try {
|
||||
await LNbits.api.request(
|
||||
'DELETE',
|
||||
'/nostrmarket/api/v1/stall/' + stall.id,
|
||||
this.adminkey
|
||||
)
|
||||
this.stalls = this.stalls.filter(s => s.id !== stall.id)
|
||||
this.pendingStalls = this.pendingStalls.filter(s => s.id !== stall.id)
|
||||
this.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 {
|
||||
|
|
@ -170,7 +203,7 @@ window.app.component('stall-list', {
|
|||
`/nostrmarket/api/v1/stall?pending=${pending}`,
|
||||
this.inkey
|
||||
)
|
||||
return data.map(s => ({...s, expanded: false}))
|
||||
return data
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
|
|
@ -194,20 +227,8 @@ window.app.component('stall-list', {
|
|||
}
|
||||
return []
|
||||
},
|
||||
handleStallDeleted: function (stallId) {
|
||||
this.stalls = _.reject(this.stalls, function (obj) {
|
||||
return obj.id === stallId
|
||||
})
|
||||
},
|
||||
handleStallUpdated: function (stall) {
|
||||
const index = this.stalls.findIndex(r => r.id === stall.id)
|
||||
if (index !== -1) {
|
||||
stall.expanded = true
|
||||
this.stalls.splice(index, 1, stall)
|
||||
}
|
||||
},
|
||||
openCreateStallDialog: async function (stallData) {
|
||||
this.currencies = await this.getCurrencies()
|
||||
this.currencies = this.getCurrencies()
|
||||
this.zoneOptions = await this.getZones()
|
||||
if (!this.zoneOptions || !this.zoneOptions.length) {
|
||||
this.$q.notify({
|
||||
|
|
@ -225,6 +246,24 @@ window.app.component('stall-list', {
|
|||
}
|
||||
this.stallDialog.show = true
|
||||
},
|
||||
openEditStallDialog: async function (stall) {
|
||||
this.currencies = this.getCurrencies()
|
||||
this.zoneOptions = await this.getZones()
|
||||
this.editDialog.data = {
|
||||
id: stall.id,
|
||||
name: stall.name,
|
||||
description: stall.config?.description || '',
|
||||
wallet: stall.wallet,
|
||||
currency: stall.currency,
|
||||
shippingZones: (stall.shipping_zones || []).map(z => ({
|
||||
...z,
|
||||
label: z.name
|
||||
? `${z.name} (${z.countries.join(', ')})`
|
||||
: z.countries.join(', ')
|
||||
}))
|
||||
}
|
||||
this.editDialog.show = true
|
||||
},
|
||||
openSelectPendingStallDialog: async function () {
|
||||
this.stallDialog.showRestore = true
|
||||
this.pendingStalls = await this.getStalls(true)
|
||||
|
|
@ -246,8 +285,11 @@ window.app.component('stall-list', {
|
|||
}))
|
||||
})
|
||||
},
|
||||
customerSelectedForOrder: function (customerPubkey) {
|
||||
this.$emit('customer-selected-for-order', customerPubkey)
|
||||
goToProducts: function (stall) {
|
||||
this.$emit('go-to-products', stall.id)
|
||||
},
|
||||
goToOrders: function (stall) {
|
||||
this.$emit('go-to-orders', stall.id)
|
||||
},
|
||||
shortLabel(value = '') {
|
||||
if (value.length <= 64) return value
|
||||
|
|
@ -256,7 +298,8 @@ window.app.component('stall-list', {
|
|||
},
|
||||
created: async function () {
|
||||
this.stalls = await this.getStalls()
|
||||
this.currencies = await this.getCurrencies()
|
||||
this.emitStallCount()
|
||||
this.currencies = this.getCurrencies()
|
||||
this.zoneOptions = await this.getZones()
|
||||
}
|
||||
})
|
||||
|
|
|
|||
123
static/images/generate_logo.py
Normal file
123
static/images/generate_logo.py
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate the Nostr Market logo.
|
||||
Requires: pip install Pillow
|
||||
"""
|
||||
|
||||
from PIL import Image, ImageDraw # type: ignore[import-not-found]
|
||||
|
||||
# Render at 4x size for antialiasing
|
||||
scale = 4
|
||||
size = 128 * scale
|
||||
final_size = 128
|
||||
|
||||
# Consistent color scheme with Nostr Proxy
|
||||
dark_purple = (80, 40, 120)
|
||||
light_purple = (140, 100, 180)
|
||||
white = (255, 255, 255)
|
||||
|
||||
margin = 4 * scale
|
||||
|
||||
swoosh_center = ((128 + 100) * scale, -90 * scale)
|
||||
swoosh_radius = 220 * scale
|
||||
|
||||
# Create rounded rectangle mask
|
||||
mask = Image.new("L", (size, size), 0)
|
||||
mask_draw = ImageDraw.Draw(mask)
|
||||
corner_radius = 20 * scale
|
||||
mask_draw.rounded_rectangle(
|
||||
[margin, margin, size - margin, size - margin],
|
||||
radius=corner_radius,
|
||||
fill=255,
|
||||
)
|
||||
|
||||
# Create background with swoosh
|
||||
bg = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||
bg_draw = ImageDraw.Draw(bg)
|
||||
bg_draw.rounded_rectangle(
|
||||
[margin, margin, size - margin, size - margin],
|
||||
radius=corner_radius,
|
||||
fill=dark_purple,
|
||||
)
|
||||
bg_draw.ellipse(
|
||||
[
|
||||
swoosh_center[0] - swoosh_radius,
|
||||
swoosh_center[1] - swoosh_radius,
|
||||
swoosh_center[0] + swoosh_radius,
|
||||
swoosh_center[1] + swoosh_radius,
|
||||
],
|
||||
fill=light_purple,
|
||||
)
|
||||
|
||||
# Apply rounded rectangle mask
|
||||
final = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||
final.paste(bg, mask=mask)
|
||||
draw = ImageDraw.Draw(final)
|
||||
|
||||
center_x, center_y = size // 2, size // 2
|
||||
|
||||
# Shop/storefront - wider and shorter for shop look
|
||||
shop_width = 80 * scale
|
||||
awning_height = 18 * scale
|
||||
body_height = 45 * scale
|
||||
total_height = awning_height + body_height
|
||||
|
||||
shop_left = center_x - shop_width // 2
|
||||
shop_right = center_x + shop_width // 2
|
||||
|
||||
# Center vertically
|
||||
awning_top = center_y - total_height // 2
|
||||
awning_bottom = awning_top + awning_height
|
||||
shop_bottom = awning_bottom + body_height
|
||||
awning_extend = 5 * scale
|
||||
|
||||
# Draw awning background (white base)
|
||||
draw.rectangle(
|
||||
[shop_left - awning_extend, awning_top, shop_right + awning_extend, awning_bottom],
|
||||
fill=white,
|
||||
)
|
||||
|
||||
# Vertical stripes on awning (alternating dark purple)
|
||||
stripe_count = 8
|
||||
stripe_width = (shop_width + 2 * awning_extend) // stripe_count
|
||||
for i in range(1, stripe_count, 2):
|
||||
x_left = shop_left - awning_extend + i * stripe_width
|
||||
draw.rectangle(
|
||||
[x_left, awning_top, x_left + stripe_width, awning_bottom],
|
||||
fill=dark_purple,
|
||||
)
|
||||
|
||||
# Shop body (below awning)
|
||||
draw.rectangle(
|
||||
[shop_left, awning_bottom, shop_right, shop_bottom],
|
||||
fill=white,
|
||||
)
|
||||
|
||||
# Large display window (shop style)
|
||||
window_margin = 8 * scale
|
||||
window_top = awning_bottom + 6 * scale
|
||||
window_bottom = shop_bottom - 6 * scale
|
||||
# Left display window
|
||||
draw.rectangle(
|
||||
[shop_left + window_margin, window_top, center_x - 10 * scale, window_bottom],
|
||||
fill=dark_purple,
|
||||
)
|
||||
# Right display window
|
||||
draw.rectangle(
|
||||
[center_x + 10 * scale, window_top, shop_right - window_margin, window_bottom],
|
||||
fill=dark_purple,
|
||||
)
|
||||
|
||||
# Door (center, dark purple cutout)
|
||||
door_width = 14 * scale
|
||||
door_left = center_x - door_width // 2
|
||||
draw.rectangle(
|
||||
[door_left, window_top, door_left + door_width, shop_bottom],
|
||||
fill=dark_purple,
|
||||
)
|
||||
|
||||
# Downscale with LANCZOS for antialiasing
|
||||
final = final.resize((final_size, final_size), Image.LANCZOS)
|
||||
|
||||
final.save("nostr-market.png")
|
||||
print("Logo saved to nostr-market.png")
|
||||
BIN
static/images/nostr-market.png
Normal file
BIN
static/images/nostr-market.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.3 KiB |
|
|
@ -5,45 +5,60 @@ window.app = Vue.createApp({
|
|||
mixins: [window.windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
activeTab: 'orders',
|
||||
selectedStallFilter: null,
|
||||
merchant: {},
|
||||
shippingZones: [],
|
||||
activeChatCustomer: '',
|
||||
orderPubkey: null,
|
||||
showKeys: false,
|
||||
importKeyDialog: {
|
||||
show: false,
|
||||
data: {
|
||||
privateKey: null
|
||||
}
|
||||
},
|
||||
wsConnection: null
|
||||
stallCount: 0,
|
||||
wsConnection: null,
|
||||
nostrStatus: {
|
||||
connected: false,
|
||||
error: null,
|
||||
relays_connected: 0,
|
||||
relays_total: 0
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
nostrStatusColor: function () {
|
||||
if (this.nostrStatus.connected) {
|
||||
return 'green'
|
||||
} else if (this.nostrStatus.warning) {
|
||||
return 'orange'
|
||||
}
|
||||
return 'red'
|
||||
},
|
||||
nostrStatusLabel: function () {
|
||||
return 'Connect'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
generateKeys: async function () {
|
||||
const privateKey = nostr.generatePrivateKey()
|
||||
await this.createMerchant(privateKey)
|
||||
},
|
||||
importKeys: async function () {
|
||||
this.importKeyDialog.show = false
|
||||
let privateKey = this.importKeyDialog.data.privateKey
|
||||
if (!privateKey) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (privateKey.toLowerCase().startsWith('nsec')) {
|
||||
privateKey = nostr.nip19.decode(privateKey).data
|
||||
}
|
||||
} catch (error) {
|
||||
this.$q.notify({
|
||||
type: 'negative',
|
||||
message: `${error}`
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
await this.createMerchant(privateKey)
|
||||
},
|
||||
showImportKeysDialog: async function () {
|
||||
this.importKeyDialog.show = true
|
||||
},
|
||||
toggleShowKeys: function () {
|
||||
this.showKeys = !this.showKeys
|
||||
|
|
@ -93,13 +108,11 @@ window.app = Vue.createApp({
|
|||
this.shippingZones = []
|
||||
this.activeChatCustomer = ''
|
||||
this.showKeys = false
|
||||
this.stallCount = 0
|
||||
},
|
||||
createMerchant: async function (privateKey) {
|
||||
createMerchant: async function () {
|
||||
try {
|
||||
const pubkey = nostr.getPublicKey(privateKey)
|
||||
const payload = {
|
||||
private_key: privateKey,
|
||||
public_key: pubkey,
|
||||
config: {}
|
||||
}
|
||||
const {data} = await LNbits.api.request(
|
||||
|
|
@ -196,10 +209,132 @@ window.app = Vue.createApp({
|
|||
})
|
||||
}
|
||||
},
|
||||
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({
|
||||
timeout: 3000,
|
||||
type: this.nostrStatus.connected ? 'positive' : 'warning',
|
||||
message: this.nostrStatus.connected ? 'Connected' : 'Disconnected',
|
||||
caption: this.nostrStatus.error || undefined
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check nostr status:', error)
|
||||
this.nostrStatus = {
|
||||
connected: false,
|
||||
error: error.message,
|
||||
relays_connected: 0,
|
||||
relays_total: 0
|
||||
}
|
||||
if (showNotification) {
|
||||
this.$q.notify({
|
||||
timeout: 5000,
|
||||
type: 'negative',
|
||||
message: this.nostrStatus.error
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
restartNostrConnection: async function () {
|
||||
LNbits.utils
|
||||
.confirmDialog(
|
||||
'Are you sure you want to reconnect to the nostrcient extension?'
|
||||
'Are you sure you want to reconnect to the nostrclient extension?'
|
||||
)
|
||||
.onOk(async () => {
|
||||
try {
|
||||
this.$q.notify({
|
||||
timeout: 2000,
|
||||
type: 'info',
|
||||
message: 'Reconnecting...'
|
||||
})
|
||||
await LNbits.api.request(
|
||||
'PUT',
|
||||
'/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',
|
||||
`/nostrmarket/api/v1/stall/${stall.id}`,
|
||||
this.g.user.wallets[0].adminkey,
|
||||
stall
|
||||
)
|
||||
}
|
||||
// Fetch products from all stalls
|
||||
let productCount = 0
|
||||
for (const stall of stalls) {
|
||||
const {data: products} = await LNbits.api.request(
|
||||
'GET',
|
||||
`/nostrmarket/api/v1/stall/product/${stall.id}?pending=false`,
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
for (const product of products) {
|
||||
await LNbits.api.request(
|
||||
'PATCH',
|
||||
`/nostrmarket/api/v1/product/${product.id}`,
|
||||
this.g.user.wallets[0].adminkey,
|
||||
product
|
||||
)
|
||||
productCount++
|
||||
}
|
||||
}
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message: `Published ${stalls.length} stall(s) and ${productCount} product(s) to Nostr (NIP-15)`
|
||||
})
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
},
|
||||
refreshNip15: async function () {
|
||||
LNbits.utils
|
||||
.confirmDialog(
|
||||
'This will sync your stalls and products from Nostr relays. Continue?'
|
||||
)
|
||||
.onOk(async () => {
|
||||
try {
|
||||
|
|
@ -208,14 +343,42 @@ window.app = Vue.createApp({
|
|||
'/nostrmarket/api/v1/restart',
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message: 'Refreshing NIP-15 data from Nostr...'
|
||||
})
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
})
|
||||
},
|
||||
deleteNip15: async function () {
|
||||
LNbits.utils
|
||||
.confirmDialog(
|
||||
'WARNING: This will delete all your stalls and products from Nostr relays. This cannot be undone! Are you sure?'
|
||||
)
|
||||
.onOk(async () => {
|
||||
this.$q.notify({
|
||||
type: 'info',
|
||||
message: 'Delete NIP-15 from Nostr not yet implemented'
|
||||
})
|
||||
})
|
||||
},
|
||||
goToProducts: function (stallId) {
|
||||
this.selectedStallFilter = stallId
|
||||
this.activeTab = 'products'
|
||||
},
|
||||
goToOrders: function (stallId) {
|
||||
this.selectedStallFilter = stallId
|
||||
}
|
||||
},
|
||||
created: async function () {
|
||||
await this.getMerchant()
|
||||
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 ||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,43 @@
|
|||
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 = [
|
||||
'wss://relay.damus.io',
|
||||
'wss://relay.snort.social',
|
||||
|
|
@ -44,13 +82,24 @@ function confirm(message) {
|
|||
|
||||
|
||||
async function hash(string) {
|
||||
const utf8 = new TextEncoder().encode(string)
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', utf8)
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
||||
const hashHex = hashArray
|
||||
.map(bytes => bytes.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
return hashHex
|
||||
const subtle = globalThis.crypto && globalThis.crypto.subtle
|
||||
if (subtle && subtle.digest) {
|
||||
const utf8 = new TextEncoder().encode(string)
|
||||
const hashBuffer = await subtle.digest('SHA-256', utf8)
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
||||
return hashArray.map(bytes => bytes.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
// 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) {
|
||||
|
|
|
|||
32
tasks.py
32
tasks.py
|
|
@ -1,4 +1,5 @@
|
|||
import asyncio
|
||||
import time
|
||||
from asyncio import Queue
|
||||
|
||||
from lnbits.core.models import Payment
|
||||
|
|
@ -9,9 +10,13 @@ from .nostr.nostr_client import NostrClient
|
|||
from .services import (
|
||||
handle_order_paid,
|
||||
process_nostr_message,
|
||||
resubscribe_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():
|
||||
invoice_queue = Queue()
|
||||
|
|
@ -35,13 +40,38 @@ async def on_invoice_paid(payment: Payment) -> None:
|
|||
|
||||
|
||||
async def wait_for_nostr_events(nostr_client: NostrClient):
|
||||
logger.info("[NOSTRMARKET] Starting wait_for_nostr_events task")
|
||||
while True:
|
||||
try:
|
||||
logger.info("[NOSTRMARKET] Subscribing to all merchants...")
|
||||
await subscribe_to_all_merchants()
|
||||
|
||||
while True:
|
||||
message = await nostr_client.get_event()
|
||||
await process_nostr_message(message)
|
||||
except Exception as e:
|
||||
logger.warning(f"Subcription failed. Will retry in one minute: {e}")
|
||||
logger.warning(f"[NOSTRMARKET] Subscription failed. Retrying in 10s: {e}")
|
||||
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,44 +1,213 @@
|
|||
<q-card>
|
||||
<q-card-section>
|
||||
<p>
|
||||
Nostr Market<br />
|
||||
<small>
|
||||
Created by,
|
||||
<q-expansion-item
|
||||
icon="help_outline"
|
||||
label="What is Nostr?"
|
||||
header-class="text-weight-medium"
|
||||
>
|
||||
<q-card>
|
||||
<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
|
||||
class="text-secondary"
|
||||
target="_blank"
|
||||
style="color: unset"
|
||||
href="https://github.com/talvasconcelos"
|
||||
>Tal Vasconcelos</a
|
||||
>
|
||||
<a
|
||||
class="text-secondary"
|
||||
target="_blank"
|
||||
style="color: unset"
|
||||
href="https://github.com/benarc"
|
||||
>Ben Arc</a
|
||||
class="text-decoration-none"
|
||||
>
|
||||
<q-chip clickable icon="person">Tal Vasconcelos</q-chip>
|
||||
</a>
|
||||
<a
|
||||
class="text-secondary"
|
||||
href="https://github.com/arcbtc"
|
||||
target="_blank"
|
||||
style="color: unset"
|
||||
class="text-decoration-none"
|
||||
>
|
||||
<q-chip clickable icon="person">Ben Arc</q-chip>
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/motorina0"
|
||||
>motorina0</a
|
||||
></small
|
||||
>
|
||||
</p>
|
||||
<a
|
||||
class="text-secondary"
|
||||
target="_blank"
|
||||
href="/docs#/nostrmarket"
|
||||
class="text-white"
|
||||
>Swagger REST API Documentation</a
|
||||
>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<a class="text-secondary" target="_blank" href="/nostrmarket/market"
|
||||
><q-tooltip>Visit the market client</q-tooltip
|
||||
><q-icon name="storefront" class="q-mr-sm"></q-icon>Market client</a
|
||||
>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
target="_blank"
|
||||
class="text-decoration-none"
|
||||
>
|
||||
<q-chip clickable icon="person">motorina0</q-chip>
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/BenGWeeks"
|
||||
target="_blank"
|
||||
class="text-decoration-none"
|
||||
>
|
||||
<q-chip clickable icon="person">Ben Weeks</q-chip>
|
||||
</a>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
|
||||
<q-separator></q-separator>
|
||||
|
||||
<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,143 +1,147 @@
|
|||
<div>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="row items-center q-col-gutter-sm">
|
||||
<div class="col-auto">
|
||||
<h6 class="text-subtitle1 q-my-none">Messages</h6>
|
||||
</div>
|
||||
<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 class="q-pa-none">
|
||||
<q-separator></q-separator>
|
||||
</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"
|
||||
<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 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
|
||||
</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>
|
||||
<q-btn
|
||||
round
|
||||
dense
|
||||
flat
|
||||
type="submit"
|
||||
icon="send"
|
||||
color="primary"
|
||||
/>
|
||||
<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-input>
|
||||
</q-form>
|
||||
</q-card-section>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</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">
|
||||
|
|
|
|||
68
templates/nostrmarket/components/edit-profile-dialog.html
Normal file
68
templates/nostrmarket/components/edit-profile-dialog.html
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
<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,93 +0,0 @@
|
|||
<div>
|
||||
<q-separator></q-separator>
|
||||
|
||||
<!-- Header with toggle -->
|
||||
<div class="row items-center justify-between q-mt-md q-px-md">
|
||||
<div class="text-subtitle2">Keys</div>
|
||||
<q-toggle
|
||||
v-model="showPrivateKey"
|
||||
color="primary"
|
||||
label="Show Private Key"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- QR Codes Container -->
|
||||
<div class="row q-col-gutter-md q-pa-md">
|
||||
<!-- Public Key QR -->
|
||||
<div class="col-12" :class="showPrivateKey ? 'col-sm-6' : ''">
|
||||
<q-card flat bordered>
|
||||
<q-card-section class="text-center">
|
||||
<div class="text-subtitle2 q-mb-sm">Public Key</div>
|
||||
<div
|
||||
class="cursor-pointer q-mx-auto"
|
||||
style="max-width: 200px"
|
||||
@click="copyText(publicKey)"
|
||||
>
|
||||
<q-responsive :ratio="1">
|
||||
<lnbits-qrcode
|
||||
:value="publicKey"
|
||||
:options="{width: 200}"
|
||||
:show-buttons="false"
|
||||
class="rounded-borders"
|
||||
></lnbits-qrcode>
|
||||
</q-responsive>
|
||||
</div>
|
||||
<div class="q-mt-md text-caption text-mono" style="padding: 0 16px">
|
||||
<span v-text="publicKey.substring(0, 8)"></span>...<span
|
||||
v-text="publicKey.substring(publicKey.length - 8)"
|
||||
></span>
|
||||
</div>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="sm"
|
||||
icon="content_copy"
|
||||
label="Click to copy"
|
||||
@click="copyText(publicKey)"
|
||||
class="q-mt-xs"
|
||||
/>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<!-- Private Key QR (conditional) -->
|
||||
<div v-if="showPrivateKey" class="col-12 col-sm-6">
|
||||
<q-card flat bordered>
|
||||
<q-card-section class="text-center">
|
||||
<div class="text-subtitle2 q-mb-sm text-warning">
|
||||
<q-icon name="warning"></q-icon>
|
||||
Private Key (Keep Secret!)
|
||||
</div>
|
||||
<div
|
||||
class="cursor-pointer q-mx-auto"
|
||||
style="max-width: 200px"
|
||||
@click="copyText(privateKey)"
|
||||
>
|
||||
<q-responsive :ratio="1">
|
||||
<lnbits-qrcode
|
||||
:value="privateKey"
|
||||
:options="{width: 200}"
|
||||
:show-buttons="false"
|
||||
class="rounded-borders"
|
||||
></lnbits-qrcode>
|
||||
</q-responsive>
|
||||
</div>
|
||||
<div class="q-mt-md text-caption text-mono" style="padding: 0 16px">
|
||||
<span v-text="privateKey.substring(0, 8)"></span>...<span
|
||||
v-text="privateKey.substring(privateKey.length - 8)"
|
||||
></span>
|
||||
</div>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="sm"
|
||||
icon="content_copy"
|
||||
label="Click to copy"
|
||||
@click="copyText(privateKey)"
|
||||
class="q-mt-xs"
|
||||
/>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
271
templates/nostrmarket/components/merchant-tab.html
Normal file
271
templates/nostrmarket/components/merchant-tab.html
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
<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>
|
||||
75
templates/nostrmarket/components/nostr-keys-dialog.html
Normal file
75
templates/nostrmarket/components/nostr-keys-dialog.html
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
<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>
|
||||
309
templates/nostrmarket/components/product-list.html
Normal file
309
templates/nostrmarket/components/product-list.html
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
<div>
|
||||
<div class="row items-center q-mb-md q-col-gutter-sm justify-end">
|
||||
<div class="col-auto">
|
||||
<q-input
|
||||
dense
|
||||
debounce="300"
|
||||
v-model="filter"
|
||||
placeholder="Search by name, stall..."
|
||||
style="min-width: 250px"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<q-icon name="search"></q-icon>
|
||||
</template>
|
||||
</q-input>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<q-btn-dropdown
|
||||
flat
|
||||
dense
|
||||
:icon="selectedStall ? 'filter_alt' : 'filter_alt_off'"
|
||||
:color="selectedStall ? 'primary' : 'grey'"
|
||||
>
|
||||
<q-list>
|
||||
<q-item clickable v-close-popup @click="selectedStall = null">
|
||||
<q-item-section>
|
||||
<q-item-label>All Stalls</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side v-if="!selectedStall">
|
||||
<q-icon name="check" color="primary"></q-icon>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-separator></q-separator>
|
||||
<q-item
|
||||
v-for="stall in stallOptions"
|
||||
:key="stall.value"
|
||||
clickable
|
||||
v-close-popup
|
||||
@click="selectedStall = stall.value"
|
||||
>
|
||||
<q-item-section>
|
||||
<q-item-label v-text="stall.label"></q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side v-if="selectedStall === stall.value">
|
||||
<q-icon name="check" color="primary"></q-icon>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-btn-dropdown>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<q-btn
|
||||
@click="openSelectPendingProductDialog"
|
||||
outline
|
||||
color="primary"
|
||||
icon="restore"
|
||||
label="Restore Product"
|
||||
:disable="!stalls.length"
|
||||
class="q-px-md"
|
||||
></q-btn>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<q-btn
|
||||
@click="showNewProductDialog()"
|
||||
unelevated
|
||||
color="primary"
|
||||
icon="add"
|
||||
label="New Product"
|
||||
:disable="!stalls.length"
|
||||
class="q-px-md"
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!stalls.length" class="text-center q-pa-lg text-grey">
|
||||
<q-icon name="info" size="md" class="q-mb-sm"></q-icon>
|
||||
<div>No stalls found. Please create a stall first in the Stalls tab.</div>
|
||||
</div>
|
||||
|
||||
<q-table
|
||||
v-else
|
||||
flat
|
||||
dense
|
||||
:rows="filteredProducts"
|
||||
row-key="id"
|
||||
:columns="productsTable.columns"
|
||||
v-model:pagination="productsTable.pagination"
|
||||
:filter="filter"
|
||||
>
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props">
|
||||
<q-td key="name" :props="props">
|
||||
<span v-text="shortLabel(props.row.name)"></span>
|
||||
</q-td>
|
||||
<q-td key="stall" :props="props">
|
||||
<span v-text="getStallName(props.row.stall_id)"></span>
|
||||
</q-td>
|
||||
<q-td key="price" :props="props">
|
||||
<span v-text="props.row.price"></span>
|
||||
</q-td>
|
||||
<q-td key="quantity" :props="props">
|
||||
<span v-text="props.row.quantity"></span>
|
||||
</q-td>
|
||||
<q-td key="actions" :props="props">
|
||||
<q-toggle
|
||||
@update:model-value="toggleProductActive(props.row)"
|
||||
size="xs"
|
||||
checked-icon="check"
|
||||
:model-value="props.row.active"
|
||||
color="green"
|
||||
unchecked-icon="clear"
|
||||
>
|
||||
<q-tooltip v-if="props.row.active"
|
||||
>Product is active - click to deactivate</q-tooltip
|
||||
>
|
||||
<q-tooltip v-else
|
||||
>Product is inactive - click to activate</q-tooltip
|
||||
>
|
||||
</q-toggle>
|
||||
<q-btn
|
||||
size="sm"
|
||||
color="primary"
|
||||
dense
|
||||
flat
|
||||
@click="editProduct(props.row)"
|
||||
icon="edit"
|
||||
>
|
||||
<q-tooltip>Edit product</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
size="sm"
|
||||
color="negative"
|
||||
dense
|
||||
flat
|
||||
@click="deleteProduct(props.row)"
|
||||
icon="delete"
|
||||
>
|
||||
<q-tooltip>Delete product</q-tooltip>
|
||||
</q-btn>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
</q-table>
|
||||
|
||||
<!-- Product Dialog -->
|
||||
<q-dialog v-model="productDialog.showDialog" position="top">
|
||||
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
|
||||
<q-form @submit="sendProductFormData" class="q-gutter-md">
|
||||
<q-select
|
||||
v-if="!productDialog.data.id"
|
||||
filled
|
||||
dense
|
||||
v-model="productDialog.data.stall_id"
|
||||
:options="stallOptions"
|
||||
label="Stall *"
|
||||
emit-value
|
||||
map-options
|
||||
></q-select>
|
||||
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="productDialog.data.name"
|
||||
label="Name *"
|
||||
></q-input>
|
||||
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="productDialog.data.config.description"
|
||||
label="Description"
|
||||
></q-input>
|
||||
|
||||
<div class="row q-mb-sm">
|
||||
<div class="col">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="productDialog.data.price"
|
||||
type="number"
|
||||
:label="'Price (' + getStallCurrency(productDialog.data.stall_id) + ') *'"
|
||||
:step="getStallCurrency(productDialog.data.stall_id) != 'sat' ? '0.01' : '1'"
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col q-ml-md">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="productDialog.data.quantity"
|
||||
type="number"
|
||||
label="Quantity *"
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-expansion-item
|
||||
group="advanced"
|
||||
label="Categories"
|
||||
caption="Add tags to products"
|
||||
>
|
||||
<div class="q-pl-sm q-pt-sm">
|
||||
<q-select
|
||||
filled
|
||||
multiple
|
||||
dense
|
||||
emit-value
|
||||
v-model.trim="productDialog.data.categories"
|
||||
use-input
|
||||
use-chips
|
||||
hide-dropdown-icon
|
||||
input-debounce="0"
|
||||
new-value-mode="add-unique"
|
||||
label="Categories (Hit Enter to add)"
|
||||
></q-select>
|
||||
</div>
|
||||
</q-expansion-item>
|
||||
|
||||
<q-expansion-item
|
||||
group="advanced"
|
||||
label="Images"
|
||||
caption="Add images for product"
|
||||
>
|
||||
<div class="q-pl-sm q-pt-sm">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="productDialog.data.image"
|
||||
@keydown.enter.prevent="addProductImage"
|
||||
type="url"
|
||||
label="Image URL"
|
||||
>
|
||||
<template v-slot:append>
|
||||
<q-btn @click="addProductImage" dense flat icon="add"></q-btn>
|
||||
</template>
|
||||
</q-input>
|
||||
<q-chip
|
||||
v-for="imageUrl in productDialog.data.images"
|
||||
:key="imageUrl"
|
||||
removable
|
||||
@remove="removeProductImage(imageUrl)"
|
||||
color="primary"
|
||||
text-color="white"
|
||||
>
|
||||
<span v-text="imageUrl.split('/').pop()"></span>
|
||||
</q-chip>
|
||||
</div>
|
||||
</q-expansion-item>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
v-if="productDialog.data.id"
|
||||
type="submit"
|
||||
:label="productDialog.data.pending ? 'Restore Product' : 'Update Product'"
|
||||
unelevated
|
||||
color="primary"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
v-else
|
||||
unelevated
|
||||
color="primary"
|
||||
:disable="!productDialog.data.stall_id || !productDialog.data.price || !productDialog.data.name || !productDialog.data.quantity"
|
||||
type="submit"
|
||||
>Create Product</q-btn
|
||||
>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<!-- Restore Dialog -->
|
||||
<q-dialog v-model="productDialog.showRestore" position="top">
|
||||
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
|
||||
<div v-if="pendingProducts && pendingProducts.length" class="row q-mt-lg">
|
||||
<q-item
|
||||
v-for="pendingProduct of pendingProducts"
|
||||
:key="pendingProduct.id"
|
||||
tag="label"
|
||||
class="full-width"
|
||||
v-ripple
|
||||
>
|
||||
<q-item-section>
|
||||
<q-item-label
|
||||
><span v-text="pendingProduct.name"></span
|
||||
></q-item-label>
|
||||
<q-item-label caption
|
||||
><span v-text="pendingProduct.config?.description"></span
|
||||
></q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section class="q-pl-xl float-right">
|
||||
<q-btn
|
||||
@click="openRestoreProductDialog(pendingProduct)"
|
||||
v-close-popup
|
||||
flat
|
||||
color="green"
|
||||
class="q-ml-auto float-right"
|
||||
>Restore</q-btn
|
||||
>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</div>
|
||||
<div v-else>There are no products to be restored.</div>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</div>
|
||||
136
templates/nostrmarket/components/shipping-zones-list.html
Normal file
136
templates/nostrmarket/components/shipping-zones-list.html
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
<div>
|
||||
<div class="row items-center q-mb-md q-col-gutter-sm justify-end">
|
||||
<div class="col-auto">
|
||||
<q-input
|
||||
dense
|
||||
debounce="300"
|
||||
v-model="filter"
|
||||
placeholder="Search zones..."
|
||||
style="min-width: 200px"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<q-icon name="search"></q-icon>
|
||||
</template>
|
||||
</q-input>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
icon="add"
|
||||
label="New Shipping Zone"
|
||||
@click="openZoneDialog()"
|
||||
class="q-px-md"
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-table
|
||||
flat
|
||||
dense
|
||||
:rows="zones"
|
||||
row-key="id"
|
||||
:columns="zonesTable.columns"
|
||||
v-model:pagination="zonesTable.pagination"
|
||||
:filter="filter"
|
||||
>
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props">
|
||||
<q-td key="name" :props="props">
|
||||
<span v-text="props.row.name || '(unnamed)'"></span>
|
||||
</q-td>
|
||||
<q-td key="countries" :props="props">
|
||||
<span v-text="props.row.countries.join(', ')"></span>
|
||||
</q-td>
|
||||
<q-td key="cost" :props="props">
|
||||
<span v-text="props.row.cost"></span>
|
||||
</q-td>
|
||||
<q-td key="currency" :props="props">
|
||||
<span v-text="props.row.currency"></span>
|
||||
</q-td>
|
||||
<q-td key="actions" :props="props">
|
||||
<q-btn
|
||||
size="sm"
|
||||
color="primary"
|
||||
dense
|
||||
flat
|
||||
icon="edit"
|
||||
@click="openZoneDialog(props.row)"
|
||||
>
|
||||
<q-tooltip>Edit zone</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
size="sm"
|
||||
color="negative"
|
||||
dense
|
||||
flat
|
||||
icon="delete"
|
||||
@click="confirmDeleteZone(props.row)"
|
||||
>
|
||||
<q-tooltip>Delete zone</q-tooltip>
|
||||
</q-btn>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
</q-table>
|
||||
|
||||
<q-dialog v-model="zoneDialog.showDialog" position="top">
|
||||
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
|
||||
<q-form @submit="sendZoneFormData" class="q-gutter-md">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
label="Zone Name"
|
||||
type="text"
|
||||
v-model.trim="zoneDialog.data.name"
|
||||
></q-input>
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
multiple
|
||||
:options="shippingZoneOptions"
|
||||
label="Countries"
|
||||
v-model="zoneDialog.data.countries"
|
||||
></q-select>
|
||||
<q-select
|
||||
:disabled="!!zoneDialog.data.id"
|
||||
:readonly="!!zoneDialog.data.id"
|
||||
filled
|
||||
dense
|
||||
v-model="zoneDialog.data.currency"
|
||||
type="text"
|
||||
label="Unit"
|
||||
:options="currencies"
|
||||
></q-select>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
:label="'Cost of Shipping (' + zoneDialog.data.currency + ') *'"
|
||||
fill-mask="0"
|
||||
reverse-fill-mask
|
||||
:step="zoneDialog.data.currency != 'sat' ? '0.01' : '1'"
|
||||
type="number"
|
||||
v-model.trim="zoneDialog.data.cost"
|
||||
></q-input>
|
||||
<div class="row q-mt-lg">
|
||||
<div v-if="zoneDialog.data.id">
|
||||
<q-btn unelevated color="primary" type="submit">Update</q-btn>
|
||||
</div>
|
||||
<div v-else>
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
:disable="!zoneDialog.data.countries || !zoneDialog.data.countries.length"
|
||||
type="submit"
|
||||
>Create Shipping Zone</q-btn
|
||||
>
|
||||
</div>
|
||||
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</div>
|
||||
|
|
@ -48,26 +48,36 @@
|
|||
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 items-start">
|
||||
<div class="col q-mr-sm">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
label="Default shipping cost"
|
||||
fill-mask="0"
|
||||
reverse-fill-mask
|
||||
:step="zoneDialog.data.currency != 'sat' ? '0.01' : '1'"
|
||||
type="number"
|
||||
v-model.trim="zoneDialog.data.cost"
|
||||
:error="(zoneDialog.data.currency === 'sat' && zoneDialog.data.cost % 1 !== 0) || (zoneDialog.data.currency !== 'sat' && (zoneDialog.data.cost * 100) % 1 !== 0)"
|
||||
:error-message="zoneDialog.data.currency === 'sat' ? 'Satoshis must be whole numbers' : 'Maximum 2 decimal places allowed'"
|
||||
hint="Additional costs can be set per product"
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<q-select
|
||||
:disabled="!!zoneDialog.data.id"
|
||||
:readonly="!!zoneDialog.data.id"
|
||||
filled
|
||||
dense
|
||||
v-model="zoneDialog.data.currency"
|
||||
type="text"
|
||||
label="Currency"
|
||||
:options="currencies"
|
||||
style="min-width: 100px"
|
||||
></q-select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<div v-if="zoneDialog.data.id">
|
||||
<q-btn unelevated color="primary" type="submit">Update</q-btn>
|
||||
|
|
@ -83,7 +93,7 @@
|
|||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
:disable="!zoneDialog.data.countries || !zoneDialog.data.countries.length"
|
||||
:disable="!zoneDialog.data.name || !zoneDialog.data.countries || !zoneDialog.data.countries.length || (zoneDialog.data.currency === 'sat' && zoneDialog.data.cost % 1 !== 0) || (zoneDialog.data.currency !== 'sat' && (zoneDialog.data.cost * 100) % 1 !== 0)"
|
||||
type="submit"
|
||||
>Create Shipping Zone</q-btn
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,43 +1,38 @@
|
|||
<div>
|
||||
<div class="row items-center no-wrap q-mb-md">
|
||||
<div class="col q-pr-lg">
|
||||
<q-btn-dropdown
|
||||
@click="openCreateStallDialog()"
|
||||
outline
|
||||
unelevated
|
||||
split
|
||||
class="float-left"
|
||||
color="primary"
|
||||
label="New Stall (Store)"
|
||||
>
|
||||
<q-item @click="openCreateStallDialog()" clickable v-close-popup>
|
||||
<q-item-section>
|
||||
<q-item-label>New Stall</q-item-label>
|
||||
<q-item-label caption>Create a new stall</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item @click="openSelectPendingStallDialog" clickable v-close-popup>
|
||||
<q-item-section>
|
||||
<q-item-label>Restore Stall</q-item-label>
|
||||
<q-item-label caption
|
||||
>Restore existing stall from Nostr</q-item-label
|
||||
>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-btn-dropdown>
|
||||
<div class="row items-center q-mb-md q-col-gutter-sm justify-end">
|
||||
<div class="col-auto">
|
||||
<q-input
|
||||
borderless
|
||||
dense
|
||||
debounce="300"
|
||||
v-model="filter"
|
||||
placeholder="Search"
|
||||
class="float-right"
|
||||
placeholder="Search by name, currency..."
|
||||
style="min-width: 250px"
|
||||
>
|
||||
<template v-slot:append>
|
||||
<template v-slot:prepend>
|
||||
<q-icon name="search"></q-icon>
|
||||
</template>
|
||||
</q-input>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<q-btn
|
||||
@click="openSelectPendingStallDialog"
|
||||
outline
|
||||
color="primary"
|
||||
icon="restore"
|
||||
label="Restore Stall"
|
||||
class="q-px-md"
|
||||
></q-btn>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<q-btn
|
||||
@click="openCreateStallDialog()"
|
||||
unelevated
|
||||
color="primary"
|
||||
icon="add"
|
||||
label="New Stall"
|
||||
class="q-px-md"
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-table
|
||||
|
|
@ -51,164 +46,242 @@
|
|||
>
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props">
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
size="sm"
|
||||
color="primary"
|
||||
round
|
||||
dense
|
||||
@click="props.row.expanded= !props.row.expanded"
|
||||
:icon="props.row.expanded? 'remove' : 'add'"
|
||||
/>
|
||||
<q-td key="name" :props="props">
|
||||
<span v-text="shortLabel(props.row.name)"></span>
|
||||
</q-td>
|
||||
|
||||
<q-td key="id" :props="props"
|
||||
><span v-text="shortLabel(props.row.name)"></span
|
||||
></q-td>
|
||||
<q-td key="currency" :props="props"
|
||||
><span v-text="props.row.currency"></span>
|
||||
<q-td key="currency" :props="props">
|
||||
<span v-text="props.row.currency"></span>
|
||||
</q-td>
|
||||
<q-td key="description" :props="props">
|
||||
<span v-text="shortLabel(props.row.config.description)"></span>
|
||||
</q-td>
|
||||
<q-td key="shippingZones" :props="props">
|
||||
<div>
|
||||
<span
|
||||
v-text="shortLabel(props.row.shipping_zones.filter(z => !!z.name).map(z => z.name).join(', '))"
|
||||
></span>
|
||||
</div>
|
||||
<span
|
||||
v-text="shortLabel(props.row.shipping_zones.filter(z => !!z.name).map(z => z.name).join(', '))"
|
||||
></span>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
<q-tr v-if="props.row.expanded" :props="props">
|
||||
<q-td colspan="100%">
|
||||
<div class="row items-center q-mb-lg">
|
||||
<div class="col-12">
|
||||
<stall-details
|
||||
:stall-id="props.row.id"
|
||||
:adminkey="adminkey"
|
||||
:inkey="inkey"
|
||||
:wallet-options="walletOptions"
|
||||
:zone-options="zoneOptions"
|
||||
:currencies="currencies"
|
||||
@stall-deleted="handleStallDeleted"
|
||||
@stall-updated="handleStallUpdated"
|
||||
@customer-selected-for-order="customerSelectedForOrder"
|
||||
></stall-details>
|
||||
</div>
|
||||
</div>
|
||||
<q-td key="actions" :props="props">
|
||||
<q-btn
|
||||
size="sm"
|
||||
color="primary"
|
||||
dense
|
||||
flat
|
||||
icon="edit"
|
||||
@click="openEditStallDialog(props.row)"
|
||||
>
|
||||
<q-tooltip>Edit stall</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
size="sm"
|
||||
color="secondary"
|
||||
dense
|
||||
flat
|
||||
icon="inventory_2"
|
||||
@click="goToProducts(props.row)"
|
||||
>
|
||||
<q-tooltip>View products</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
size="sm"
|
||||
color="accent"
|
||||
dense
|
||||
flat
|
||||
icon="receipt"
|
||||
@click="goToOrders(props.row)"
|
||||
>
|
||||
<q-tooltip>View orders</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
size="sm"
|
||||
color="negative"
|
||||
dense
|
||||
flat
|
||||
icon="delete"
|
||||
@click="confirmDeleteStall(props.row)"
|
||||
>
|
||||
<q-tooltip>Delete stall</q-tooltip>
|
||||
</q-btn>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
</q-table>
|
||||
|
||||
<div>
|
||||
<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>
|
||||
<!-- 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>
|
||||
<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>
|
||||
<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-card>
|
||||
</q-dialog>
|
||||
</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,83 +1,80 @@
|
|||
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
||||
%} {% block page %}
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-7 q-gutter-y-md">
|
||||
<div class="col-12 col-md-7 col-lg-8 q-gutter-y-md">
|
||||
<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-section>
|
||||
<div class="row items-center q-col-gutter-sm">
|
||||
<div class="col-12 col-sm-auto">
|
||||
<merchant-details
|
||||
:merchant-id="merchant.id"
|
||||
:inkey="g.user.wallets[0].inkey"
|
||||
:adminkey="g.user.wallets[0].adminkey"
|
||||
:show-keys="showKeys"
|
||||
@toggle-show-keys="toggleShowKeys"
|
||||
@merchant-deleted="handleMerchantDeleted"
|
||||
></merchant-details>
|
||||
</div>
|
||||
<div class="col-12 col-sm-auto q-mx-sm">
|
||||
<div class="row items-center no-wrap">
|
||||
<q-toggle
|
||||
@update:model-value="toggleMerchantState()"
|
||||
size="md"
|
||||
checked-icon="check"
|
||||
v-model="merchant.config.active"
|
||||
color="primary"
|
||||
unchecked-icon="clear"
|
||||
/>
|
||||
<span
|
||||
class="q-ml-sm"
|
||||
v-text="merchant.config.active ? 'Accepting Orders': 'Orders Paused'"
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-sm-auto q-ml-sm-auto">
|
||||
<shipping-zones
|
||||
:inkey="g.user.wallets[0].inkey"
|
||||
:adminkey="g.user.wallets[0].adminkey"
|
||||
></shipping-zones>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-card-section v-if="showKeys">
|
||||
<div class="row q-mb-md">
|
||||
<div class="col">
|
||||
<q-btn
|
||||
unelevated
|
||||
color="grey"
|
||||
outline
|
||||
@click="showKeys = false"
|
||||
class="float-left"
|
||||
>Hide Keys</q-btn
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row items-center no-wrap">
|
||||
<q-tabs
|
||||
v-model="activeTab"
|
||||
class="text-grey col"
|
||||
active-color="primary"
|
||||
indicator-color="primary"
|
||||
align="left"
|
||||
>
|
||||
<q-tab
|
||||
name="orders"
|
||||
label="Orders"
|
||||
icon="receipt_long"
|
||||
style="min-width: 120px"
|
||||
></q-tab>
|
||||
<q-tab
|
||||
name="merchant"
|
||||
label="Merchant"
|
||||
icon="person"
|
||||
style="min-width: 120px"
|
||||
></q-tab>
|
||||
<q-tab
|
||||
name="shipping"
|
||||
label="Shipping"
|
||||
icon="local_shipping"
|
||||
style="min-width: 120px"
|
||||
></q-tab>
|
||||
<q-tab
|
||||
name="stalls"
|
||||
label="Stalls"
|
||||
icon="store"
|
||||
style="min-width: 120px"
|
||||
></q-tab>
|
||||
<q-tab
|
||||
name="products"
|
||||
label="Products"
|
||||
icon="inventory_2"
|
||||
style="min-width: 120px"
|
||||
></q-tab>
|
||||
</q-tabs>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<key-pair
|
||||
:public-key="merchant.public_key"
|
||||
:private-key="merchant.private_key"
|
||||
></key-pair>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
<q-card class="q-mt-lg">
|
||||
<q-card-section>
|
||||
<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">
|
||||
<q-separator></q-separator>
|
||||
|
||||
<q-tab-panels v-model="activeTab" animated>
|
||||
<!-- Orders Tab -->
|
||||
<q-tab-panel name="orders">
|
||||
<q-card-section>
|
||||
<div class="text-h6">Orders</div>
|
||||
</q-card-section>
|
||||
<q-separator></q-separator>
|
||||
<q-card-section class="q-pt-none">
|
||||
<order-list
|
||||
ref="orderListRef"
|
||||
:adminkey="g.user.wallets[0].adminkey"
|
||||
|
|
@ -85,95 +82,244 @@
|
|||
:customer-pubkey-filter="orderPubkey"
|
||||
@customer-selected="customerSelectedForOrder"
|
||||
></order-list>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card-section>
|
||||
</q-tab-panel>
|
||||
|
||||
<!-- 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>
|
||||
</div>
|
||||
<q-card v-else>
|
||||
<q-card-section>
|
||||
<span class="text-h4">Welcome to Nostr Market!</span><br />
|
||||
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 class="text-center q-pa-xl">
|
||||
<q-spinner color="primary" size="3em" class="q-mb-md"></q-spinner>
|
||||
<div class="text-h6">Setting up Nostr Market...</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-5 q-gutter-y-md">
|
||||
<div class="col-12 col-md-5 col-lg-4 q-gutter-y-md">
|
||||
<div v-if="g.user.admin" class="col-12 q-mb-lg">
|
||||
<q-card>
|
||||
<q-card-section class="q-pa-md">
|
||||
<q-btn
|
||||
label="Restart Nostr Connection"
|
||||
color="grey"
|
||||
outline
|
||||
@click="restartNostrConnection"
|
||||
>
|
||||
<q-tooltip>
|
||||
Restart the connection to the nostrclient extension
|
||||
</q-tooltip>
|
||||
</q-btn>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<h6 class="text-subtitle1 q-my-none">
|
||||
{{SITE_TITLE}} Nostr Market Extension
|
||||
</h6>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pa-none">
|
||||
<q-separator></q-separator>
|
||||
<q-list> {% include "nostrmarket/_api_docs.html" %} </q-list>
|
||||
<div class="row items-center no-wrap q-col-gutter-sm">
|
||||
<div class="col">
|
||||
<q-btn-dropdown
|
||||
:color="nostrStatusColor"
|
||||
:label="nostrStatusLabel"
|
||||
icon="sync"
|
||||
split
|
||||
@click="restartNostrConnection"
|
||||
>
|
||||
<q-list>
|
||||
<q-item
|
||||
clickable
|
||||
v-close-popup
|
||||
@click="restartNostrConnection"
|
||||
>
|
||||
<q-item-section avatar>
|
||||
<q-icon name="refresh" color="primary"></q-icon>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>Restart Connection</q-item-label>
|
||||
<q-item-label caption>
|
||||
Reconnect to the nostrclient extension
|
||||
</q-item-label>
|
||||
</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>
|
||||
</div>
|
||||
|
|
@ -189,34 +335,36 @@
|
|||
>
|
||||
</direct-messages>
|
||||
</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>
|
||||
<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>
|
||||
{% endblock%}{% block scripts %} {{ window_vars(user) }}
|
||||
|
||||
|
|
@ -234,10 +382,23 @@
|
|||
margin-left: auto;
|
||||
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>
|
||||
|
||||
<template id="key-pair"
|
||||
>{% include("nostrmarket/components/key-pair.html") %}</template
|
||||
<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
|
||||
|
|
@ -257,17 +418,30 @@
|
|||
<template id="merchant-details"
|
||||
>{% include("nostrmarket/components/merchant-details.html") %}</template
|
||||
>
|
||||
<template id="merchant-tab"
|
||||
>{% include("nostrmarket/components/merchant-tab.html") %}</template
|
||||
>
|
||||
<template id="shipping-zones-list"
|
||||
>{% include("nostrmarket/components/shipping-zones-list.html") %}</template
|
||||
>
|
||||
<template id="product-list"
|
||||
>{% include("nostrmarket/components/product-list.html") %}</template
|
||||
>
|
||||
|
||||
<script src="{{ url_for('nostrmarket_static', path='js/nostr.bundle.js') }}"></script>
|
||||
<script src="{{ url_for('nostrmarket_static', path='js/utils.js') }}"></script>
|
||||
<script src="{{ url_for('nostrmarket_static', path='js/index.js') }}"></script>
|
||||
|
||||
<script src="{{ static_url_for('nostrmarket/static', 'components/key-pair.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 %}
|
||||
|
|
|
|||
27
tests/conftest.py
Normal file
27
tests/conftest.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
"""
|
||||
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
|
||||
139
tests/test_nip44.py
Normal file
139
tests/test_nip44.py
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
"""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))
|
||||
258
tests/test_nip59.py
Normal file
258
tests/test_nip59.py
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
"""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
|
||||
261
views_api.py
261
views_api.py
|
|
@ -1,11 +1,13 @@
|
|||
import json
|
||||
from http import HTTPStatus
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import Depends
|
||||
from fastapi.exceptions import HTTPException
|
||||
from lnbits.core.models import WalletTypeInfo
|
||||
from lnbits.core.crud import get_account
|
||||
from lnbits.core.services import websocket_updater
|
||||
from lnbits.decorators import (
|
||||
WalletTypeInfo,
|
||||
require_admin_key,
|
||||
require_invoice_key,
|
||||
)
|
||||
|
|
@ -36,6 +38,7 @@ from .crud import (
|
|||
get_last_direct_messages_time,
|
||||
get_merchant_by_pubkey,
|
||||
get_merchant_for_user,
|
||||
update_merchant_pubkey,
|
||||
get_order,
|
||||
get_order_by_event_id,
|
||||
get_orders,
|
||||
|
|
@ -58,10 +61,12 @@ from .crud import (
|
|||
)
|
||||
from .helpers import normalize_public_key
|
||||
from .models import (
|
||||
CreateMerchantRequest,
|
||||
Customer,
|
||||
DirectMessage,
|
||||
DirectMessageType,
|
||||
Merchant,
|
||||
MerchantConfig,
|
||||
Order,
|
||||
OrderReissue,
|
||||
OrderStatusUpdate,
|
||||
|
|
@ -77,8 +82,10 @@ from .models import (
|
|||
from .services import (
|
||||
build_order_with_payment,
|
||||
create_or_update_order_from_dm,
|
||||
provision_merchant,
|
||||
reply_to_structured_dm,
|
||||
resubscribe_to_all_merchants,
|
||||
send_dm,
|
||||
sign_and_send_to_nostr,
|
||||
subscribe_to_all_merchants,
|
||||
update_merchant_to_nostr,
|
||||
|
|
@ -87,37 +94,55 @@ from .services import (
|
|||
######################################## 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")
|
||||
async def api_create_merchant(
|
||||
data: PartialMerchant,
|
||||
data: CreateMerchantRequest,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
) -> Merchant:
|
||||
|
||||
try:
|
||||
merchant = await get_merchant_by_pubkey(data.public_key)
|
||||
assert merchant is None, "A merchant already uses this public key"
|
||||
|
||||
merchant = await get_merchant_for_user(wallet.wallet.user)
|
||||
assert merchant is None, "A merchant already exists for this user"
|
||||
|
||||
merchant = await create_merchant(wallet.wallet.user, data)
|
||||
|
||||
await create_zone(
|
||||
merchant.id,
|
||||
Zone(
|
||||
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
|
||||
return await _auto_create_merchant(wallet, data.config)
|
||||
except AssertionError as ex:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
|
|
@ -134,12 +159,13 @@ async def api_create_merchant(
|
|||
@nostrmarket_ext.get("/api/v1/merchant")
|
||||
async def api_get_merchant(
|
||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
||||
) -> Merchant | None:
|
||||
) -> Merchant:
|
||||
|
||||
try:
|
||||
merchant = await get_merchant_for_user(wallet.wallet.user)
|
||||
if not merchant:
|
||||
return None
|
||||
# Auto-provision merchant from the user's account keypair
|
||||
merchant = await _auto_create_merchant(wallet)
|
||||
|
||||
merchant = await touch_merchant(wallet.wallet.user, merchant.id)
|
||||
assert merchant
|
||||
|
|
@ -147,6 +173,11 @@ async def api_get_merchant(
|
|||
assert merchant.time
|
||||
merchant.config.restore_in_progress = (merchant.time - last_dm_time) < 30
|
||||
|
||||
# Detect keypair rotation: account key no longer matches merchant key
|
||||
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
|
||||
except Exception as ex:
|
||||
logger.warning(ex)
|
||||
|
|
@ -192,6 +223,104 @@ async def api_delete_merchant(
|
|||
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")
|
||||
async def api_republish_merchant(
|
||||
merchant_id: str,
|
||||
|
|
@ -302,7 +431,7 @@ async def api_delete_merchant_on_nostr(
|
|||
@nostrmarket_ext.get("/api/v1/zone")
|
||||
async def api_get_zones(
|
||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
||||
) -> list[Zone]:
|
||||
) -> List[Zone]:
|
||||
try:
|
||||
merchant = await get_merchant_for_user(wallet.wallet.user)
|
||||
assert merchant, "Merchant cannot be found"
|
||||
|
|
@ -502,7 +631,7 @@ async def api_get_stall(
|
|||
|
||||
@nostrmarket_ext.get("/api/v1/stall")
|
||||
async def api_get_stalls(
|
||||
pending: bool | None = False,
|
||||
pending: Optional[bool] = False,
|
||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
||||
):
|
||||
try:
|
||||
|
|
@ -526,7 +655,7 @@ async def api_get_stalls(
|
|||
@nostrmarket_ext.get("/api/v1/stall/product/{stall_id}")
|
||||
async def api_get_stall_products(
|
||||
stall_id: str,
|
||||
pending: bool | None = False,
|
||||
pending: Optional[bool] = False,
|
||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
||||
):
|
||||
try:
|
||||
|
|
@ -550,9 +679,9 @@ async def api_get_stall_products(
|
|||
@nostrmarket_ext.get("/api/v1/stall/order/{stall_id}")
|
||||
async def api_get_stall_orders(
|
||||
stall_id: str,
|
||||
paid: bool | None = None,
|
||||
shipped: bool | None = None,
|
||||
pubkey: str | None = None,
|
||||
paid: Optional[bool] = None,
|
||||
shipped: Optional[bool] = None,
|
||||
pubkey: Optional[str] = None,
|
||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
||||
):
|
||||
try:
|
||||
|
|
@ -625,6 +754,21 @@ async def api_create_product(
|
|||
assert stall, "Stall missing for product"
|
||||
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)
|
||||
|
||||
event = await sign_and_send_to_nostr(merchant, product)
|
||||
|
|
@ -686,7 +830,7 @@ async def api_update_product(
|
|||
async def api_get_product(
|
||||
product_id: str,
|
||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
||||
) -> Product | None:
|
||||
) -> Optional[Product]:
|
||||
try:
|
||||
merchant = await get_merchant_for_user(wallet.wallet.user)
|
||||
assert merchant, "Merchant cannot be found"
|
||||
|
|
@ -771,9 +915,9 @@ async def api_get_order(
|
|||
|
||||
@nostrmarket_ext.get("/api/v1/order")
|
||||
async def api_get_orders(
|
||||
paid: bool | None = None,
|
||||
shipped: bool | None = None,
|
||||
pubkey: str | None = None,
|
||||
paid: Optional[bool] = None,
|
||||
shipped: Optional[bool] = None,
|
||||
pubkey: Optional[str] = None,
|
||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
||||
):
|
||||
try:
|
||||
|
|
@ -817,27 +961,11 @@ async def api_update_order_status(
|
|||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
dm_event = merchant.build_dm_event(dm_content, order.public_key)
|
||||
|
||||
dm = PartialDirectMessage(
|
||||
event_id=dm_event.id,
|
||||
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(),
|
||||
}
|
||||
),
|
||||
await send_dm(
|
||||
merchant,
|
||||
order.public_key,
|
||||
DirectMessageType.ORDER_PAID_OR_SHIPPED.value,
|
||||
dm_content,
|
||||
)
|
||||
|
||||
return order
|
||||
|
|
@ -859,7 +987,7 @@ async def api_update_order_status(
|
|||
async def api_restore_order(
|
||||
event_id: str,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
) -> Order | None:
|
||||
) -> Optional[Order]:
|
||||
try:
|
||||
merchant = await get_merchant_for_user(wallet.wallet.user)
|
||||
assert merchant, "Merchant cannot be found"
|
||||
|
|
@ -986,7 +1114,7 @@ async def api_reissue_order_invoice(
|
|||
@nostrmarket_ext.get("/api/v1/message/{public_key}")
|
||||
async def api_get_messages(
|
||||
public_key: str, wallet: WalletTypeInfo = Depends(require_invoice_key)
|
||||
) -> list[DirectMessage]:
|
||||
) -> List[DirectMessage]:
|
||||
try:
|
||||
merchant = await get_merchant_for_user(wallet.wallet.user)
|
||||
assert merchant, "Merchant cannot be found"
|
||||
|
|
@ -1015,14 +1143,13 @@ async def api_create_message(
|
|||
merchant = await get_merchant_for_user(wallet.wallet.user)
|
||||
assert merchant, "Merchant cannot be found"
|
||||
|
||||
dm_event = merchant.build_dm_event(data.message, data.public_key)
|
||||
data.event_id = dm_event.id
|
||||
data.event_created_at = dm_event.created_at
|
||||
|
||||
dm = await create_direct_message(merchant.id, data)
|
||||
await nostr_client.publish_nostr_event(dm_event)
|
||||
|
||||
return dm
|
||||
dm_reply = await send_dm(
|
||||
merchant,
|
||||
data.public_key,
|
||||
data.type,
|
||||
data.message,
|
||||
)
|
||||
return dm_reply
|
||||
except AssertionError as ex:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
|
|
@ -1042,7 +1169,7 @@ async def api_create_message(
|
|||
@nostrmarket_ext.get("/api/v1/customer")
|
||||
async def api_get_customers(
|
||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
||||
) -> list[Customer]:
|
||||
) -> List[Customer]:
|
||||
try:
|
||||
merchant = await get_merchant_for_user(wallet.wallet.user)
|
||||
assert merchant, "Merchant cannot be found"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue