This commit is contained in:
Vlad Stan 2024-11-06 11:50:21 +02:00 committed by GitHub
parent 83c94e94db
commit 0fc26d096f
52 changed files with 6684 additions and 3120 deletions

1
.gitignore vendored
View file

@ -16,6 +16,7 @@ __pycache__
htmlcov
test-reports
tests/data/*.sqlite3
node_modules
*.swo
*.swp

14
.prettierignore Normal file
View file

@ -0,0 +1,14 @@
**/.git
**/.svn
**/.hg
**/node_modules
*.yml
**/static/market/*
**/static/js/nostr.bundle.js*
flake.lock
.venv

12
.prettierrc Normal file
View file

@ -0,0 +1,12 @@
{
"semi": false,
"arrowParens": "avoid",
"insertPragma": false,
"printWidth": 80,
"proseWrap": "preserve",
"singleQuote": true,
"trailingComma": "none",
"useTabs": false,
"bracketSameLine": false,
"bracketSpacing": false
}

47
Makefile Normal file
View file

@ -0,0 +1,47 @@
all: format check
format: prettier black ruff
check: mypy pyright checkblack checkruff checkprettier
prettier:
poetry run ./node_modules/.bin/prettier --write .
pyright:
poetry run ./node_modules/.bin/pyright
mypy:
poetry run mypy .
black:
poetry run black .
ruff:
poetry run ruff check . --fix
checkruff:
poetry run ruff check .
checkprettier:
poetry run ./node_modules/.bin/prettier --check .
checkblack:
poetry run black --check .
checkeditorconfig:
editorconfig-checker
test:
PYTHONUNBUFFERED=1 \
DEBUG=true \
poetry run pytest
install-pre-commit-hook:
@echo "Installing pre-commit hook to git"
@echo "Uninstall the hook with poetry run pre-commit uninstall"
poetry run pre-commit install
pre-commit:
poetry run pre-commit run --all-files
checkbundle:
@echo "skipping checkbundle"

View file

@ -1,6 +1,6 @@
# Nostr Market ([NIP-15](https://github.com/nostr-protocol/nips/blob/master/15.md)) - <small>[LNbits](https://github.com/lnbits/lnbits) extension</small>
<small>For more about LNBits extension check [this tutorial](https://github.com/lnbits/lnbits/wiki/LNbits-Extensions).</small>
<small>For more about LNBits extension check [this tutorial](https://github.com/lnbits/lnbits/wiki/LNbits-Extensions).</small>
**Demo at Nostrica <a href="https://www.youtube.com/live/2NueacYJovA?feature=share&t=6846">here</a>**.
@ -8,15 +8,15 @@
> The concepts around resilience in Diagon Alley helped influence the creation of the NOSTR protocol, now we get to build Diagon Alley on NOSTR!
## Prerequisites
This extension uses the LNbits [nostrclient](https://github.com/lnbits/nostrclient) extension, an extension that makes _nostrfying_ other extensions easy.
![image](https://user-images.githubusercontent.com/2951406/236773044-81d3f30b-1ce7-4c5d-bdaf-b4a80ddddc58.png)
- before you continue, please make sure that [nostrclient](https://github.com/lnbits/nostrclient) extension is installed, activated and correctly configured.
- [nostrclient](https://github.com/lnbits/nostrclient) is usually installed as admin-only extension, so if you do not have admin access please ask an admin to confirm that [nostrclient](https://github.com/lnbits/nostrclient) is OK.
- see the [Troubleshoot](https://github.com/lnbits/nostrclient#troubleshoot) section for more details on how to check the health of `nostrclient` extension
## Create, or import, a merchant account
As a merchant you need to provide a Nostr key pair, or the extension can generate one for you.
@ -97,35 +97,39 @@ Make sure to add your `merchant` public key to the list:
![image](https://user-images.githubusercontent.com/2951406/236787686-0e300c0a-eb5d-4490-aa70-568738ac78f4.png)
### Styling
In order to create a customized Marketplace, we use `naddr` as defined in [NIP-19](https://github.com/nostr-protocol/nips/blob/master/19.md#shareable-identifiers-with-extra-metadata). You must create an event (kind: `30019`) that has all the custom properties, including merchants and relays, of your marketplace. Start by going to the marketplace page:
![vanilla market](https://i.imgur.com/nCaMh1N.png)
You'll need to Login, and head over to *Marketplace Info*. Optionally import some merchants and relays, that will be included in the event. Click on *Edit* and fill out your marketplace custom info:
You'll need to Login, and head over to _Marketplace Info_. Optionally import some merchants and relays, that will be included in the event. Click on _Edit_ and fill out your marketplace custom info:
![edit](https://i.imgur.com/wEuHuN9.png)
Fill in the optional fields:
- Add a name to the Marketplace
- Add a small description
- Add a logo image URL
- Add a banner image URL (max height: 250px)
- Choose a theme
- Choose a theme
By clicking *Publish*, a `kind: 30019` event will be sent to the defined relays containing all the information about your custom Marketplace. On the left drawer, a button with *Copy Naddr* will show up.
By clicking _Publish_, a `kind: 30019` event will be sent to the defined relays containing all the information about your custom Marketplace. On the left drawer, a button with _Copy Naddr_ will show up.
![copy naddr](https://i.imgur.com/VuNIMVf.png)
You can then share your Marketplace, with the merchants and relays, banner, and style by using that Nostr identifier. The URL for the marketplace will be for example: `https://legend.lnbits.com/nostrmarket/market?naddr=naddr1qqfy6ctjddjhgurvv93k....`, you need to include the URL parameter `naddr=<your naddr>`. When a user visits that URL, the client will get the `30019` event and configure the Marketplace to what you defined. In the example bellow, a couple of merchants, relays, `autumn` theme, name (*Veggies Market*) and a header banner:
You can then share your Marketplace, with the merchants and relays, banner, and style by using that Nostr identifier. The URL for the marketplace will be for example: `https://legend.lnbits.com/nostrmarket/market?naddr=naddr1qqfy6ctjddjhgurvv93k....`, you need to include the URL parameter `naddr=<your naddr>`. When a user visits that URL, the client will get the `30019` event and configure the Marketplace to what you defined. In the example bellow, a couple of merchants, relays, `autumn` theme, name (_Veggies Market_) and a header banner:
![final](https://i.imgur.com/EYG7vYS.png)
The nostr event is a replaceable event, so you can change it to what you like and publish a new one to replace a previous one. For example adding a new merchant, or remove, change theme, add more relays,e tc...
## Troubleshoot
### Check communication with Nostr
In order to test that the integration with Nostr is working fine, one can add an `npub` to the chat box and check that DMs are working as expected:
https://user-images.githubusercontent.com/2951406/236777983-259f81d8-136f-48b3-bb73-80749819b5f9.mov
### Restart connection to Nostr
If the communication with Nostr is not working then an admin user can `Restart` the Nostr connection.
Merchants can afterwards re-publish their products.
@ -133,8 +137,8 @@ Merchants can afterwards re-publish their products.
https://user-images.githubusercontent.com/2951406/236778651-7ada9f6d-07a1-491c-ac9c-55530326c32a.mp4
### Check Nostrclient extension
- see the [Troubleshoot](https://github.com/lnbits/nostrclient#troubleshoot) section for more details on how to check the health of `nostrclient` extension
- see the [Troubleshoot](https://github.com/lnbits/nostrclient#troubleshoot) section for more details on how to check the health of `nostrclient` extension
## Aditional info

View file

@ -1,11 +1,10 @@
import asyncio
from fastapi import APIRouter
from loguru import logger
from lnbits.db import Database
from lnbits.helpers import template_renderer
from lnbits.tasks import create_permanent_unique_task
from loguru import logger
from .nostr.nostr_client import NostrClient
@ -24,14 +23,14 @@ nostrmarket_static_files = [
def nostrmarket_renderer():
return template_renderer(["nostrmarket/templates"])
nostr_client: NostrClient = NostrClient()
from .tasks import wait_for_nostr_events, wait_for_paid_invoices
from .tasks import wait_for_nostr_events, wait_for_paid_invoices # noqa
from .views import * # noqa
from .views_api import * # noqa
scheduled_tasks: list[asyncio.Task] = []
@ -57,7 +56,13 @@ def nostrmarket_start():
await asyncio.sleep(15)
await wait_for_nostr_events(nostr_client)
task1 = create_permanent_unique_task("ext_nostrmarket_paid_invoices", wait_for_paid_invoices)
task2 = create_permanent_unique_task("ext_nostrmarket_subscribe_to_nostr_client", _subscribe_to_nostr_client)
task3 = create_permanent_unique_task("ext_nostrmarket_wait_for_events", _wait_for_nostr_events)
task1 = create_permanent_unique_task(
"ext_nostrmarket_paid_invoices", wait_for_paid_invoices
)
task2 = create_permanent_unique_task(
"ext_nostrmarket_subscribe_to_nostr_client", _subscribe_to_nostr_client
)
task3 = create_permanent_unique_task(
"ext_nostrmarket_wait_for_events", _wait_for_nostr_events
)
scheduled_tasks.extend([task1, task2, task3])

View file

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

832
crud.py

File diff suppressed because it is too large Load diff

View file

@ -5,8 +5,8 @@ Buy and sell things over Nostr, using NIP15 https://github.com/nostr-protocol/ni
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.
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.
- 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.

View file

@ -1,7 +1,6 @@
import base64
import json
import secrets
from typing import Any, Optional, Tuple
from typing import Optional
import secp256k1
from bech32 import bech32_decode, convertbits
@ -44,12 +43,14 @@ def encrypt_message(message: str, encryption_key, iv: Optional[bytes] = None) ->
encryptor = cipher.encryptor()
encrypted_message = encryptor.update(padded_data) + encryptor.finalize()
return f"{base64.b64encode(encrypted_message).decode()}?iv={base64.b64encode(iv).decode()}"
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:
def sign_message_hash(private_key: str, hash_: bytes) -> str:
privkey = secp256k1.PrivateKey(bytes.fromhex(private_key))
sig = privkey.schnorr_sign(hash, None, raw=True)
sig = privkey.schnorr_sign(hash_, None, raw=True)
return sig.hex()

View file

@ -1,5 +1,4 @@
async def m001_initial(db):
"""
Initial merchants table.
"""
@ -121,7 +120,10 @@ async def m001_initial(db):
Create indexes for message fetching
"""
await db.execute(
"CREATE INDEX idx_messages_timestamp ON nostrmarket.direct_messages (time DESC)"
"""
CREATE INDEX idx_messages_timestamp
ON nostrmarket.direct_messages (time DESC)
"""
)
await db.execute(
"CREATE INDEX idx_event_id ON nostrmarket.direct_messages (event_id)"
@ -142,23 +144,26 @@ async def m001_initial(db):
"""
)
async def m002_update_stall_and_product(db):
await db.execute(
"ALTER TABLE nostrmarket.stalls ADD COLUMN pending BOOLEAN NOT NULL DEFAULT false;"
)
await db.execute(
"ALTER TABLE nostrmarket.stalls ADD COLUMN event_id TEXT;"
"""
ALTER TABLE nostrmarket.stalls
ADD COLUMN pending BOOLEAN NOT NULL DEFAULT false;
"""
)
await db.execute("ALTER TABLE nostrmarket.stalls ADD COLUMN event_id TEXT;")
await db.execute(
"ALTER TABLE nostrmarket.stalls ADD COLUMN event_created_at INTEGER;"
)
await db.execute(
"ALTER TABLE nostrmarket.products ADD COLUMN pending BOOLEAN NOT NULL DEFAULT false;"
)
await db.execute(
"ALTER TABLE nostrmarket.products ADD COLUMN event_id TEXT;"
"""
ALTER TABLE nostrmarket.products
ADD COLUMN pending BOOLEAN NOT NULL DEFAULT false;
"""
)
await db.execute("ALTER TABLE nostrmarket.products ADD COLUMN event_id TEXT;")
await db.execute(
"ALTER TABLE nostrmarket.products ADD COLUMN event_created_at INTEGER;"
)
@ -166,15 +171,21 @@ async def m002_update_stall_and_product(db):
async def m003_update_direct_message_type(db):
await db.execute(
"ALTER TABLE nostrmarket.direct_messages ADD COLUMN type INTEGER NOT NULL DEFAULT -1;"
"""
ALTER TABLE nostrmarket.direct_messages
ADD COLUMN type INTEGER NOT NULL DEFAULT -1;
"""
)
async def m004_add_merchant_timestamp(db):
await db.execute(
f"ALTER TABLE nostrmarket.merchants ADD COLUMN time TIMESTAMP;"
)
await db.execute("ALTER TABLE nostrmarket.merchants ADD COLUMN time TIMESTAMP;")
async def m005_update_product_activation(db):
await db.execute(
"ALTER TABLE nostrmarket.products ADD COLUMN active BOOLEAN NOT NULL DEFAULT true;"
)
"""
ALTER TABLE nostrmarket.products
ADD COLUMN active BOOLEAN NOT NULL DEFAULT true;
"""
)

197
models.py
View file

@ -2,12 +2,10 @@ import json
import time
from abc import abstractmethod
from enum import Enum
from sqlite3 import Row
from typing import Any, List, Optional, Tuple
from pydantic import BaseModel
from lnbits.utils.exchange_rates import btc_price, fiat_amount_as_satoshis
from pydantic import BaseModel
from .helpers import (
decrypt_message,
@ -30,17 +28,17 @@ class Nostrable:
pass
######################################## MERCHANT ########################################
######################################## MERCHANT ######################################
class MerchantProfile(BaseModel):
name: Optional[str]
about: Optional[str]
picture: Optional[str]
name: Optional[str] = None
about: Optional[str] = None
picture: Optional[str] = None
class MerchantConfig(MerchantProfile):
event_id: Optional[str]
event_id: Optional[str] = None
sync_from_nostr = False
active: bool = False
restore_in_progress: Optional[bool] = False
@ -56,8 +54,8 @@ class Merchant(PartialMerchant, Nostrable):
id: str
time: Optional[int] = 0
def sign_hash(self, hash: bytes) -> str:
return sign_message_hash(self.private_key, hash)
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)
@ -82,8 +80,8 @@ class Merchant(PartialMerchant, Nostrable):
return event
@classmethod
def from_row(cls, row: Row) -> "Merchant":
merchant = cls(**dict(row))
def from_row(cls, row: dict) -> "Merchant":
merchant = cls(**row)
merchant.config = MerchantConfig(**json.loads(row["meta"]))
return merchant
@ -123,20 +121,16 @@ class Merchant(PartialMerchant, Nostrable):
######################################## ZONES ########################################
class PartialZone(BaseModel):
id: Optional[str]
name: Optional[str]
class Zone(BaseModel):
id: Optional[str] = None
name: Optional[str] = None
currency: str
cost: float
countries: List[str] = []
class Zone(PartialZone):
id: str
@classmethod
def from_row(cls, row: Row) -> "Zone":
zone = cls(**dict(row))
def from_row(cls, row: dict) -> "Zone":
zone = cls(**row)
zone.countries = json.loads(row["regions"])
return zone
@ -145,12 +139,12 @@ class Zone(PartialZone):
class StallConfig(BaseModel):
image_url: Optional[str]
description: Optional[str]
image_url: Optional[str] = None
description: Optional[str] = None
class PartialStall(BaseModel):
id: Optional[str]
class Stall(BaseModel, Nostrable):
id: Optional[str] = None
wallet: str
name: str
currency: str = "sat"
@ -159,8 +153,8 @@ class PartialStall(BaseModel):
pending: bool = False
"""Last published nostr event for this Stall"""
event_id: Optional[str]
event_created_at: Optional[int]
event_id: Optional[str] = None
event_created_at: Optional[int] = None
def validate_stall(self):
for z in self.shipping_zones:
@ -169,10 +163,6 @@ class PartialStall(BaseModel):
f"Sipping zone '{z.name}' has different currency than stall."
)
class Stall(PartialStall, Nostrable):
id: str
def to_nostr_event(self, pubkey: str) -> NostrEvent:
content = {
"id": self.id,
@ -181,6 +171,7 @@ class Stall(PartialStall, Nostrable):
"currency": self.currency,
"shipping": [dict(z) for z in self.shipping_zones],
}
assert self.id
event = NostrEvent(
pubkey=pubkey,
created_at=round(time.time()),
@ -197,7 +188,7 @@ class Stall(PartialStall, Nostrable):
pubkey=pubkey,
created_at=round(time.time()),
kind=5,
tags=[["e", self.event_id]],
tags=[["e", self.event_id or ""]],
content=f"Stall '{self.name}' deleted",
)
delete_event.id = delete_event.event_id
@ -205,14 +196,14 @@ class Stall(PartialStall, Nostrable):
return delete_event
@classmethod
def from_row(cls, row: Row) -> "Stall":
stall = cls(**dict(row))
def from_row(cls, row: dict) -> "Stall":
stall = cls(**row)
stall.config = StallConfig(**json.loads(row["meta"]))
stall.shipping_zones = [Zone(**z) for z in json.loads(row["zones"])]
return stall
######################################## PRODUCTS ########################################
######################################## PRODUCTS ######################################
class ProductShippingCost(BaseModel):
@ -221,15 +212,15 @@ class ProductShippingCost(BaseModel):
class ProductConfig(BaseModel):
description: Optional[str]
currency: Optional[str]
description: Optional[str] = None
currency: Optional[str] = None
use_autoreply: Optional[bool] = False
autoreply_message: Optional[str]
shipping: Optional[List[ProductShippingCost]] = []
autoreply_message: Optional[str] = None
shipping: List[ProductShippingCost] = []
class PartialProduct(BaseModel):
id: Optional[str]
class Product(BaseModel, Nostrable):
id: Optional[str] = None
stall_id: str
name: str
categories: List[str] = []
@ -241,12 +232,8 @@ class PartialProduct(BaseModel):
config: ProductConfig = ProductConfig()
"""Last published nostr event for this Product"""
event_id: Optional[str]
event_created_at: Optional[int]
class Product(PartialProduct, Nostrable):
id: str
event_id: Optional[str] = None
event_created_at: Optional[int] = None
def to_nostr_event(self, pubkey: str) -> NostrEvent:
content = {
@ -259,16 +246,17 @@ class Product(PartialProduct, Nostrable):
"price": self.price,
"quantity": self.quantity,
"active": self.active,
"shipping": [dict(s) for s in self.config.shipping or []]
"shipping": [dict(s) for s in self.config.shipping or []],
}
categories = [["t", tag] for tag in self.categories]
assert self.id
if self.active:
event = NostrEvent(
pubkey=pubkey,
created_at=round(time.time()),
kind=30018,
tags=[["d", self.id]] + categories,
tags=[["d", self.id], *categories],
content=json.dumps(content, separators=(",", ":"), ensure_ascii=False),
)
event.id = event.event_id
@ -282,7 +270,7 @@ class Product(PartialProduct, Nostrable):
pubkey=pubkey,
created_at=round(time.time()),
kind=5,
tags=[["e", self.event_id]],
tags=[["e", self.event_id or ""]],
content=f"Product '{self.name}' deleted",
)
delete_event.id = delete_event.event_id
@ -290,8 +278,8 @@ class Product(PartialProduct, Nostrable):
return delete_event
@classmethod
def from_row(cls, row: Row) -> "Product":
product = cls(**dict(row))
def from_row(cls, row: dict) -> "Product":
product = cls(**row)
product.config = ProductConfig(**json.loads(row["meta"]))
product.images = json.loads(row["image_urls"]) if "image_urls" in row else []
product.categories = json.loads(row["category_list"])
@ -302,6 +290,12 @@ class ProductOverview(BaseModel):
id: str
name: str
price: float
product_shipping_cost: Optional[float] = None
@classmethod
def from_product(cls, p: Product) -> "ProductOverview":
assert p.id
return ProductOverview(id=p.id, name=p.name, price=p.price)
######################################## ORDERS ########################################
@ -313,9 +307,9 @@ class OrderItem(BaseModel):
class OrderContact(BaseModel):
nostr: Optional[str]
phone: Optional[str]
email: Optional[str]
nostr: Optional[str] = None
phone: Optional[str] = None
email: Optional[str] = None
class OrderExtra(BaseModel):
@ -324,27 +318,33 @@ class OrderExtra(BaseModel):
btc_price: str
shipping_cost: float = 0
shipping_cost_sat: float = 0
fail_message: Optional[str]
fail_message: Optional[str] = None
@classmethod
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
await btc_price(currency) if currency and currency != "sat" else 1
)
products_overview = [ProductOverview.from_product(p) for p in products]
return OrderExtra(
products=products_overview,
currency=currency or "sat",
btc_price=str(exchange_rate),
)
return OrderExtra(products=products, currency=currency, btc_price=exchange_rate)
class PartialOrder(BaseModel):
id: str
event_id: Optional[str]
event_created_at: Optional[int]
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: Optional[OrderContact]
address: Optional[str]
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}'"
@ -383,10 +383,11 @@ class PartialOrder(BaseModel):
}
product_cost: float = 0 # todo
currency = "sat"
for item in self.items:
assert item.quantity > 0, "Quantity cannot be negative"
price = product_prices[item.product_id]["price"]
currency = product_prices[item.product_id]["currency"]
price = float(str(product_prices[item.product_id]["price"]))
currency = str(product_prices[item.product_id]["currency"])
if currency != "sat":
price = await fiat_amount_as_satoshis(price, currency)
product_cost += item.quantity * price
@ -404,30 +405,39 @@ class PartialOrder(BaseModel):
if len(products) == 0:
return "[No Products]"
receipt = ""
product_prices = {}
product_prices: dict[str, ProductOverview] = {}
for p in products:
product_shipping_cost = next(
(s.cost for s in p.config.shipping if s.id == shipping_id), 0
)
product_prices[p.id] = {
"name": p.name,
"price": p.price,
"product_shipping_cost": product_shipping_cost
}
assert p.id
product_prices[p.id] = ProductOverview(
id=p.id,
name=p.name,
price=p.price,
product_shipping_cost=product_shipping_cost,
)
currency = products[0].config.currency or "sat"
products_cost: float = 0 # todo
items_receipts = []
for item in self.items:
prod = product_prices[item.product_id]
price = prod["price"] + prod["product_shipping_cost"]
price = prod.price + (prod.product_shipping_cost or 0)
products_cost += item.quantity * price
items_receipts.append(f"""[{prod["name"]}: {item.quantity} x ({prod["price"]} + {prod["product_shipping_cost"]}) = {item.quantity * price} {currency}] """)
items_receipts.append(
f"""[{prod.name}: {item.quantity} x ({prod.price}"""
f""" + {prod.product_shipping_cost})"""
f""" = {item.quantity * price} {currency}] """
)
receipt = "; ".join(items_receipts)
receipt += f"[Products cost: {products_cost} {currency}] [Stall shipping cost: {stall_shipping_cost} {currency}]; "
receipt += (
f"[Products cost: {products_cost} {currency}] "
f"[Stall shipping cost: {stall_shipping_cost} {currency}]; "
)
receipt += f"[Total: {products_cost + stall_shipping_cost} {currency}]"
return receipt
@ -439,23 +449,23 @@ class Order(PartialOrder):
total: float
paid: bool = False
shipped: bool = False
time: Optional[int]
time: Optional[int] = None
extra: OrderExtra
@classmethod
def from_row(cls, row: Row) -> "Order":
def from_row(cls, row: dict) -> "Order":
contact = OrderContact(**json.loads(row["contact_data"]))
extra = OrderExtra(**json.loads(row["extra_data"]))
items = [OrderItem(**z) for z in json.loads(row["order_items"])]
order = cls(**dict(row), contact=contact, items=items, extra=extra)
order = cls(**row, contact=contact, items=items, extra=extra)
return order
class OrderStatusUpdate(BaseModel):
id: str
message: Optional[str]
paid: Optional[bool]
shipped: Optional[bool]
message: Optional[str] = None
paid: Optional[bool] = False
shipped: Optional[bool] = None
class OrderReissue(BaseModel):
@ -470,11 +480,11 @@ class PaymentOption(BaseModel):
class PaymentRequest(BaseModel):
id: str
message: Optional[str]
message: Optional[str] = None
payment_options: List[PaymentOption]
######################################## MESSAGE ########################################
######################################## MESSAGE #######################################
class DirectMessageType(Enum):
@ -487,13 +497,13 @@ class DirectMessageType(Enum):
class PartialDirectMessage(BaseModel):
event_id: Optional[str]
event_created_at: Optional[int]
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: Optional[int]
time: Optional[int] = None
@classmethod
def parse_message(cls, msg) -> Tuple[DirectMessageType, Optional[Any]]:
@ -511,29 +521,28 @@ class DirectMessage(PartialDirectMessage):
id: str
@classmethod
def from_row(cls, row: Row) -> "DirectMessage":
dm = cls(**dict(row))
return dm
def from_row(cls, row: dict) -> "DirectMessage":
return cls(**row)
######################################## CUSTOMERS ########################################
######################################## CUSTOMERS #####################################
class CustomerProfile(BaseModel):
name: Optional[str]
about: Optional[str]
name: Optional[str] = None
about: Optional[str] = None
class Customer(BaseModel):
merchant_id: str
public_key: str
event_created_at: Optional[int]
profile: Optional[CustomerProfile]
event_created_at: Optional[int] = None
profile: Optional[CustomerProfile] = None
unread_messages: int = 0
@classmethod
def from_row(cls, row: Row) -> "Customer":
customer = cls(**dict(row))
def from_row(cls, row: dict) -> "Customer":
customer = cls(**row)
customer.profile = (
CustomerProfile(**json.loads(row["meta"])) if "meta" in row else None
)

View file

@ -13,7 +13,7 @@ class NostrEvent(BaseModel):
kind: int
tags: List[List[str]] = []
content: str = ""
sig: Optional[str]
sig: Optional[str] = None
def serialize(self) -> List:
return [0, self.pubkey, self.created_at, self.kind, self.tags, self.content]
@ -41,7 +41,7 @@ class NostrEvent(BaseModel):
f"Invalid public key: '{self.pubkey}' for event '{self.id}'"
)
valid_signature = pub_key.schnorr_verify(
valid_signature = self.sig and pub_key.schnorr_verify(
bytes.fromhex(event_id), bytes.fromhex(self.sig), None, raw=True
)
if not valid_signature:

View file

@ -2,12 +2,12 @@ import asyncio
import json
from asyncio import Queue
from threading import Thread
from typing import Callable, List
from typing import Callable, List, Optional
from loguru import logger
from websocket import WebSocketApp
from lnbits.app import settings
from lnbits.settings import settings
from lnbits.helpers import encrypt_internal_message, urlsafe_short_hash
from .event import NostrEvent
@ -17,7 +17,7 @@ class NostrClient:
def __init__(self):
self.recieve_event_queue: Queue = Queue()
self.send_req_queue: Queue = Queue()
self.ws: WebSocketApp = None
self.ws: Optional[WebSocketApp] = None
self.subscription_id = "nostrmarket-" + urlsafe_short_hash()[:32]
self.running = False
@ -30,7 +30,6 @@ class NostrClient:
async def connect_to_nostrclient_ws(self) -> WebSocketApp:
logger.debug(f"Connecting to websockets for 'nostrclient' extension...")
relay_endpoint = encrypt_internal_message("relay")
on_open, on_message, on_error, on_close = self._ws_handlers()
ws = WebSocketApp(
@ -57,19 +56,18 @@ class NostrClient:
await asyncio.sleep(5)
req = await self.send_req_queue.get()
assert self.ws
self.ws.send(json.dumps(req))
except Exception as ex:
logger.warning(ex)
await asyncio.sleep(60)
async def get_event(self):
value = await self.recieve_event_queue.get()
if isinstance(value, ValueError):
raise value
return value
async def publish_nostr_event(self, e: NostrEvent):
await self.send_req_queue.put(["EVENT", e.dict()])
@ -119,7 +117,7 @@ class NostrClient:
asyncio.create_task(unsubscribe_with_delay(subscription_id, duration))
async def user_profile_temp_subscribe(self, public_key: str, duration=5) -> List:
async def user_profile_temp_subscribe(self, public_key: str, duration=5):
try:
profile_filter = [{"kinds": [0], "authors": [public_key]}]
subscription_id = "profile-" + urlsafe_short_hash()[:32]

15
package.json Normal file
View file

@ -0,0 +1,15 @@
{
"name": "nostrmarket",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC",
"dependencies": {
"prettier": "^3.2.5",
"pyright": "^1.1.358"
}
}

2616
poetry.lock generated Normal file

File diff suppressed because it is too large Load diff

96
pyproject.toml Normal file
View file

@ -0,0 +1,96 @@
[tool.poetry]
name = "lnbits-nostrmarket"
version = "0.0.0"
description = "LNbits, free and open-source Lightning wallet and accounts system."
authors = ["Alan Bits <alan@lnbits.com>"]
[tool.poetry.dependencies]
python = "^3.10 | ^3.9"
lnbits = {version = "*", allow-prereleases = true}
[tool.poetry.group.dev.dependencies]
black = "^24.3.0"
pytest-asyncio = "^0.21.0"
pytest = "^7.3.2"
mypy = "^1.5.1"
pre-commit = "^3.2.2"
ruff = "^0.3.2"
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
[tool.mypy]
exclude = "(nostr/*)"
[[tool.mypy.overrides]]
module = [
"secp256k1.*",
"embit.*",
"lnbits.*",
"lnurl.*",
"loguru.*",
"fastapi.*",
"pydantic.*",
"pyqrcode.*",
"shortuuid.*",
"httpx.*",
]
ignore_missing_imports = "True"
[tool.pytest.ini_options]
log_cli = false
testpaths = [
"tests"
]
[tool.black]
line-length = 88
[tool.ruff]
# Same as Black. + 10% rule of black
line-length = 88
exclude = [
"nostr",
]
[tool.ruff.lint]
# Enable:
# F - pyflakes
# E - pycodestyle errors
# W - pycodestyle warnings
# I - isort
# A - flake8-builtins
# C - mccabe
# N - naming
# UP - pyupgrade
# RUF - ruff
# B - bugbear
select = ["F", "E", "W", "I", "A", "C", "N", "UP", "RUF", "B"]
ignore = ["C901"]
# Allow autofix for all enabled rules (when `--fix`) is provided.
fixable = ["ALL"]
unfixable = []
# Allow unused variables when underscore-prefixed.
dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
# needed for pydantic
[tool.ruff.lint.pep8-naming]
classmethod-decorators = [
"root_validator",
]
# Ignore unused imports in __init__.py files.
# [tool.ruff.lint.extend-per-file-ignores]
# "__init__.py" = ["F401", "F403"]
# [tool.ruff.lint.mccabe]
# max-complexity = 10
[tool.ruff.lint.flake8-bugbear]
# Allow default arguments like, e.g., `data: List[str] = fastapi.Query(None)`.
extend-immutable-calls = [
"fastapi.Depends",
"fastapi.Query",
]

View file

@ -2,10 +2,10 @@ import asyncio
import json
from typing import List, Optional, Tuple
from loguru import logger
from lnbits.bolt11 import decode
from lnbits.core.services import websocket_updater, create_invoice, get_wallet
from lnbits.core.crud import get_wallet
from lnbits.core.services import create_invoice, websocket_updater
from loguru import logger
from . import nostr_client
from .crud import (
@ -75,7 +75,9 @@ async def create_new_order(
await create_order(merchant.id, order)
return PaymentRequest(
id=data.id, payment_options=[PaymentOption(type="ln", link=invoice)], message=receipt
id=data.id,
payment_options=[PaymentOption(type="ln", link=invoice)],
message=receipt,
)
@ -89,12 +91,11 @@ async def build_order_with_payment(
shipping_zone = await get_zone(merchant_id, data.shipping_id)
assert shipping_zone, f"Shipping zone not found for order '{data.id}'"
assert shipping_zone.id
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
)
receipt = data.receipt(products, shipping_zone.id, shipping_zone.cost)
wallet_id = await get_wallet_for_product(data.items[0].product_id)
assert wallet_id, "Missing wallet for order `{data.id}`"
@ -106,7 +107,7 @@ async def build_order_with_payment(
if not success:
raise ValueError(message)
payment_hash, invoice = await create_invoice(
payment = await create_invoice(
wallet_id=wallet_id,
amount=round(product_cost_sat + shipping_cost_sat),
memo=f"Order '{data.id}' for pubkey '{data.public_key}'",
@ -124,19 +125,21 @@ async def build_order_with_payment(
order = Order(
**data.dict(),
stall_id=products[0].stall_id,
invoice_id=payment_hash,
invoice_id=payment.payment_hash,
total=product_cost_sat + shipping_cost_sat,
extra=extra,
)
return order, invoice, receipt
return order, payment.bolt11, receipt
async def update_merchant_to_nostr(
merchant: Merchant, delete_merchant=False
) -> Merchant:
stalls = await get_stalls(merchant.id)
event: Optional[NostrEvent] = None
for stall in stalls:
assert stall.id
products = await get_products(merchant.id, stall.id)
for product in products:
event = await sign_and_send_to_nostr(merchant, product, delete_merchant)
@ -150,6 +153,7 @@ async def update_merchant_to_nostr(
if delete_merchant:
# merchant profile updates not supported yet
event = await sign_and_send_to_nostr(merchant, merchant, delete_merchant)
assert event
merchant.config.event_id = event.id
return merchant
@ -227,25 +231,29 @@ async def update_products_for_order(
return success, message
for p in products:
assert p.id
product = await update_product_quantity(p.id, p.quantity)
assert product
event = await sign_and_send_to_nostr(merchant, product)
product.event_id = event.id
await update_product(merchant.id, product)
return True, "ok"
async def autoreply_for_products_in_order(
merchant: Merchant, order: Order
) -> Tuple[bool, str]:
async def autoreply_for_products_in_order(merchant: Merchant, order: Order):
product_ids = [i.product_id for i in order.items]
products = await get_products_by_ids(merchant.id, product_ids)
products_with_autoreply = [p for p in products if p.config.use_autoreply]
for p in products_with_autoreply:
dm_content = p.config.autoreply_message
dm_content = p.config.autoreply_message or ""
await send_dm(
merchant, order.public_key, DirectMessageType.PLAIN_TEXT.value, dm_content
merchant,
order.public_key,
DirectMessageType.PLAIN_TEXT.value,
dm_content,
)
await asyncio.sleep(1) # do not send all autoreplies at once
@ -253,7 +261,7 @@ async def autoreply_for_products_in_order(
async def send_dm(
merchant: Merchant,
other_pubkey: str,
type: str,
type_: int,
dm_content: str,
):
dm_event = merchant.build_dm_event(dm_content, other_pubkey)
@ -263,7 +271,7 @@ async def send_dm(
event_created_at=dm_event.created_at,
message=dm_content,
public_key=other_pubkey,
type=type,
type=type_,
)
dm_reply = await create_direct_message(merchant.id, dm)
@ -296,7 +304,8 @@ async def compute_products_new_quantity(
return (
False,
[],
f"Quantity not sufficient for product: '{p.name}' ({p.id}). Required '{required_quantity}' but only have '{p.quantity}'.",
f"Quantity not sufficient for product: '{p.name}' ({p.id})."
f" Required '{required_quantity}' but only have '{p.quantity}'.",
)
p.quantity -= required_quantity
@ -306,9 +315,9 @@ async def compute_products_new_quantity(
async def process_nostr_message(msg: str):
try:
type, *rest = json.loads(msg)
type_, *rest = json.loads(msg)
if type.upper() == "EVENT":
if type_.upper() == "EVENT":
_, event = rest
event = NostrEvent(**event)
if event.kind == 0:
@ -328,11 +337,11 @@ async def process_nostr_message(msg: str):
async def create_or_update_order_from_dm(
merchant_id: str, merchant_pubkey: str, dm: DirectMessage
):
type, json_data = PartialDirectMessage.parse_message(dm.message)
if "id" not in json_data:
type_, json_data = PartialDirectMessage.parse_message(dm.message)
if not json_data or "id" not in json_data:
return
if type == DirectMessageType.CUSTOMER_ORDER:
if type_ == DirectMessageType.CUSTOMER_ORDER:
order = await extract_customer_order_from_dm(
merchant_id, merchant_pubkey, dm, json_data
)
@ -348,7 +357,7 @@ async def create_or_update_order_from_dm(
)
return
if type == DirectMessageType.PAYMENT_REQUEST:
if type_ == DirectMessageType.PAYMENT_REQUEST:
payment_request = PaymentRequest(**json_data)
pr = next(
(o.link for o in payment_request.payment_options if o.type == "ln"), None
@ -356,14 +365,15 @@ async def create_or_update_order_from_dm(
if not pr:
return
invoice = decode(pr)
total = invoice.amount_msat / 1000 if invoice.amount_msat else 0
await update_order(
merchant_id,
payment_request.id,
**{"total": invoice.amount_msat / 1000, "invoice_id": invoice.payment_hash},
**{"total": total, "invoice_id": invoice.payment_hash},
)
return
if type == DirectMessageType.ORDER_PAID_OR_SHIPPED:
if type_ == DirectMessageType.ORDER_PAID_OR_SHIPPED:
order_update = OrderStatusUpdate(**json_data)
if order_update.paid:
await update_order_paid_status(order_update.id, True)
@ -380,16 +390,18 @@ async def extract_customer_order_from_dm(
)
extra = await OrderExtra.from_products(products)
order = Order(
id=json_data.get("id"),
id=str(json_data.get("id")),
event_id=dm.event_id,
event_created_at=dm.event_created_at,
public_key=dm.public_key,
merchant_public_key=merchant_pubkey,
shipping_id=json_data.get("shipping_id", "None"),
items=order_items,
contact=OrderContact(**json_data.get("contact"))
if json_data.get("contact")
else None,
contact=(
OrderContact(**json_data.get("contact", {}))
if json_data.get("contact")
else None
),
address=json_data.get("address"),
stall_id=products[0].stall_id if len(products) else "None",
invoice_id="None",
@ -406,12 +418,9 @@ async def _handle_nip04_message(event: NostrEvent):
if not merchant:
p_tags = event.tag_values("p")
merchant_public_key = p_tags[0] if len(p_tags) else None
merchant = (
await get_merchant_by_pubkey(merchant_public_key)
if merchant_public_key
else None
)
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}'"
@ -461,21 +470,21 @@ async def _handle_outgoing_dms(
event: NostrEvent, merchant: Merchant, clear_text_msg: str
):
sent_to = event.tag_values("p")
type, _ = PartialDirectMessage.parse_message(clear_text_msg)
type_, _ = PartialDirectMessage.parse_message(clear_text_msg)
if len(sent_to) != 0:
dm = PartialDirectMessage(
event_id=event.id,
event_created_at=event.created_at,
message=clear_text_msg,
public_key=sent_to[0],
type=type.value,
type=type_.value,
)
await create_direct_message(merchant.id, dm)
async def _handle_incoming_structured_dm(
merchant: Merchant, dm: DirectMessage, json_data: dict
) -> Tuple[DirectMessageType, str]:
) -> Tuple[DirectMessageType, Optional[str]]:
try:
if dm.type == DirectMessageType.CUSTOMER_ORDER.value and merchant.config.active:
json_resp = await _handle_new_order(
@ -487,7 +496,7 @@ async def _handle_incoming_structured_dm(
except Exception as ex:
logger.warning(ex)
return None, None
return DirectMessageType.PLAIN_TEXT, None
async def _persist_dm(
@ -570,9 +579,13 @@ async def _handle_new_order(
except Exception as e:
logger.debug(e)
payment_req = await create_new_failed_order(
merchant_id, merchant_public_key, dm, json_data, "Order received, but cannot be processed. Please contact merchant."
merchant_id,
merchant_public_key,
dm,
json_data,
"Order received, but cannot be processed. Please contact merchant.",
)
assert payment_req
response = {
"type": DirectMessageType.PAYMENT_REQUEST.value,
**payment_req.dict(),
@ -594,12 +607,14 @@ async def create_new_failed_order(
await create_order(merchant_id, order)
return PaymentRequest(id=order.id, message=fail_message, payment_options=[])
async def resubscribe_to_all_merchants():
await nostr_client.unsubscribe_merchants()
# give some time for the message to propagate
asyncio.sleep(1)
await asyncio.sleep(1)
await subscribe_to_all_merchants()
async def subscribe_to_all_merchants():
ids = await get_merchants_ids_with_pubkeys()
public_keys = [pk for _, pk in ids]
@ -608,7 +623,9 @@ async def subscribe_to_all_merchants():
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)
await nostr_client.subscribe_merchants(
public_keys, last_dm_time, last_stall_time, last_prod_time, 0
)
async def _handle_new_customer(event: NostrEvent, merchant: Merchant):

View file

@ -0,0 +1,170 @@
window.app.component('direct-messages', {
name: 'direct-messages',
props: ['active-chat-customer', 'merchant-id', 'adminkey', 'inkey'],
template: '#direct-messages',
delimiters: ['${', '}'],
watch: {
activeChatCustomer: async function (n) {
this.activePublicKey = n
},
activePublicKey: async function (n) {
await this.getDirectMessages(n)
}
},
computed: {
messagesAsJson: function () {
return this.messages.map(m => {
const dateFrom = moment(m.event_created_at * 1000).fromNow()
try {
const message = JSON.parse(m.message)
return {
isJson: message.type >= 0,
dateFrom,
...m,
message
}
} catch (error) {
return {
isJson: false,
dateFrom,
...m,
message: m.message
}
}
})
}
},
data: function () {
return {
customers: [],
unreadMessages: 0,
activePublicKey: null,
messages: [],
newMessage: '',
showAddPublicKey: false,
newPublicKey: null,
showRawMessage: false,
rawMessage: null
}
},
methods: {
sendMessage: async function () {},
buildCustomerLabel: function (c) {
let label = `${c.profile.name || 'unknown'} ${c.profile.about || ''}`
if (c.unread_messages) {
label += `[new: ${c.unread_messages}]`
}
label += ` (${c.public_key.slice(0, 16)}...${c.public_key.slice(
c.public_key.length - 16
)}`
return label
},
getDirectMessages: async function (pubkey) {
if (!pubkey) {
this.messages = []
return
}
try {
const {data} = await LNbits.api.request(
'GET',
'/nostrmarket/api/v1/message/' + pubkey,
this.inkey
)
this.messages = data
this.focusOnChatBox(this.messages.length - 1)
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
getCustomers: async function () {
try {
const {data} = await LNbits.api.request(
'GET',
'/nostrmarket/api/v1/customer',
this.inkey
)
this.customers = data
this.unreadMessages = data.filter(c => c.unread_messages).length
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
sendDirectMesage: async function () {
try {
const {data} = await LNbits.api.request(
'POST',
'/nostrmarket/api/v1/message',
this.adminkey,
{
message: this.newMessage,
public_key: this.activePublicKey
}
)
this.messages = this.messages.concat([data])
this.newMessage = ''
this.focusOnChatBox(this.messages.length - 1)
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
addPublicKey: async function () {
try {
const {data} = await LNbits.api.request(
'POST',
'/nostrmarket/api/v1/customer',
this.adminkey,
{
public_key: this.newPublicKey,
merchant_id: this.merchantId,
unread_messages: 0
}
)
this.newPublicKey = null
this.activePublicKey = data.public_key
await this.selectActiveCustomer()
} catch (error) {
LNbits.utils.notifyApiError(error)
} finally {
this.showAddPublicKey = false
}
},
handleNewMessage: async function (data) {
if (data.customerPubkey === this.activePublicKey) {
this.messages.push(data.dm)
this.focusOnChatBox(this.messages.length - 1)
// focus back on input box
}
this.getCustomersDebounced()
},
showOrderDetails: function (orderId, eventId) {
this.$emit('order-selected', {orderId, eventId})
},
showClientOrders: function () {
this.$emit('customer-selected', this.activePublicKey)
},
selectActiveCustomer: async function () {
await this.getDirectMessages(this.activePublicKey)
await this.getCustomers()
},
showMessageRawData: function (index) {
this.rawMessage = this.messages[index]?.message
this.showRawMessage = true
},
focusOnChatBox: function (index) {
setTimeout(() => {
const lastChatBox = document.getElementsByClassName(
`chat-mesage-index-${index}`
)
if (lastChatBox && lastChatBox[0]) {
lastChatBox[0].scrollIntoView()
}
}, 100)
}
},
created: async function () {
await this.getCustomers()
this.getCustomersDebounced = _.debounce(this.getCustomers, 2000, false)
}
})

View file

@ -1,104 +0,0 @@
<div>
<q-card>
<q-card-section>
<div class="row">
<div class="col-2">
<h6 class="text-subtitle1 q-my-none">Messages</h6>
</div>
<div class="col-4">
<q-badge v-if="unreadMessages" color="primary" outline><span v-text="unreadMessages"></span>&nbsp; new</q-badge>
</div>
<div class="col-6">
<q-btn v-if="activePublicKey" @click="showClientOrders" unelevated outline class="float-right">Client
Orders</q-btn>
</div>
</div>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
</q-card-section>
<q-card-section>
<div class="row">
<div class="col-10">
<q-select v-model="activePublicKey"
:options="customers.map(c => ({label: buildCustomerLabel(c), value: c.public_key}))" label="Select Customer"
emit-value @input="selectActiveCustomer()">
</q-select>
</div>
<div class="col-2">
<q-btn label="Add" color="primary" class="float-right q-mt-md" @click="showAddPublicKey = true">
<q-tooltip>
Add a public key to chat with
</q-tooltip>
</q-btn>
</div>
</div>
</q-card-section>
<q-card-section>
<div class="chat-container" ref="chatCard">
<div class="chat-box">
<div class="chat-messages" style="height: 45vh">
<q-chat-message v-for="(dm, index) in messagesAsJson" :key="index" :name="dm.incoming ? 'customer': 'me'"
:text="dm.isJson ? [] : [dm.message]" :sent="!dm.incoming"
:stamp="dm.dateFrom"
:bg-color="dm.incoming ? 'white' : 'light-green-2'" :class="'chat-mesage-index-'+index">
<div v-if="dm.isJson">
<div v-if="dm.message.type === 0">
<strong>New order:</strong>
</div>
<div v-else-if="dm.message.type === 1">
<strong>Reply sent for order: </strong>
</div>
<div v-else-if="dm.message.type === 2">
<q-badge v-if="dm.message.paid" color="green">Paid </q-badge>
<q-badge v-if="dm.message.shipped" color="green">Shipped </q-badge>
</div>
<div>
<span v-text="dm.message.message"></span>
<q-badge color="orange">
<span v-text="dm.message.id" @click="showOrderDetails(dm.message.id, dm.event_id)" class="cursor-pointer"></span>
</q-badge>
</div>
<q-badge @click="showMessageRawData(index)" class="cursor-pointer">...</q-badge>
</div>
</q-chat-message>
</div>
</div>
<q-card-section>
<q-form @submit="sendDirectMesage" class="full-width chat-input">
<q-input ref="newMessage" v-model="newMessage" placeholder="Message" class="full-width" dense outlined>
<template>
<q-btn round dense flat type="submit" icon="send" color="primary" />
</template>
</q-input>
</q-form>
</q-card-section>
</div>
</q-card-section>
</q-card>
<div>
<q-dialog v-model="showAddPublicKey" position="top">
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
<q-form @submit="addPublicKey" class="q-gutter-md">
<q-input filled dense v-model.trim="newPublicKey" label="Public Key (hex or nsec)"></q-input>
<div class="row q-mt-lg">
<q-btn unelevated color="primary" :disable="!newPublicKey" type="submit">Add</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
</div>
</q-form>
</q-card>
</q-dialog>
<q-dialog v-model="showRawMessage" position="top">
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
<q-input filled dense type="textarea" rows="20" v-model.trim="rawMessage" label="Raw Data"></q-input>
<div class="row q-mt-lg">
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</div>
</q-form>
</q-card>
</q-dialog>
</div>
</div>

View file

@ -1,173 +0,0 @@
async function directMessages(path) {
const template = await loadTemplateAsync(path)
Vue.component('direct-messages', {
name: 'direct-messages',
props: ['active-chat-customer', 'merchant-id', 'adminkey', 'inkey'],
template,
watch: {
activeChatCustomer: async function (n) {
this.activePublicKey = n
},
activePublicKey: async function (n) {
await this.getDirectMessages(n)
}
},
computed: {
messagesAsJson: function () {
return this.messages.map(m => {
const dateFrom = moment(m.event_created_at * 1000).fromNow()
try {
const message = JSON.parse(m.message)
return {
isJson: message.type >= 0,
dateFrom,
...m,
message
}
} catch (error) {
return {
isJson: false,
dateFrom,
...m,
message: m.message
}
}
})
}
},
data: function () {
return {
customers: [],
unreadMessages: 0,
activePublicKey: null,
messages: [],
newMessage: '',
showAddPublicKey: false,
newPublicKey: null,
showRawMessage: false,
rawMessage: null,
}
},
methods: {
sendMessage: async function () { },
buildCustomerLabel: function (c) {
let label = `${c.profile.name || 'unknown'} ${c.profile.about || ''}`
if (c.unread_messages) {
label += `[new: ${c.unread_messages}]`
}
label += ` (${c.public_key.slice(0, 16)}...${c.public_key.slice(
c.public_key.length - 16
)}`
return label
},
getDirectMessages: async function (pubkey) {
if (!pubkey) {
this.messages = []
return
}
try {
const { data } = await LNbits.api.request(
'GET',
'/nostrmarket/api/v1/message/' + pubkey,
this.inkey
)
this.messages = data
this.focusOnChatBox(this.messages.length - 1)
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
getCustomers: async function () {
try {
const { data } = await LNbits.api.request(
'GET',
'/nostrmarket/api/v1/customer',
this.inkey
)
this.customers = data
this.unreadMessages = data.filter(c => c.unread_messages).length
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
sendDirectMesage: async function () {
try {
const { data } = await LNbits.api.request(
'POST',
'/nostrmarket/api/v1/message',
this.adminkey,
{
message: this.newMessage,
public_key: this.activePublicKey
}
)
this.messages = this.messages.concat([data])
this.newMessage = ''
this.focusOnChatBox(this.messages.length - 1)
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
addPublicKey: async function () {
try {
const { data } = await LNbits.api.request(
'POST',
'/nostrmarket/api/v1/customer',
this.adminkey,
{
public_key: this.newPublicKey,
merchant_id: this.merchantId,
unread_messages: 0
}
)
this.newPublicKey = null
this.activePublicKey = data.public_key
await this.selectActiveCustomer()
} catch (error) {
LNbits.utils.notifyApiError(error)
} finally {
this.showAddPublicKey = false
}
},
handleNewMessage: async function (data) {
if (data.customerPubkey === this.activePublicKey) {
this.messages.push(data.dm)
this.focusOnChatBox(this.messages.length - 1)
// focus back on input box
}
this.getCustomersDebounced()
},
showOrderDetails: function (orderId, eventId) {
this.$emit('order-selected', { orderId, eventId })
},
showClientOrders: function () {
this.$emit('customer-selected', this.activePublicKey)
},
selectActiveCustomer: async function () {
await this.getDirectMessages(this.activePublicKey)
await this.getCustomers()
},
showMessageRawData: function (index) {
this.rawMessage = this.messages[index]?.message
this.showRawMessage = true
},
focusOnChatBox: function (index) {
setTimeout(() => {
const lastChatBox = document.getElementsByClassName(
`chat-mesage-index-${index}`
)
if (lastChatBox && lastChatBox[0]) {
lastChatBox[0].scrollIntoView()
}
}, 100)
}
},
created: async function () {
await this.getCustomers()
this.getCustomersDebounced = _.debounce(this.getCustomers, 2000, false)
}
})
}

View file

@ -0,0 +1,22 @@
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'
})
})
}
}
})

View file

@ -1,25 +0,0 @@
async function keyPair(path) {
const template = await loadTemplateAsync(path)
Vue.component('key-pair', {
name: 'key-pair',
template,
props: ['public-key', 'private-key'],
data: function () {
return {
showPrivateKey: false
}
},
methods: {
copyText: function (text, message, position) {
var notify = this.$q.notify
Quasar.utils.copyToClipboard(text).then(function () {
notify({
message: message || 'Copied to clipboard!',
position: position || 'bottom'
})
})
}
}
})
}

View file

@ -0,0 +1,102 @@
window.app.component('merchant-details', {
name: 'merchant-details',
template: '#merchant-details',
props: ['merchant-id', 'adminkey', 'inkey', 'showKeys'],
delimiters: ['${', '}'],
data: function () {
return {}
},
methods: {
toggleShowKeys: async function () {
this.$emit('toggle-show-keys')
},
republishMerchantData: async function () {
try {
await LNbits.api.request(
'PUT',
`/nostrmarket/api/v1/merchant/${this.merchantId}/nostr`,
this.adminkey
)
this.$q.notify({
type: 'positive',
message: 'Merchant data republished to Nostr',
timeout: 5000
})
} catch (error) {
console.warn(error)
LNbits.utils.notifyApiError(error)
}
},
requeryMerchantData: async function () {
try {
await LNbits.api.request(
'GET',
`/nostrmarket/api/v1/merchant/${this.merchantId}/nostr`,
this.adminkey
)
this.$q.notify({
type: 'positive',
message: 'Merchant data refreshed from Nostr',
timeout: 5000
})
} catch (error) {
console.warn(error)
LNbits.utils.notifyApiError(error)
}
},
deleteMerchantTables: function () {
LNbits.utils
.confirmDialog(
`
Stalls, products and orders will be deleted also!
Are you sure you want to delete this merchant?
`
)
.onOk(async () => {
try {
await LNbits.api.request(
'DELETE',
'/nostrmarket/api/v1/merchant/' + this.merchantId,
this.adminkey
)
this.$emit('merchant-deleted', this.merchantId)
this.$q.notify({
type: 'positive',
message: 'Merchant Deleted',
timeout: 5000
})
} catch (error) {
console.warn(error)
LNbits.utils.notifyApiError(error)
}
})
},
deleteMerchantFromNostr: function () {
LNbits.utils
.confirmDialog(
`
Do you want to remove the merchant from Nostr?
`
)
.onOk(async () => {
try {
await LNbits.api.request(
'DELETE',
`/nostrmarket/api/v1/merchant/${this.merchantId}/nostr`,
this.adminkey
)
this.$q.notify({
type: 'positive',
message: 'Merchant Deleted from Nostr',
timeout: 5000
})
} catch (error) {
console.warn(error)
LNbits.utils.notifyApiError(error)
}
})
}
},
created: async function () {}
})

View file

@ -1,106 +0,0 @@
async function merchantDetails(path) {
const template = await loadTemplateAsync(path)
Vue.component('merchant-details', {
name: 'merchant-details',
props: ['merchant-id', 'adminkey', 'inkey','showKeys'],
template,
data: function () {
return {
}
},
methods: {
toggleShowKeys: async function () {
this.$emit('toggle-show-keys')
},
republishMerchantData: async function () {
try {
await LNbits.api.request(
'PUT',
`/nostrmarket/api/v1/merchant/${this.merchantId}/nostr`,
this.adminkey
)
this.$q.notify({
type: 'positive',
message: 'Merchant data republished to Nostr',
timeout: 5000
})
} catch (error) {
console.warn(error)
LNbits.utils.notifyApiError(error)
}
},
requeryMerchantData: async function () {
try {
await LNbits.api.request(
'GET',
`/nostrmarket/api/v1/merchant/${this.merchantId}/nostr`,
this.adminkey
)
this.$q.notify({
type: 'positive',
message: 'Merchant data refreshed from Nostr',
timeout: 5000
})
} catch (error) {
console.warn(error)
LNbits.utils.notifyApiError(error)
}
},
deleteMerchantTables: function () {
LNbits.utils
.confirmDialog(
`
Stalls, products and orders will be deleted also!
Are you sure you want to delete this merchant?
`
)
.onOk(async () => {
try {
await LNbits.api.request(
'DELETE',
'/nostrmarket/api/v1/merchant/' + this.merchantId,
this.adminkey
)
this.$emit('merchant-deleted', this.merchantId)
this.$q.notify({
type: 'positive',
message: 'Merchant Deleted',
timeout: 5000
})
} catch (error) {
console.warn(error)
LNbits.utils.notifyApiError(error)
}
})
},
deleteMerchantFromNostr: function () {
LNbits.utils
.confirmDialog(
`
Do you want to remove the merchant from Nostr?
`
)
.onOk(async () => {
try {
await LNbits.api.request(
'DELETE',
`/nostrmarket/api/v1/merchant/${this.merchantId}/nostr`,
this.adminkey
)
this.$q.notify({
type: 'positive',
message: 'Merchant Deleted from Nostr',
timeout: 5000
})
} catch (error) {
console.warn(error)
LNbits.utils.notifyApiError(error)
}
})
}
},
created: async function () {}
})
}

View file

@ -0,0 +1,406 @@
window.app.component('order-list', {
name: 'order-list',
props: ['stall-id', 'customer-pubkey-filter', 'adminkey', 'inkey'],
template: '#order-list',
delimiters: ['${', '}'],
watch: {
customerPubkeyFilter: async function (n) {
this.search.publicKey = n
this.search.isPaid = {label: 'All', id: null}
this.search.isShipped = {label: 'All', id: null}
await this.getOrders()
}
},
data: function () {
return {
orders: [],
stalls: [],
selectedOrder: null,
shippingMessage: '',
showShipDialog: false,
filter: '',
search: {
publicKey: null,
isPaid: {
label: 'All',
id: null
},
isShipped: {
label: 'All',
id: null
},
restoring: false
},
customers: [],
ternaryOptions: [
{
label: 'All',
id: null
},
{
label: 'Yes',
id: 'true'
},
{
label: 'No',
id: 'false'
}
],
zoneOptions: [],
ordersTable: {
columns: [
{
name: '',
align: 'left',
label: '',
field: ''
},
{
name: 'id',
align: 'left',
label: 'Order ID',
field: 'id'
},
{
name: 'total',
align: 'left',
label: 'Total Sats',
field: 'total'
},
{
name: 'fiat',
align: 'left',
label: 'Total Fiat',
field: 'fiat'
},
{
name: 'paid',
align: 'left',
label: 'Paid',
field: 'paid'
},
{
name: 'shipped',
align: 'left',
label: 'Shipped',
field: 'shipped'
},
{
name: 'public_key',
align: 'left',
label: 'Customer',
field: 'pubkey'
},
{
name: 'event_created_at',
align: 'left',
label: 'Created At',
field: 'event_created_at'
}
],
pagination: {
rowsPerPage: 10
}
}
}
},
computed: {
customerOptions: function () {
const options = this.customers.map(c => ({
label: this.buildCustomerLabel(c),
value: c.public_key
}))
options.unshift({label: 'All', value: null, id: null})
return options
}
},
methods: {
toShortId: function (value) {
return value.substring(0, 5) + '...' + value.substring(value.length - 5)
},
formatDate: function (value) {
return Quasar.date.formatDate(new Date(value * 1000), 'YYYY-MM-DD HH:mm')
},
satBtc(val, showUnit = true) {
return satOrBtc(val, showUnit, true)
},
formatFiat(value, currency) {
return Math.trunc(value) + ' ' + currency
},
shortLabel(value = '') {
if (value.length <= 44) return value
return value.substring(0, 20) + '...'
},
productName: function (order, productId) {
product = order.extra.products.find(p => p.id === productId)
if (product) {
return product.name
}
return ''
},
productPrice: function (order, productId) {
product = order.extra.products.find(p => p.id === productId)
if (product) {
return `${product.price} ${order.extra.currency}`
}
return ''
},
orderTotal: function (order) {
const productCost = order.items.reduce((t, item) => {
product = order.extra.products.find(p => p.id === item.product_id)
return t + item.quantity * product.price
}, 0)
return productCost + order.extra.shipping_cost
},
getOrders: async function () {
try {
const ordersPath = this.stallId
? `stall/order/${this.stallId}`
: 'order'
const query = []
if (this.search.publicKey) {
query.push(`pubkey=${this.search.publicKey}`)
}
if (this.search.isPaid.id) {
query.push(`paid=${this.search.isPaid.id}`)
}
if (this.search.isShipped.id) {
query.push(`shipped=${this.search.isShipped.id}`)
}
const {data} = await LNbits.api.request(
'GET',
`/nostrmarket/api/v1/${ordersPath}?${query.join('&')}`,
this.inkey
)
this.orders = data.map(s => ({...s, expanded: false}))
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
getOrder: async function (orderId) {
try {
const {data} = await LNbits.api.request(
'GET',
`/nostrmarket/api/v1/order/${orderId}`,
this.inkey
)
return {...data, expanded: false, isNew: true}
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
restoreOrder: async function (eventId) {
console.log('### restoreOrder', eventId)
try {
this.search.restoring = true
const {data} = await LNbits.api.request(
'PUT',
`/nostrmarket/api/v1/order/restore/${eventId}`,
this.adminkey
)
await this.getOrders()
this.$q.notify({
type: 'positive',
message: 'Order restored!'
})
return data
} catch (error) {
LNbits.utils.notifyApiError(error)
} finally {
this.search.restoring = false
}
},
restoreOrders: async function () {
try {
this.search.restoring = true
await LNbits.api.request(
'PUT',
`/nostrmarket/api/v1/orders/restore`,
this.adminkey
)
await this.getOrders()
this.$q.notify({
type: 'positive',
message: 'Orders restored!'
})
} catch (error) {
LNbits.utils.notifyApiError(error)
} finally {
this.search.restoring = false
}
},
reissueOrderInvoice: async function (order) {
try {
const {data} = await LNbits.api.request(
'PUT',
`/nostrmarket/api/v1/order/reissue`,
this.adminkey,
{
id: order.id,
shipping_id: order.shipping_id
}
)
this.$q.notify({
type: 'positive',
message: 'Order invoice reissued!'
})
data.expanded = order.expanded
const i = this.orders.map(o => o.id).indexOf(order.id)
if (i !== -1) {
this.orders[i] = {...this.orders[i], ...data}
}
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
updateOrderShipped: async function () {
this.selectedOrder.shipped = !this.selectedOrder.shipped
try {
await LNbits.api.request(
'PATCH',
`/nostrmarket/api/v1/order/${this.selectedOrder.id}`,
this.adminkey,
{
id: this.selectedOrder.id,
message: this.shippingMessage,
shipped: this.selectedOrder.shipped
}
)
this.$q.notify({
type: 'positive',
message: 'Order updated!'
})
} catch (error) {
LNbits.utils.notifyApiError(error)
}
this.showShipDialog = false
},
addOrder: async function (data) {
if (
!this.search.publicKey ||
this.search.publicKey === data.customerPubkey
) {
const orderData = JSON.parse(data.dm.message)
const i = this.orders.map(o => o.id).indexOf(orderData.id)
if (i === -1) {
const order = await this.getOrder(orderData.id)
this.orders.unshift(order)
}
}
},
orderSelected: async function (orderId, eventId) {
const order = await this.getOrder(orderId)
if (!order) {
LNbits.utils
.confirmDialog(
'Order could not be found. Do you want to restore it from this direct message?'
)
.onOk(async () => {
const restoredOrder = await this.restoreOrder(eventId)
console.log('### restoredOrder', restoredOrder)
if (restoredOrder) {
restoredOrder.expanded = true
restoredOrder.isNew = false
this.orders = [restoredOrder]
}
})
return
}
order.expanded = true
order.isNew = false
this.orders = [order]
},
getZones: async function () {
try {
const {data} = await LNbits.api.request(
'GET',
'/nostrmarket/api/v1/zone',
this.inkey
)
return data.map(z => ({
id: z.id,
value: z.id,
label: z.name
? `${z.name} (${z.countries.join(', ')})`
: z.countries.join(', ')
}))
} catch (error) {
LNbits.utils.notifyApiError(error)
}
return []
},
getStalls: async function (pending = false) {
try {
const {data} = await LNbits.api.request(
'GET',
`/nostrmarket/api/v1/stall?pending=${pending}`,
this.inkey
)
return data.map(s => ({...s, expanded: false}))
} catch (error) {
LNbits.utils.notifyApiError(error)
}
return []
},
getStallZones: function (stallId) {
const stall = this.stalls.find(s => s.id === stallId)
if (!stall) return []
return this.zoneOptions.filter(z =>
stall.shipping_zones.find(s => s.id === z.id)
)
},
showShipOrderDialog: function (order) {
this.selectedOrder = order
this.shippingMessage = order.shipped
? 'The order has been shipped!'
: 'The order has NOT yet been shipped!'
// do not change the status yet
this.selectedOrder.shipped = !order.shipped
this.showShipDialog = true
},
customerSelected: function (customerPubkey) {
this.$emit('customer-selected', customerPubkey)
},
getCustomers: async function () {
try {
const {data} = await LNbits.api.request(
'GET',
'/nostrmarket/api/v1/customer',
this.inkey
)
this.customers = data
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
buildCustomerLabel: function (c) {
let label = `${c.profile.name || 'unknown'} ${c.profile.about || ''}`
if (c.unread_messages) {
label += `[new: ${c.unread_messages}]`
}
label += ` (${c.public_key.slice(0, 16)}...${c.public_key.slice(
c.public_key.length - 16
)}`
return label
},
orderPaid: function (orderId) {
const order = this.orders.find(o => o.id === orderId)
if (order) {
order.paid = true
}
}
},
created: async function () {
if (this.stallId) {
await this.getOrders()
}
await this.getCustomers()
this.zoneOptions = await this.getZones()
this.stalls = await this.getStalls()
}
})

View file

@ -1,212 +0,0 @@
<div>
<div class="row q-mb-md">
<div class="col-md-4 col-sm-6 q-pr-lg">
<q-select v-model="search.publicKey" :options="customerOptions" label="Customer" emit-value class="text-wrap">
</q-select>
</div>
<div class="col-md-2 col-sm-6 q-pr-lg">
<q-select v-model="search.isPaid" :options="ternaryOptions" label="Paid" emit-value>
</q-select>
</div>
<div class="col-md-2 col-sm-6 q-pr-lg">
<q-select v-model="search.isShipped" :options="ternaryOptions" label="Shipped" emit-value>
</q-select>
</div>
<div class="col-md-4 col-sm-6">
<q-btn-dropdown @click="getOrders()" :disable="search.restoring" outline unelevated split
class="q-pt-md float-right" :label="search.restoring ? 'Restoring Orders...' : 'Load Orders'">
<q-spinner v-if="search.restoring" color="primary" size="2.55em" class="q-pt-md float-right"></q-spinner>
<q-item @click="restoreOrders" clickable v-close-popup>
<q-item-section>
<q-item-label>Restore Orders</q-item-label>
<q-item-label caption>Restore previous orders from Nostr</q-item-label>
</q-item-section>
</q-item>
</q-btn-dropdown>
</div>
</div>
<div class="row q-mt-md">
<div class="col">
<q-table flat dense :data="orders" row-key="id" :columns="ordersTable.columns"
:pagination.sync="ordersTable.pagination" :filter="filter">
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn size="sm" color="primary" round dense @click="props.row.expanded= !props.row.expanded"
:icon="props.row.expanded? 'remove' : 'add'" />
</q-td>
<q-td key="id" :props="props">
{{toShortId(props.row.id)}}
<q-badge v-if="props.row.isNew" color="orange">new</q-badge></q-td>
<q-td key="total" :props="props">
{{satBtc(props.row.total)}}
</q-td>
<q-td key="fiat" :props="props">
<span v-if="props.row.extra.currency !== 'sat'">
{{orderTotal(props.row)}} {{props.row.extra.currency}}
</span>
</q-td>
<q-td key="paid" :props="props">
<q-checkbox v-model="props.row.paid" :label="props.row.paid ? 'Yes' : 'No'" disable readonly
size="sm"></q-checkbox>
</q-td>
<q-td key="shipped" :props="props">
<q-checkbox v-model="props.row.shipped" @input="showShipOrderDialog(props.row)"
:label="props.row.shipped ? 'Yes' : 'No'" size="sm"></q-checkbox>
</q-td>
<q-td key="public_key" :props="props">
<span @click="customerSelected(props.row.public_key)" class="cursor-pointer">
{{toShortId(props.row.public_key)}}
</span>
</q-td>
<q-td key="event_created_at" :props="props">
{{formatDate(props.row.event_created_at)}}
</q-td>
</q-tr>
<q-tr v-if="props.row.expanded" :props="props">
<q-td colspan="100%">
<div class="row items-center no-wrap">
<div class="col-3 q-pr-lg">Products:</div>
<div class="col-8">
<div class="row items-center no-wrap q-mb-md">
<div class="col-1"><strong>Quantity</strong></div>
<div class="col-1"></div>
<div class="col-4"><strong>Name</strong></div>
<div class="col-2"><strong>Price</strong></div>
<div class="col-4"></div>
</div>
</div>
<div class="col-1"></div>
</div>
<div class="row items-center no-wrap q-mb-md">
<div class="col-3 q-pr-lg"></div>
<div class="col-8">
<div v-for="item in props.row.items" class="row items-center no-wrap q-mb-md">
<div class="col-1">{{item.quantity}}</div>
<div class="col-1">x</div>
<div class="col-4">
<p :title="productName(props.row, item.product_id)">
{{shortLabel(productName(props.row, item.product_id))}}
</p>
</div>
<div class="col-2">
{{productPrice(props.row, item.product_id)}}
</div>
<div class="col-4"></div>
</div>
<div v-if="props.row.extra.shipping_cost" class="row items-center no-wrap q-mb-md">
<div class="col-1"></div>
<div class="col-1"></div>
<div class="col-4">Shipping Cost</div>
<div class="col-2">
{{props.row.extra.shipping_cost}} {{props.row.extra.currency}}
</div>
<div class="col-4"></div>
</div>
</div>
<div class="col-1"></div>
</div>
<div v-if="props.row.extra.currency !== 'sat'" class="row items-center no-wrap q-mb-md q-mt-md">
<div class="col-3 q-pr-lg">Exchange Rate (1 BTC):</div>
<div class="col-6 col-sm-8 q-pr-lg">
<q-input filled dense readonly disabled
:value="formatFiat(props.row.extra.btc_price, props.row.extra.currency)" type="text"></q-input>
</div>
<div class="col-3 col-sm-1"></div>
</div>
<div v-if="props.row.extra.fail_message" class="row items-center no-wrap q-mb-md">
<div class="col-3 q-pr-lg">Error:</div>
<div class="col-6 col-sm-8 q-pr-lg">
<q-badge color="pink"><span v-text="props.row.extra.fail_message"></span></q-badge>
</div>
<div class="col-3 col-sm-1"></div>
</div>
<div class="row items-center no-wrap q-mb-md q-mt-md">
<div class="col-3 q-pr-lg">Order ID:</div>
<div class="col-6 col-sm-8 q-pr-lg">
<q-input filled dense readonly disabled v-model.trim="props.row.id" type="text"></q-input>
</div>
<div class="col-3 col-sm-1"></div>
</div>
<div class="row items-center no-wrap q-mb-md">
<div class="col-3 q-pr-lg">Customer Public Key:</div>
<div class="col-6 col-sm-8 q-pr-lg">
<q-input filled dense readonly disabled v-model.trim="props.row.public_key" type="text"></q-input>
</div>
<div class="col-3 col-sm-1"></div>
</div>
<div v-if="props.row.address" class="row items-center no-wrap q-mb-md">
<div class="col-3 q-pr-lg">Address:</div>
<div class="col-6 col-sm-8 q-pr-lg">
<q-input filled dense readonly disabled v-model.trim="props.row.address" type="text"></q-input>
</div>
<div class="col-3 col-sm-1"></div>
</div>
<div v-if="props.row.contact.phone" class="row items-center no-wrap q-mb-md">
<div class="col-3 q-pr-lg">Phone:</div>
<div class="col-6 col-sm-8 q-pr-lg">
<q-input filled dense readonly disabled v-model.trim="props.row.contact.phone" type="text"></q-input>
</div>
<div class="col-3 col-sm-1"></div>
</div>
<div v-if="props.row.contact.email" class="row items-center no-wrap q-mb-md">
<div class="col-3 q-pr-lg">Email:</div>
<div class="col-6 col-sm-8 q-pr-lg">
<q-input filled dense readonly disabled v-model.trim="props.row.contact.email" type="text"></q-input>
</div>
<div class="col-3 col-sm-1"></div>
</div>
<div class="row items-center no-wrap q-mb-md">
<div class="col-3 q-pr-lg">Shipping Zone:</div>
<div class="col-6 col-sm-8 q-pr-lg">
<q-select :options="getStallZones(props.row.stall_id)" filled dense emit-value
v-model.trim="props.row.shipping_id" label="Shipping Zones"></q-select>
</div>
<div class="col-3 col-sm-1"></div>
</div>
<div class="row items-center no-wrap q-mb-md">
<div class="col-3 q-pr-lg">Invoice ID:</div>
<div class="col-6 col-sm-8 q-pr-lg">
<q-input filled dense readonly disabled v-model.trim="props.row.invoice_id" type="text"></q-input>
</div>
<div class="col-3">
</div>
</div>
<div class="row items-center no-wrap q-mb-md">
<div class="col-3 q-pr-lg"></div>
<div class="col-9">
<q-btn @click="reissueOrderInvoice(props.row)" unelevated color="primary" type="submit"
class="float-left" label="Reissue Invoice"></q-btn>
</div>
</div>
</q-td>
</q-tr>
</template>
</q-table>
</div>
</div>
<q-dialog v-model="showShipDialog" position="top">
<q-card v-if="selectedOrder" class="q-pa-lg q-pt-xl" style="width: 500px">
<q-form @submit="updateOrderShipped" class="q-gutter-md">
<q-input filled dense v-model.trim="shippingMessage" label="Shipping Message" type="textarea"
rows="4"></q-input>
<div class="row q-mt-lg">
<q-btn unelevated color="primary" type="submit"
:label="selectedOrder.shipped? 'Unship Order' : 'Ship Order'"></q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
</div>
</q-form>
</q-card>
</q-dialog>
</div>

View file

@ -1,409 +0,0 @@
async function orderList(path) {
const template = await loadTemplateAsync(path)
Vue.component('order-list', {
name: 'order-list',
props: ['stall-id', 'customer-pubkey-filter', 'adminkey', 'inkey'],
template,
watch: {
customerPubkeyFilter: async function (n) {
this.search.publicKey = n
this.search.isPaid = { label: 'All', id: null }
this.search.isShipped = { label: 'All', id: null }
await this.getOrders()
}
},
data: function () {
return {
orders: [],
stalls: [],
selectedOrder: null,
shippingMessage: '',
showShipDialog: false,
filter: '',
search: {
publicKey: null,
isPaid: {
label: 'All',
id: null
},
isShipped: {
label: 'All',
id: null
},
restoring: false
},
customers: [],
ternaryOptions: [
{
label: 'All',
id: null
},
{
label: 'Yes',
id: 'true'
},
{
label: 'No',
id: 'false'
}
],
zoneOptions: [],
ordersTable: {
columns: [
{
name: '',
align: 'left',
label: '',
field: ''
},
{
name: 'id',
align: 'left',
label: 'Order ID',
field: 'id'
},
{
name: 'total',
align: 'left',
label: 'Total Sats',
field: 'total'
},
{
name: 'fiat',
align: 'left',
label: 'Total Fiat',
field: 'fiat'
},
{
name: 'paid',
align: 'left',
label: 'Paid',
field: 'paid'
},
{
name: 'shipped',
align: 'left',
label: 'Shipped',
field: 'shipped'
},
{
name: 'public_key',
align: 'left',
label: 'Customer',
field: 'pubkey'
},
{
name: 'event_created_at',
align: 'left',
label: 'Created At',
field: 'event_created_at'
}
],
pagination: {
rowsPerPage: 10
}
}
}
},
computed: {
customerOptions: function () {
const options = this.customers.map(c => ({ label: this.buildCustomerLabel(c), value: c.public_key }))
options.unshift({ label: 'All', value: null, id: null })
return options
}
},
methods: {
toShortId: function (value) {
return value.substring(0, 5) + '...' + value.substring(value.length - 5)
},
formatDate: function (value) {
return Quasar.utils.date.formatDate(
new Date(value * 1000),
'YYYY-MM-DD HH:mm'
)
},
satBtc(val, showUnit = true) {
return satOrBtc(val, showUnit, true)
},
formatFiat(value, currency) {
return Math.trunc(value) + ' ' + currency
},
shortLabel(value = ''){
if (value.length <= 44) return value
return value.substring(0, 20) + '...'
},
productName: function (order, productId) {
product = order.extra.products.find(p => p.id === productId)
if (product) {
return product.name
}
return ''
},
productPrice: function (order, productId) {
product = order.extra.products.find(p => p.id === productId)
if (product) {
return `${product.price} ${order.extra.currency}`
}
return ''
},
orderTotal: function (order) {
const productCost = order.items.reduce((t, item) => {
product = order.extra.products.find(p => p.id === item.product_id)
return t + item.quantity * product.price
}, 0)
return productCost + order.extra.shipping_cost
},
getOrders: async function () {
try {
const ordersPath = this.stallId
? `stall/order/${this.stallId}`
: 'order'
const query = []
if (this.search.publicKey) {
query.push(`pubkey=${this.search.publicKey}`)
}
if (this.search.isPaid.id) {
query.push(`paid=${this.search.isPaid.id}`)
}
if (this.search.isShipped.id) {
query.push(`shipped=${this.search.isShipped.id}`)
}
const { data } = await LNbits.api.request(
'GET',
`/nostrmarket/api/v1/${ordersPath}?${query.join('&')}`,
this.inkey
)
this.orders = data.map(s => ({ ...s, expanded: false }))
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
getOrder: async function (orderId) {
try {
const { data } = await LNbits.api.request(
'GET',
`/nostrmarket/api/v1/order/${orderId}`,
this.inkey
)
return { ...data, expanded: false, isNew: true }
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
restoreOrder: async function (eventId) {
console.log('### restoreOrder', eventId)
try {
this.search.restoring = true
const {data} = await LNbits.api.request(
'PUT',
`/nostrmarket/api/v1/order/restore/${eventId}`,
this.adminkey
)
await this.getOrders()
this.$q.notify({
type: 'positive',
message: 'Order restored!'
})
return data
} catch (error) {
LNbits.utils.notifyApiError(error)
} finally {
this.search.restoring = false
}
},
restoreOrders: async function () {
try {
this.search.restoring = true
await LNbits.api.request(
'PUT',
`/nostrmarket/api/v1/orders/restore`,
this.adminkey
)
await this.getOrders()
this.$q.notify({
type: 'positive',
message: 'Orders restored!'
})
} catch (error) {
LNbits.utils.notifyApiError(error)
} finally {
this.search.restoring = false
}
},
reissueOrderInvoice: async function (order) {
try {
const { data } = await LNbits.api.request(
'PUT',
`/nostrmarket/api/v1/order/reissue`,
this.adminkey,
{
id: order.id,
shipping_id: order.shipping_id
}
)
this.$q.notify({
type: 'positive',
message: 'Order invoice reissued!'
})
data.expanded = order.expanded
const i = this.orders.map(o => o.id).indexOf(order.id)
if (i !== -1) {
this.orders[i] = { ...this.orders[i], ...data }
}
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
updateOrderShipped: async function () {
this.selectedOrder.shipped = !this.selectedOrder.shipped
try {
await LNbits.api.request(
'PATCH',
`/nostrmarket/api/v1/order/${this.selectedOrder.id}`,
this.adminkey,
{
id: this.selectedOrder.id,
message: this.shippingMessage,
shipped: this.selectedOrder.shipped
}
)
this.$q.notify({
type: 'positive',
message: 'Order updated!'
})
} catch (error) {
LNbits.utils.notifyApiError(error)
}
this.showShipDialog = false
},
addOrder: async function (data) {
if (
!this.search.publicKey ||
this.search.publicKey === data.customerPubkey
) {
const orderData = JSON.parse(data.dm.message)
const i = this.orders.map(o => o.id).indexOf(orderData.id)
if (i === -1) {
const order = await this.getOrder(orderData.id)
this.orders.unshift(order)
}
}
},
orderSelected: async function (orderId, eventId) {
const order = await this.getOrder(orderId)
if (!order) {
LNbits.utils
.confirmDialog(
"Order could not be found. Do you want to restore it from this direct message?"
)
.onOk(async () => {
const restoredOrder = await this.restoreOrder(eventId)
console.log('### restoredOrder', restoredOrder)
if (restoredOrder) {
restoredOrder.expanded = true
restoredOrder.isNew = false
this.orders = [restoredOrder]
}
})
return
}
order.expanded = true
order.isNew = false
this.orders = [order]
},
getZones: async function () {
try {
const { data } = await LNbits.api.request(
'GET',
'/nostrmarket/api/v1/zone',
this.inkey
)
return data.map(z => ({
id: z.id,
value: z.id,
label: z.name
? `${z.name} (${z.countries.join(', ')})`
: z.countries.join(', ')
}))
} catch (error) {
LNbits.utils.notifyApiError(error)
}
return []
},
getStalls: async function (pending = false) {
try {
const { data } = await LNbits.api.request(
'GET',
`/nostrmarket/api/v1/stall?pending=${pending}`,
this.inkey
)
return data.map(s => ({ ...s, expanded: false }))
} catch (error) {
LNbits.utils.notifyApiError(error)
}
return []
},
getStallZones: function (stallId) {
const stall = this.stalls.find(s => s.id === stallId)
if (!stall) return []
return this.zoneOptions.filter(z => stall.shipping_zones.find(s => s.id === z.id))
},
showShipOrderDialog: function (order) {
this.selectedOrder = order
this.shippingMessage = order.shipped
? 'The order has been shipped!'
: 'The order has NOT yet been shipped!'
// do not change the status yet
this.selectedOrder.shipped = !order.shipped
this.showShipDialog = true
},
customerSelected: function (customerPubkey) {
this.$emit('customer-selected', customerPubkey)
},
getCustomers: async function () {
try {
const { data } = await LNbits.api.request(
'GET',
'/nostrmarket/api/v1/customer',
this.inkey
)
this.customers = data
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
buildCustomerLabel: function (c) {
let label = `${c.profile.name || 'unknown'} ${c.profile.about || ''}`
if (c.unread_messages) {
label += `[new: ${c.unread_messages}]`
}
label += ` (${c.public_key.slice(0, 16)}...${c.public_key.slice(
c.public_key.length - 16
)}`
return label
},
orderPaid: function (orderId) {
const order = this.orders.find(o => o.id === orderId)
if (order) {
order.paid = true
}
}
},
created: async function () {
if (this.stallId) {
await this.getOrders()
}
await this.getCustomers()
this.zoneOptions = await this.getZones()
this.stalls = await this.getStalls()
}
})
}

View file

@ -0,0 +1,183 @@
window.app.component('shipping-zones', {
name: 'shipping-zones',
props: ['adminkey', 'inkey'],
template: '#shipping-zones',
delimiters: ['${', '}'],
data: function () {
return {
zones: [],
zoneDialog: {
showDialog: false,
data: {
id: null,
name: '',
countries: [],
cost: 0,
currency: 'sat'
}
},
currencies: [],
shippingZoneOptions: [
'Free (digital)',
'Flat rate',
'Worldwide',
'Europe',
'Australia',
'Austria',
'Belgium',
'Brazil',
'Canada',
'Denmark',
'Finland',
'France',
'Germany',
'Greece',
'Hong Kong',
'Hungary',
'Ireland',
'Indonesia',
'Israel',
'Italy',
'Japan',
'Kazakhstan',
'Korea',
'Luxembourg',
'Malaysia',
'Mexico',
'Netherlands',
'New Zealand',
'Norway',
'Poland',
'Portugal',
'Romania',
'Russia',
'Saudi Arabia',
'Singapore',
'Spain',
'Sweden',
'Switzerland',
'Thailand',
'Turkey',
'Ukraine',
'United Kingdom**',
'United States***',
'Vietnam',
'China'
]
}
},
methods: {
openZoneDialog: function (data) {
data = data || {
id: null,
name: '',
countries: [],
cost: 0,
currency: 'sat'
}
this.zoneDialog.data = data
this.zoneDialog.showDialog = true
},
createZone: async function () {
try {
const {data} = await LNbits.api.request(
'POST',
'/nostrmarket/api/v1/zone',
this.adminkey,
{}
)
this.zones = data
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
getZones: async function () {
try {
const {data} = await LNbits.api.request(
'GET',
'/nostrmarket/api/v1/zone',
this.inkey
)
this.zones = data
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
sendZoneFormData: async function () {
this.zoneDialog.showDialog = false
if (this.zoneDialog.data.id) {
await this.updateShippingZone(this.zoneDialog.data)
} else {
await this.createShippingZone(this.zoneDialog.data)
}
await this.getZones()
},
createShippingZone: async function (newZone) {
try {
await LNbits.api.request(
'POST',
'/nostrmarket/api/v1/zone',
this.adminkey,
newZone
)
this.$q.notify({
type: 'positive',
message: 'Zone created!'
})
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
updateShippingZone: async function (updatedZone) {
try {
await LNbits.api.request(
'PATCH',
`/nostrmarket/api/v1/zone/${updatedZone.id}`,
this.adminkey,
updatedZone
)
this.$q.notify({
type: 'positive',
message: 'Zone updated!'
})
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
deleteShippingZone: async function () {
try {
await LNbits.api.request(
'DELETE',
`/nostrmarket/api/v1/zone/${this.zoneDialog.data.id}`,
this.adminkey
)
this.$q.notify({
type: 'positive',
message: 'Zone deleted!'
})
await this.getZones()
this.zoneDialog.showDialog = false
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
async getCurrencies() {
try {
const {data} = await LNbits.api.request(
'GET',
'/nostrmarket/api/v1/currencies',
this.inkey
)
this.currencies = ['sat', ...data]
} catch (error) {
LNbits.utils.notifyApiError(error)
}
}
},
created: async function () {
await this.getZones()
await this.getCurrencies()
}
})

View file

@ -1,186 +0,0 @@
async function shippingZones(path) {
const template = await loadTemplateAsync(path)
Vue.component('shipping-zones', {
name: 'shipping-zones',
props: ['adminkey', 'inkey'],
template,
data: function () {
return {
zones: [],
zoneDialog: {
showDialog: false,
data: {
id: null,
name: '',
countries: [],
cost: 0,
currency: 'sat'
}
},
currencies: [],
shippingZoneOptions: [
'Free (digital)',
'Flat rate',
'Worldwide',
'Europe',
'Australia',
'Austria',
'Belgium',
'Brazil',
'Canada',
'Denmark',
'Finland',
'France',
'Germany',
'Greece',
'Hong Kong',
'Hungary',
'Ireland',
'Indonesia',
'Israel',
'Italy',
'Japan',
'Kazakhstan',
'Korea',
'Luxembourg',
'Malaysia',
'Mexico',
'Netherlands',
'New Zealand',
'Norway',
'Poland',
'Portugal',
'Romania',
'Russia',
'Saudi Arabia',
'Singapore',
'Spain',
'Sweden',
'Switzerland',
'Thailand',
'Turkey',
'Ukraine',
'United Kingdom**',
'United States***',
'Vietnam',
'China'
]
}
},
methods: {
openZoneDialog: function (data) {
data = data || {
id: null,
name: '',
countries: [],
cost: 0,
currency: 'sat'
}
this.zoneDialog.data = data
this.zoneDialog.showDialog = true
},
createZone: async function () {
try {
const {data} = await LNbits.api.request(
'POST',
'/nostrmarket/api/v1/zone',
this.adminkey,
{}
)
this.zones = data
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
getZones: async function () {
try {
const {data} = await LNbits.api.request(
'GET',
'/nostrmarket/api/v1/zone',
this.inkey
)
this.zones = data
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
sendZoneFormData: async function () {
this.zoneDialog.showDialog = false
if (this.zoneDialog.data.id) {
await this.updateShippingZone(this.zoneDialog.data)
} else {
await this.createShippingZone(this.zoneDialog.data)
}
await this.getZones()
},
createShippingZone: async function (newZone) {
try {
await LNbits.api.request(
'POST',
'/nostrmarket/api/v1/zone',
this.adminkey,
newZone
)
this.$q.notify({
type: 'positive',
message: 'Zone created!'
})
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
updateShippingZone: async function (updatedZone) {
try {
await LNbits.api.request(
'PATCH',
`/nostrmarket/api/v1/zone/${updatedZone.id}`,
this.adminkey,
updatedZone
)
this.$q.notify({
type: 'positive',
message: 'Zone updated!'
})
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
deleteShippingZone: async function () {
try {
await LNbits.api.request(
'DELETE',
`/nostrmarket/api/v1/zone/${this.zoneDialog.data.id}`,
this.adminkey
)
this.$q.notify({
type: 'positive',
message: 'Zone deleted!'
})
await this.getZones()
this.zoneDialog.showDialog = false
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
async getCurrencies() {
try {
const {data} = await LNbits.api.request(
'GET',
'/nostrmarket/api/v1/currencies',
this.inkey
)
this.currencies = ['sat', ...data]
} catch (error) {
LNbits.utils.notifyApiError(error)
}
}
},
created: async function () {
await this.getZones()
await this.getCurrencies()
}
})
}

View file

@ -0,0 +1,338 @@
window.app.component('stall-details', {
name: 'stall-details',
template: '#stall-details',
delimiters: ['${', '}'],
props: [
'stall-id',
'adminkey',
'inkey',
'wallet-options',
'zone-options',
'currencies'
],
data: function () {
return {
tab: 'products',
stall: null,
products: [],
pendingProducts: [],
productDialog: {
showDialog: false,
showRestore: false,
url: true,
data: null
},
productsFilter: '',
productsTable: {
columns: [
{
name: 'delete',
align: 'left',
label: '',
field: ''
},
{
name: 'edit',
align: 'left',
label: '',
field: ''
},
{
name: 'activate',
align: 'left',
label: '',
field: ''
},
{
name: 'id',
align: 'left',
label: 'ID',
field: 'id'
},
{
name: 'name',
align: 'left',
label: 'Name',
field: 'name'
},
{
name: 'price',
align: 'left',
label: 'Price',
field: 'price'
},
{
name: 'quantity',
align: 'left',
label: 'Quantity',
field: 'quantity'
}
],
pagination: {
rowsPerPage: 10
}
}
}
},
computed: {
filteredZoneOptions: function () {
if (!this.stall) return []
return this.zoneOptions.filter(z => z.currency === this.stall.currency)
}
},
methods: {
mapStall: function (stall) {
stall.shipping_zones.forEach(
z =>
(z.label = z.name
? `${z.name} (${z.countries.join(', ')})`
: z.countries.join(', '))
)
return stall
},
newEmtpyProductData: function () {
return {
id: null,
name: '',
categories: [],
images: [],
image: null,
price: 0,
quantity: 0,
config: {
description: '',
use_autoreply: false,
autoreply_message: '',
shipping: (this.stall.shipping_zones || []).map(z => ({
id: z.id,
name: z.name,
cost: 0
}))
}
}
},
getStall: async function () {
try {
const {data} = await LNbits.api.request(
'GET',
'/nostrmarket/api/v1/stall/' + this.stallId,
this.inkey
)
this.stall = this.mapStall(data)
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
updateStall: async function () {
try {
const {data} = await LNbits.api.request(
'PUT',
'/nostrmarket/api/v1/stall/' + this.stallId,
this.adminkey,
this.stall
)
this.stall = this.mapStall(data)
this.$emit('stall-updated', this.stall)
this.$q.notify({
type: 'positive',
message: 'Stall Updated',
timeout: 5000
})
} catch (error) {
console.warn(error)
LNbits.utils.notifyApiError(error)
}
},
deleteStall: function () {
LNbits.utils
.confirmDialog(
`
Products and orders will be deleted also!
Are you sure you want to delete this stall?
`
)
.onOk(async () => {
try {
await LNbits.api.request(
'DELETE',
'/nostrmarket/api/v1/stall/' + this.stallId,
this.adminkey
)
this.$emit('stall-deleted', this.stallId)
this.$q.notify({
type: 'positive',
message: 'Stall Deleted',
timeout: 5000
})
} catch (error) {
console.warn(error)
LNbits.utils.notifyApiError(error)
}
})
},
addProductImage: function () {
if (!isValidImageUrl(this.productDialog.data.image)) {
this.$q.notify({
type: 'warning',
message: 'Not a valid image URL',
timeout: 5000
})
return
}
this.productDialog.data.images.push(this.productDialog.data.image)
this.productDialog.data.image = null
},
removeProductImage: function (imageUrl) {
const index = this.productDialog.data.images.indexOf(imageUrl)
if (index !== -1) {
this.productDialog.data.images.splice(index, 1)
}
},
getProducts: async function (pending = false) {
try {
const {data} = await LNbits.api.request(
'GET',
`/nostrmarket/api/v1/stall/product/${this.stall.id}?pending=${pending}`,
this.inkey
)
return data
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
sendProductFormData: function () {
const data = {
stall_id: this.stall.id,
id: this.productDialog.data.id,
name: this.productDialog.data.name,
images: this.productDialog.data.images,
price: this.productDialog.data.price,
quantity: this.productDialog.data.quantity,
categories: this.productDialog.data.categories,
config: this.productDialog.data.config
}
this.productDialog.showDialog = false
if (this.productDialog.data.id) {
data.pending = false
this.updateProduct(data)
} else {
this.createProduct(data)
}
},
updateProduct: async function (product) {
try {
const {data} = await LNbits.api.request(
'PATCH',
'/nostrmarket/api/v1/product/' + product.id,
this.adminkey,
product
)
const index = this.products.findIndex(r => r.id === product.id)
if (index !== -1) {
this.products.splice(index, 1, data)
} else {
this.products.unshift(data)
}
this.$q.notify({
type: 'positive',
message: 'Product Updated',
timeout: 5000
})
} catch (error) {
console.warn(error)
LNbits.utils.notifyApiError(error)
}
},
createProduct: async function (payload) {
try {
const {data} = await LNbits.api.request(
'POST',
'/nostrmarket/api/v1/product',
this.adminkey,
payload
)
this.products.unshift(data)
this.$q.notify({
type: 'positive',
message: 'Product Created',
timeout: 5000
})
} catch (error) {
console.warn(error)
LNbits.utils.notifyApiError(error)
}
},
editProduct: async function (product) {
const emptyShipping = this.newEmtpyProductData().config.shipping
this.productDialog.data = {...product}
this.productDialog.data.config.shipping = emptyShipping.map(
shippingZone => {
const existingShippingCost = (product.config.shipping || []).find(
ps => ps.id === shippingZone.id
)
shippingZone.cost = existingShippingCost?.cost || 0
return shippingZone
}
)
this.productDialog.showDialog = true
},
deleteProduct: async function (productId) {
LNbits.utils
.confirmDialog('Are you sure you want to delete this product?')
.onOk(async () => {
try {
await LNbits.api.request(
'DELETE',
'/nostrmarket/api/v1/product/' + productId,
this.adminkey
)
this.products = _.reject(this.products, function (obj) {
return obj.id === productId
})
this.$q.notify({
type: 'positive',
message: 'Product deleted',
timeout: 5000
})
} catch (error) {
console.warn(error)
LNbits.utils.notifyApiError(error)
}
})
},
showNewProductDialog: async function (data) {
this.productDialog.data = data || this.newEmtpyProductData()
this.productDialog.showDialog = true
},
openSelectPendingProductDialog: async function () {
this.productDialog.showRestore = true
this.pendingProducts = await this.getProducts(true)
},
openRestoreProductDialog: async function (pendingProduct) {
pendingProduct.pending = true
await this.showNewProductDialog(pendingProduct)
},
restoreAllPendingProducts: async function () {
for (const p of this.pendingProducts) {
p.pending = false
await this.updateProduct(p)
}
},
customerSelectedForOrder: function (customerPubkey) {
this.$emit('customer-selected-for-order', customerPubkey)
},
shortLabel(value = '') {
if (value.length <= 44) return value
return value.substring(0, 40) + '...'
}
},
created: async function () {
await this.getStall()
this.products = await this.getProducts()
this.productDialog.data = this.newEmtpyProductData()
}
})

View file

@ -1,255 +0,0 @@
<div>
<q-tabs v-model="tab" no-caps class="bg-dark text-white shadow-2">
<q-tab name="info" label="Stall Info"></q-tab>
<q-tab name="products" label="Products"></q-tab>
<q-tab name="orders" label="Orders"></q-tab>
</q-tabs>
<q-tab-panels v-model="tab">
<q-tab-panel name="info">
<div v-if="stall">
<div class="row items-center no-wrap q-mb-md">
<div class="col-3 q-pr-lg">ID:</div>
<div class="col-6 col-sm-8 q-pr-lg">
<q-input filled dense readonly disabled v-model.trim="stall.id" type="text"></q-input>
</div>
<div class="col-3 col-sm-1"></div>
</div>
<div class="row items-center no-wrap q-mb-md">
<div class="col-3 q-pr-lg">Name:</div>
<div class="col-6 col-sm-8 q-pr-lg">
<q-input filled dense v-model.trim="stall.name" type="text"></q-input>
</div>
<div class="col-3 col-sm-1"></div>
</div>
<div class="row items-center no-wrap q-mb-md">
<div class="col-3 q-pr-lg">Description:</div>
<div class="col-6 col-sm-8 q-pr-lg">
<q-input filled dense v-model.trim="stall.config.description" type="textarea" rows="3"
label="Description"></q-input>
</div>
<div class="col-3 col-sm-1"></div>
</div>
<div class="row items-center no-wrap q-mb-md">
<div class="col-3 q-pr-lg">Wallet:</div>
<div class="col-6 col-sm-8 q-pr-lg">
<q-select filled dense emit-value v-model="stall.wallet" :options="walletOptions" label="Wallet *">
</q-select>
</div>
<div class="col-3 col-sm-1"></div>
</div>
<div class="row items-center no-wrap q-mb-md">
<div class="col-3 q-pr-lg">Currency:</div>
<div class="col-6 col-sm-8 q-pr-lg">
<q-select filled dense v-model="stall.currency" type="text" label="Unit" :options="currencies"></q-select>
</div>
<div class="col-3 col-sm-1"></div>
</div>
<div class="row items-center no-wrap q-mb-md">
<div class="col-3 q-pr-lg">Shipping Zones:</div>
<div class="col-6 col-sm-8 q-pr-lg">
<q-select :options="filteredZoneOptions" filled dense multiple v-model.trim="stall.shipping_zones"
label="Shipping Zones"></q-select>
</div>
<div class="col-3 col-sm-1"></div>
</div>
</div>
<div class="row items-center q-mt-xl">
<div class="col-6 q-pr-lg">
<q-btn outline unelevated class="float-left" color="primary" @click="updateStall()">Update Stall</q-btn>
</div>
<div class="col-6">
<q-btn outline unelevated icon="cancel" class="float-right" @click="deleteStall()">Delete Stall</q-btn>
</div>
</div>
</q-tab-panel>
<q-tab-panel name="products">
<div v-if="stall">
<div class="row items-center no-wrap q-mb-md">
<div class="col-3 q-pr-lg">
<q-btn-dropdown @click="showNewProductDialog()" outline unelevated split class="float-left" color="primary"
label="New Product">
<q-item @click="showNewProductDialog()" clickable v-close-popup>
<q-item-section>
<q-item-label>New Product</q-item-label>
<q-item-label caption>Create a new product</q-item-label>
</q-item-section>
</q-item>
<q-item @click="openSelectPendingProductDialog" clickable v-close-popup>
<q-item-section>
<q-item-label>Restore Product</q-item-label>
<q-item-label caption>Restore existing product from Nostr</q-item-label>
</q-item-section>
</q-item>
</q-btn-dropdown>
</div>
<div class="col-6 col-sm-8 q-pr-lg"></div>
<div class="col-3 col-sm-1"></div>
</div>
<div class="row items-center no-wrap q-mb-md">
<div class="col-12">
<q-table flat dense :data="products" row-key="id" :columns="productsTable.columns"
:pagination.sync="productsTable.pagination" :filter="productsFilter">
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn size="sm" color="grey" dense @click="deleteProduct(props.row.id)" icon="delete" />
</q-td>
<q-td auto-width>
<q-btn size="sm" color="primary" dense @click="editProduct(props.row)" icon="edit" />
</q-td>
<q-td auto-width>
<q-toggle
@input="updateProduct({ ...props.row, active: props.row.active })"
size="xs"
checked-icon="check"
v-model="props.row.active"
color="green"
unchecked-icon="clear"
/>
</q-td>
<q-td key="id" :props="props"> {{props.row.id}} </q-td>
<q-td key="name" :props="props"> {{shortLabel(props.row.name)}} </q-td>
<q-td key="price" :props="props"> {{props.row.price}} </q-td>
<q-td key="quantity" :props="props">
{{props.row.quantity}}
</q-td>
</q-tr>
</template>
</q-table>
</div>
</div>
</div>
</q-tab-panel>
<q-tab-panel name="orders">
<div v-if="stall">
<order-list :adminkey="adminkey" :inkey="inkey" :stall-id="stallId"
@customer-selected="customerSelectedForOrder"></order-list>
</div>
</q-tab-panel>
</q-tab-panels>
<q-dialog v-model="productDialog.showDialog" position="top">
<q-card v-if="stall && productDialog.data" class="q-pa-lg q-pt-xl" style="width: 500px">
<q-form @submit="sendProductFormData" class="q-gutter-md">
<q-input filled dense v-model.trim="productDialog.data.name" label="Name"></q-input>
<q-input filled dense v-model.trim="productDialog.data.config.description" label="Description"></q-input>
<div class="row q-mb-sm">
<div class="col">
<q-input filled dense v-model.number="productDialog.data.price" type="number"
:label="'Price (' + stall.currency + ') *'" :step="stall.currency != 'sat' ? '0.01' : '1'"
:mask="stall.currency != 'sat' ? '#.##' : '#'" fill-mask="0" reverse-fill-mask></q-input>
</div>
<div class="col q-ml-md">
<q-input filled dense v-model.number="productDialog.data.quantity" type="number" label="Quantity"></q-input>
</div>
</div>
<q-expansion-item group="advanced" label="Categories"
caption="Add tags to producsts, make them easy to search.">
<div class="q-pl-sm q-pt-sm">
<q-select filled multiple dense emit-value v-model.trim="productDialog.data.categories" use-input use-chips
multiple hide-dropdown-icon input-debounce="0" new-value-mode="add-unique"
label="Categories (Hit Enter to add)" placeholder="crafts,robots,etc"></q-select>
</div>
</q-expansion-item>
<q-expansion-item group="advanced" label="Images" caption="Add images for product.">
<div class="q-pl-sm q-pt-sm">
<q-input filled dense v-model.trim="productDialog.data.image" @keydown.enter="addProductImage" type="url"
label="Image URL">
<q-btn @click="addProductImage" dense flat icon="add"></q-btn></q-input>
<q-chip v-for="imageUrl in productDialog.data.images" :key="imageUrl" removable
@remove="removeProductImage(imageUrl)" color="primary" text-color="white">
<span v-text="imageUrl.split('/').pop()"></span>
</q-chip>
</div>
</q-expansion-item>
<q-expansion-item group="advanced" label="Custom Shipping Cost"
caption="Configure custom shipping costs for this product">
<div v-for="zone of productDialog.data.config.shipping" class="row q-mb-sm q-ml-lg q-mt-sm">
<div class="col">
<span v-text="zone.name"></span>
</div>
<div class="col q-pr-md">
<q-input v-model="zone.cost" filled dense type="number" label="Extra cost">
</q-input>
</div>
</div>
</q-expansion-item>
<q-expansion-item group="advanced" label="Autoreply" caption="Autoreply when paid">
<q-card>
<q-card-section>
<div class="row q-mb-sm">
<div class="col">
<q-checkbox v-model="productDialog.data.config.use_autoreply" dense
label="Send a direct message when paid" class="q-ml-sm" />
</div>
</div>
<div class="row q-mb-sm q-ml-sm">
<div class="col">
<q-input v-model="productDialog.data.config.autoreply_message" filled dense type="textarea" rows="5"
label="Autoreply message" hint="It can include link to a digital asset">
</q-input>
</div>
</div>
</q-card-section>
</q-card>
</q-expansion-item>
<div class="row q-mt-lg">
<q-btn v-if="productDialog.data.id" type="submit"
:label="productDialog.data.pending ? 'Restore Product' : 'Update Product'" unelevated
color="primary"></q-btn>
<q-btn v-else unelevated color="primary" :disable="!productDialog.data.price
|| !productDialog.data.name
|| !productDialog.data.quantity" type="submit">Create Product</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
</div>
</q-form>
</q-card>
</q-dialog>
<q-dialog v-model="productDialog.showRestore" position="top">
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
<div v-if="pendingProducts && pendingProducts.length" class="row q-mt-lg">
<q-item v-for="pendingProduct of pendingProducts" :key="pendingProduct.id" tag="label" class="full-width"
v-ripple>
<q-item-section>
<q-item-label><span v-text="pendingProduct.name"></span></q-item-label>
<q-item-label caption><span v-text="pendingProduct.config?.description"></span></q-item-label>
</q-item-section>
<q-item-section class="q-pl-xl float-right">
<q-btn @click="openRestoreProductDialog(pendingProduct)" v-close-popup flat color="green"
class="q-ml-auto float-right">Restore</q-btn>
</q-item-section>
<q-item-section class="float-right">
<q-btn @click="deleteProduct(pendingProduct.id)" v-close-popup color="red" class="q-ml-auto float-right"
icon="cancel"></q-btn>
</q-item-section>
</q-item>
</div>
<div v-else>
There are no products to be restored.
</div>
<div class="row q-mt-lg">
<q-btn @click="restoreAllPendingProducts" v-close-popup flat color="green">Restore All</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</div>
</q-card>
</q-dialog>
</div>

View file

@ -1,334 +0,0 @@
async function stallDetails(path) {
const template = await loadTemplateAsync(path)
Vue.component('stall-details', {
name: 'stall-details',
template,
props: [
'stall-id',
'adminkey',
'inkey',
'wallet-options',
'zone-options',
'currencies'
],
data: function () {
return {
tab: 'products',
stall: null,
products: [],
pendingProducts: [],
productDialog: {
showDialog: false,
showRestore: false,
url: true,
data: null
},
productsFilter: '',
productsTable: {
columns: [
{
name: 'delete',
align: 'left',
label: '',
field: ''
},
{
name: 'edit',
align: 'left',
label: '',
field: ''
},
{
name: 'activate',
align: 'left',
label: '',
field: ''
},
{
name: 'id',
align: 'left',
label: 'ID',
field: 'id'
},
{
name: 'name',
align: 'left',
label: 'Name',
field: 'name'
},
{
name: 'price',
align: 'left',
label: 'Price',
field: 'price'
},
{
name: 'quantity',
align: 'left',
label: 'Quantity',
field: 'quantity'
}
],
pagination: {
rowsPerPage: 10
}
}
}
},
computed: {
filteredZoneOptions: function () {
if (!this.stall) return []
return this.zoneOptions.filter(z => z.currency === this.stall.currency)
}
},
methods: {
mapStall: function (stall) {
stall.shipping_zones.forEach(
z =>
(z.label = z.name
? `${z.name} (${z.countries.join(', ')})`
: z.countries.join(', '))
)
return stall
},
newEmtpyProductData: function() {
return {
id: null,
name: '',
categories: [],
images: [],
image: null,
price: 0,
quantity: 0,
config: {
description: '',
use_autoreply: false,
autoreply_message: '',
shipping: (this.stall.shipping_zones || []).map(z => ({id: z.id, name: z.name, cost: 0}))
}
}
},
getStall: async function () {
try {
const { data } = await LNbits.api.request(
'GET',
'/nostrmarket/api/v1/stall/' + this.stallId,
this.inkey
)
this.stall = this.mapStall(data)
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
updateStall: async function () {
try {
const { data } = await LNbits.api.request(
'PUT',
'/nostrmarket/api/v1/stall/' + this.stallId,
this.adminkey,
this.stall
)
this.stall = this.mapStall(data)
this.$emit('stall-updated', this.stall)
this.$q.notify({
type: 'positive',
message: 'Stall Updated',
timeout: 5000
})
} catch (error) {
console.warn(error)
LNbits.utils.notifyApiError(error)
}
},
deleteStall: function () {
LNbits.utils
.confirmDialog(
`
Products and orders will be deleted also!
Are you sure you want to delete this stall?
`
)
.onOk(async () => {
try {
await LNbits.api.request(
'DELETE',
'/nostrmarket/api/v1/stall/' + this.stallId,
this.adminkey
)
this.$emit('stall-deleted', this.stallId)
this.$q.notify({
type: 'positive',
message: 'Stall Deleted',
timeout: 5000
})
} catch (error) {
console.warn(error)
LNbits.utils.notifyApiError(error)
}
})
},
addProductImage: function () {
if (!isValidImageUrl(this.productDialog.data.image)) {
this.$q.notify({
type: 'warning',
message: 'Not a valid image URL',
timeout: 5000
})
return
}
this.productDialog.data.images.push(this.productDialog.data.image)
this.productDialog.data.image = null
},
removeProductImage: function (imageUrl) {
const index = this.productDialog.data.images.indexOf(imageUrl)
if (index !== -1) {
this.productDialog.data.images.splice(index, 1)
}
},
getProducts: async function (pending = false) {
try {
const { data } = await LNbits.api.request(
'GET',
`/nostrmarket/api/v1/stall/product/${this.stall.id}?pending=${pending}`,
this.inkey
)
return data
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
sendProductFormData: function () {
const data = {
stall_id: this.stall.id,
id: this.productDialog.data.id,
name: this.productDialog.data.name,
images: this.productDialog.data.images,
price: this.productDialog.data.price,
quantity: this.productDialog.data.quantity,
categories: this.productDialog.data.categories,
config: this.productDialog.data.config
}
this.productDialog.showDialog = false
if (this.productDialog.data.id) {
data.pending = false
this.updateProduct(data)
} else {
this.createProduct(data)
}
},
updateProduct: async function (product) {
try {
const { data } = await LNbits.api.request(
'PATCH',
'/nostrmarket/api/v1/product/' + product.id,
this.adminkey,
product
)
const index = this.products.findIndex(r => r.id === product.id)
if (index !== -1) {
this.products.splice(index, 1, data)
} else {
this.products.unshift(data)
}
this.$q.notify({
type: 'positive',
message: 'Product Updated',
timeout: 5000
})
} catch (error) {
console.warn(error)
LNbits.utils.notifyApiError(error)
}
},
createProduct: async function (payload) {
try {
const { data } = await LNbits.api.request(
'POST',
'/nostrmarket/api/v1/product',
this.adminkey,
payload
)
this.products.unshift(data)
this.$q.notify({
type: 'positive',
message: 'Product Created',
timeout: 5000
})
} catch (error) {
console.warn(error)
LNbits.utils.notifyApiError(error)
}
},
editProduct: async function (product) {
const emptyShipping = this.newEmtpyProductData().config.shipping
this.productDialog.data = { ...product }
this.productDialog.data.config.shipping = emptyShipping.map(shippingZone => {
const existingShippingCost = (product.config.shipping || []).find(ps => ps.id === shippingZone.id)
shippingZone.cost = existingShippingCost?.cost || 0
return shippingZone
})
this.productDialog.showDialog = true
},
deleteProduct: async function (productId) {
LNbits.utils
.confirmDialog('Are you sure you want to delete this product?')
.onOk(async () => {
try {
await LNbits.api.request(
'DELETE',
'/nostrmarket/api/v1/product/' + productId,
this.adminkey
)
this.products = _.reject(this.products, function (obj) {
return obj.id === productId
})
this.$q.notify({
type: 'positive',
message: 'Product deleted',
timeout: 5000
})
} catch (error) {
console.warn(error)
LNbits.utils.notifyApiError(error)
}
})
},
showNewProductDialog: async function (data) {
this.productDialog.data = data || this.newEmtpyProductData()
this.productDialog.showDialog = true
},
openSelectPendingProductDialog: async function () {
this.productDialog.showRestore = true
this.pendingProducts = await this.getProducts(true)
},
openRestoreProductDialog: async function (pendingProduct) {
pendingProduct.pending = true
await this.showNewProductDialog(pendingProduct)
},
restoreAllPendingProducts: async function () {
for (const p of this.pendingProducts){
p.pending = false
await this.updateProduct(p)
}
},
customerSelectedForOrder: function (customerPubkey) {
this.$emit('customer-selected-for-order', customerPubkey)
},
shortLabel(value = ''){
if (value.length <= 44) return value
return value.substring(0, 40) + '...'
}
},
created: async function () {
await this.getStall()
this.products = await this.getProducts()
this.productDialog.data = this.newEmtpyProductData()
}
})
}

View file

@ -0,0 +1,262 @@
window.app.component('stall-list', {
name: 'stall-list',
template: '#stall-list',
delimiters: ['${', '}'],
props: [`adminkey`, 'inkey', 'wallet-options'],
data: function () {
return {
filter: '',
stalls: [],
pendingStalls: [],
currencies: [],
stallDialog: {
show: false,
showRestore: false,
data: {
name: '',
description: '',
wallet: null,
currency: 'sat',
shippingZones: []
}
},
zoneOptions: [],
stallsTable: {
columns: [
{
name: '',
align: 'left',
label: '',
field: ''
},
{
name: 'id',
align: 'left',
label: 'Name',
field: 'id'
},
{
name: 'currency',
align: 'left',
label: 'Currency',
field: 'currency'
},
{
name: 'description',
align: 'left',
label: 'Description',
field: 'description'
},
{
name: 'shippingZones',
align: 'left',
label: 'Shipping Zones',
field: 'shippingZones'
}
],
pagination: {
rowsPerPage: 10
}
}
}
},
computed: {
filteredZoneOptions: function () {
return this.zoneOptions.filter(
z => z.currency === this.stallDialog.data.currency
)
}
},
methods: {
sendStallFormData: async function () {
const stallData = {
name: this.stallDialog.data.name,
wallet: this.stallDialog.data.wallet,
currency: this.stallDialog.data.currency,
shipping_zones: this.stallDialog.data.shippingZones,
config: {
description: this.stallDialog.data.description
}
}
if (this.stallDialog.data.id) {
stallData.id = this.stallDialog.data.id
await this.restoreStall(stallData)
} else {
await this.createStall(stallData)
}
},
createStall: async function (stall) {
try {
const {data} = await LNbits.api.request(
'POST',
'/nostrmarket/api/v1/stall',
this.adminkey,
stall
)
this.stallDialog.show = false
data.expanded = false
this.stalls.unshift(data)
this.$q.notify({
type: 'positive',
message: 'Stall created!'
})
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
restoreStall: async function (stallData) {
try {
stallData.pending = false
const {data} = await LNbits.api.request(
'PUT',
`/nostrmarket/api/v1/stall/${stallData.id}`,
this.adminkey,
stallData
)
this.stallDialog.show = false
data.expanded = false
this.stalls.unshift(data)
this.$q.notify({
type: 'positive',
message: 'Stall restored!'
})
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
deleteStall: async function (pendingStall) {
LNbits.utils
.confirmDialog(
`
Are you sure you want to delete this pending stall '${pendingStall.name}'?
`
)
.onOk(async () => {
try {
await LNbits.api.request(
'DELETE',
'/nostrmarket/api/v1/stall/' + pendingStall.id,
this.adminkey
)
this.$q.notify({
type: 'positive',
message: 'Pending Stall Deleted',
timeout: 5000
})
} catch (error) {
console.warn(error)
LNbits.utils.notifyApiError(error)
}
})
},
getCurrencies: async function () {
try {
const {data} = await LNbits.api.request(
'GET',
'/nostrmarket/api/v1/currencies',
this.inkey
)
return ['sat', ...data]
} catch (error) {
LNbits.utils.notifyApiError(error)
}
return []
},
getStalls: async function (pending = false) {
try {
const {data} = await LNbits.api.request(
'GET',
`/nostrmarket/api/v1/stall?pending=${pending}`,
this.inkey
)
return data.map(s => ({...s, expanded: false}))
} catch (error) {
LNbits.utils.notifyApiError(error)
}
return []
},
getZones: async function () {
try {
const {data} = await LNbits.api.request(
'GET',
'/nostrmarket/api/v1/zone',
this.inkey
)
return data.map(z => ({
...z,
label: z.name
? `${z.name} (${z.countries.join(', ')})`
: z.countries.join(', ')
}))
} catch (error) {
LNbits.utils.notifyApiError(error)
}
return []
},
handleStallDeleted: function (stallId) {
this.stalls = _.reject(this.stalls, function (obj) {
return obj.id === stallId
})
},
handleStallUpdated: function (stall) {
const index = this.stalls.findIndex(r => r.id === stall.id)
if (index !== -1) {
stall.expanded = true
this.stalls.splice(index, 1, stall)
}
},
openCreateStallDialog: async function (stallData) {
this.currencies = await this.getCurrencies()
this.zoneOptions = await this.getZones()
if (!this.zoneOptions || !this.zoneOptions.length) {
this.$q.notify({
type: 'warning',
message: 'Please create a Shipping Zone first!'
})
return
}
this.stallDialog.data = stallData || {
name: '',
description: '',
wallet: null,
currency: 'sat',
shippingZones: []
}
this.stallDialog.show = true
},
openSelectPendingStallDialog: async function () {
this.stallDialog.showRestore = true
this.pendingStalls = await this.getStalls(true)
},
openRestoreStallDialog: async function (pendingStall) {
const shippingZonesIds = this.zoneOptions.map(z => z.id)
await this.openCreateStallDialog({
id: pendingStall.id,
name: pendingStall.name,
description: pendingStall.config?.description,
currency: pendingStall.currency,
shippingZones: (pendingStall.shipping_zones || [])
.filter(z => shippingZonesIds.indexOf(z.id) !== -1)
.map(z => ({
...z,
label: z.name
? `${z.name} (${z.countries.join(', ')})`
: z.countries.join(', ')
}))
})
},
customerSelectedForOrder: function (customerPubkey) {
this.$emit('customer-selected-for-order', customerPubkey)
},
shortLabel(value = '') {
if (value.length <= 64) return value
return value.substring(0, 60) + '...'
}
},
created: async function () {
this.stalls = await this.getStalls()
this.currencies = await this.getCurrencies()
this.zoneOptions = await this.getZones()
}
})

View file

@ -1,117 +0,0 @@
<div>
<div class="row items-center no-wrap q-mb-md">
<div class="col q-pr-lg">
<q-btn-dropdown @click="openCreateStallDialog()" outline unelevated split class="float-left" color="primary"
label="New Stall (Store)">
<q-item @click="openCreateStallDialog()" clickable v-close-popup>
<q-item-section>
<q-item-label>New Stall</q-item-label>
<q-item-label caption>Create a new stall</q-item-label>
</q-item-section>
</q-item>
<q-item @click="openSelectPendingStallDialog" clickable v-close-popup>
<q-item-section>
<q-item-label>Restore Stall</q-item-label>
<q-item-label caption>Restore existing stall from Nostr</q-item-label>
</q-item-section>
</q-item>
</q-btn-dropdown>
<q-input borderless dense debounce="300" v-model="filter" placeholder="Search" class="float-right">
<template v-slot:append>
<q-icon name="search"></q-icon>
</template>
</q-input>
</div>
</div>
<q-table flat dense :data="stalls" row-key="id" :columns="stallsTable.columns"
:pagination.sync="stallsTable.pagination" :filter="filter">
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn size="sm" color="primary" round dense @click="props.row.expanded= !props.row.expanded"
:icon="props.row.expanded? 'remove' : 'add'" />
</q-td>
<q-td key="id" :props="props"> {{shortLabel(props.row.name)}} </q-td>
<q-td key="currency" :props="props"> {{props.row.currency}} </q-td>
<q-td key="description" :props="props">
{{shortLabel(props.row.config.description)}}
</q-td>
<q-td key="shippingZones" :props="props">
<div>
{{shortLabel(props.row.shipping_zones.filter(z => !!z.name).map(z => z.name).join(', '))}}
</div>
</q-td>
</q-tr>
<q-tr v-if="props.row.expanded" :props="props">
<q-td colspan="100%">
<div class="row items-center q-mb-lg">
<div class="col-12">
<stall-details :stall-id="props.row.id" :adminkey="adminkey" :inkey="inkey"
:wallet-options="walletOptions" :zone-options="zoneOptions" :currencies="currencies"
@stall-deleted="handleStallDeleted" @stall-updated="handleStallUpdated"
@customer-selected-for-order="customerSelectedForOrder"></stall-details>
</div>
</div>
</q-td>
</q-tr>
</template>
</q-table>
<div>
<q-dialog v-model="stallDialog.show" position="top">
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
<q-form @submit="sendStallFormData" class="q-gutter-md">
<q-input filled dense v-model.trim="stallDialog.data.name" label="Name"></q-input>
<q-input filled dense v-model.trim="stallDialog.data.description" type="textarea" rows="3"
label="Description"></q-input>
<q-select filled dense emit-value v-model="stallDialog.data.wallet" :options="walletOptions" label="Wallet *">
</q-select>
<q-select filled dense v-model="stallDialog.data.currency" type="text" label="Unit"
:options="currencies"></q-select>
<q-select :options="filteredZoneOptions" filled dense multiple v-model.trim="stallDialog.data.shippingZones"
label="Shipping Zones"></q-select>
<div class="row q-mt-lg">
<q-btn unelevated color="primary" :disable="!stallDialog.data.name
|| !stallDialog.data.currency
|| !stallDialog.data.wallet
|| !stallDialog.data.shippingZones
|| !stallDialog.data.shippingZones.length" type="submit"
:label="stallDialog.data.id ? 'Restore Stall' : 'Create Stall'"></q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
</div>
</q-form>
</q-card>
</q-dialog>
<q-dialog v-model="stallDialog.showRestore" position="top">
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
<div v-if="pendingStalls && pendingStalls.length" class="row q-mt-lg">
<q-item v-for="pendingStall of pendingStalls" :key="pendingStall.id" tag="label" class="full-width" v-ripple>
<q-item-section>
<q-item-label><span v-text="pendingStall.name"></span></q-item-label>
<q-item-label caption><span v-text="pendingStall.config?.description"></span></q-item-label>
</q-item-section>
<q-item-section class="q-pl-xl float-right">
<q-btn @click="openRestoreStallDialog(pendingStall)" v-close-popup flat color="green"
class="q-ml-auto float-right">Restore</q-btn>
</q-item-section>
<q-item-section class="float-right">
<q-btn @click="deleteStall(pendingStall)" v-close-popup color="red" class="q-ml-auto float-right"
icon="cancel"></q-btn>
</q-item-section>
</q-item>
</div>
<div v-else>
There are no stalls to be restored.
</div>
<div class="row q-mt-lg">
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</div>
</q-card>
</q-dialog>
</div>
</div>

View file

@ -1,266 +0,0 @@
async function stallList(path) {
const template = await loadTemplateAsync(path)
Vue.component('stall-list', {
name: 'stall-list',
template,
props: [`adminkey`, 'inkey', 'wallet-options'],
data: function () {
return {
filter: '',
stalls: [],
pendingStalls: [],
currencies: [],
stallDialog: {
show: false,
showRestore: false,
data: {
name: '',
description: '',
wallet: null,
currency: 'sat',
shippingZones: []
}
},
zoneOptions: [],
stallsTable: {
columns: [
{
name: '',
align: 'left',
label: '',
field: ''
},
{
name: 'id',
align: 'left',
label: 'Name',
field: 'id'
},
{
name: 'currency',
align: 'left',
label: 'Currency',
field: 'currency'
},
{
name: 'description',
align: 'left',
label: 'Description',
field: 'description'
},
{
name: 'shippingZones',
align: 'left',
label: 'Shipping Zones',
field: 'shippingZones'
}
],
pagination: {
rowsPerPage: 10
}
}
}
},
computed: {
filteredZoneOptions: function () {
return this.zoneOptions.filter(
z => z.currency === this.stallDialog.data.currency
)
}
},
methods: {
sendStallFormData: async function () {
const stallData = {
name: this.stallDialog.data.name,
wallet: this.stallDialog.data.wallet,
currency: this.stallDialog.data.currency,
shipping_zones: this.stallDialog.data.shippingZones,
config: {
description: this.stallDialog.data.description
}
}
if (this.stallDialog.data.id) {
stallData.id = this.stallDialog.data.id
await this.restoreStall(stallData)
} else {
await this.createStall(stallData)
}
},
createStall: async function (stall) {
try {
const { data } = await LNbits.api.request(
'POST',
'/nostrmarket/api/v1/stall',
this.adminkey,
stall
)
this.stallDialog.show = false
data.expanded = false
this.stalls.unshift(data)
this.$q.notify({
type: 'positive',
message: 'Stall created!'
})
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
restoreStall: async function (stallData) {
try {
stallData.pending = false
const { data } = await LNbits.api.request(
'PUT',
`/nostrmarket/api/v1/stall/${stallData.id}`,
this.adminkey,
stallData
)
this.stallDialog.show = false
data.expanded = false
this.stalls.unshift(data)
this.$q.notify({
type: 'positive',
message: 'Stall restored!'
})
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
deleteStall: async function (pendingStall) {
LNbits.utils
.confirmDialog(
`
Are you sure you want to delete this pending stall '${pendingStall.name}'?
`
)
.onOk(async () => {
try {
await LNbits.api.request(
'DELETE',
'/nostrmarket/api/v1/stall/' + pendingStall.id,
this.adminkey
)
this.$q.notify({
type: 'positive',
message: 'Pending Stall Deleted',
timeout: 5000
})
} catch (error) {
console.warn(error)
LNbits.utils.notifyApiError(error)
}
})
},
getCurrencies: async function () {
try {
const { data } = await LNbits.api.request(
'GET',
'/nostrmarket/api/v1/currencies',
this.inkey
)
return ['sat', ...data]
} catch (error) {
LNbits.utils.notifyApiError(error)
}
return []
},
getStalls: async function (pending = false) {
try {
const { data } = await LNbits.api.request(
'GET',
`/nostrmarket/api/v1/stall?pending=${pending}`,
this.inkey
)
return data.map(s => ({ ...s, expanded: false }))
} catch (error) {
LNbits.utils.notifyApiError(error)
}
return []
},
getZones: async function () {
try {
const { data } = await LNbits.api.request(
'GET',
'/nostrmarket/api/v1/zone',
this.inkey
)
return data.map(z => ({
...z,
label: z.name
? `${z.name} (${z.countries.join(', ')})`
: z.countries.join(', ')
}))
} catch (error) {
LNbits.utils.notifyApiError(error)
}
return []
},
handleStallDeleted: function (stallId) {
this.stalls = _.reject(this.stalls, function (obj) {
return obj.id === stallId
})
},
handleStallUpdated: function (stall) {
const index = this.stalls.findIndex(r => r.id === stall.id)
if (index !== -1) {
stall.expanded = true
this.stalls.splice(index, 1, stall)
}
},
openCreateStallDialog: async function (stallData) {
this.currencies = await this.getCurrencies()
this.zoneOptions = await this.getZones()
if (!this.zoneOptions || !this.zoneOptions.length) {
this.$q.notify({
type: 'warning',
message: 'Please create a Shipping Zone first!'
})
return
}
this.stallDialog.data = stallData || {
name: '',
description: '',
wallet: null,
currency: 'sat',
shippingZones: []
}
this.stallDialog.show = true
},
openSelectPendingStallDialog: async function () {
this.stallDialog.showRestore = true
this.pendingStalls = await this.getStalls(true)
},
openRestoreStallDialog: async function (pendingStall) {
const shippingZonesIds = this.zoneOptions.map(z => z.id)
await this.openCreateStallDialog({
id: pendingStall.id,
name: pendingStall.name,
description: pendingStall.config?.description,
currency: pendingStall.currency,
shippingZones: (pendingStall.shipping_zones || [])
.filter(z => shippingZonesIds.indexOf(z.id) !== -1)
.map(z => ({
...z,
label: z.name
? `${z.name} (${z.countries.join(', ')})`
: z.countries.join(', ')
}))
})
},
customerSelectedForOrder: function (customerPubkey) {
this.$emit('customer-selected-for-order', customerPubkey)
},
shortLabel(value = ''){
if (value.length <= 64) return value
return value.substring(0, 60) + '...'
}
},
created: async function () {
this.stalls = await this.getStalls()
this.currencies = await this.getCurrencies()
this.zoneOptions = await this.getZones()
}
})
}

View file

@ -1,241 +1,228 @@
const merchant = async () => {
Vue.component(VueQrcode.name, VueQrcode)
const nostr = window.NostrTools
await keyPair('static/components/key-pair/key-pair.html')
await shippingZones('static/components/shipping-zones/shipping-zones.html')
await stallDetails('static/components/stall-details/stall-details.html')
await stallList('static/components/stall-list/stall-list.html')
await orderList('static/components/order-list/order-list.html')
await directMessages('static/components/direct-messages/direct-messages.html')
await merchantDetails(
'static/components/merchant-details/merchant-details.html'
)
const nostr = window.NostrTools
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
merchant: {},
shippingZones: [],
activeChatCustomer: '',
orderPubkey: null,
showKeys: false,
importKeyDialog: {
show: false,
data: {
privateKey: null
}
},
wsConnection: null
}
},
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}`
})
}
await this.createMerchant(privateKey)
},
showImportKeysDialog: async function () {
this.importKeyDialog.show = true
},
toggleShowKeys: function () {
this.showKeys = !this.showKeys
},
toggleMerchantState: async function () {
const merchant = await this.getMerchant()
if (!merchant) {
this.$q.notify({
timeout: 5000,
type: 'warning',
message: "Cannot fetch merchant!"
})
return
}
const message = merchant.config.active ?
'New orders will not be processed. Are you sure you want to deactivate?' :
merchant.config.restore_in_progress ?
'Merchant restore from nostr in progress. Please wait!! ' +
'Activating now can lead to duplicate order processing. Click "OK" if you want to activate anyway?' :
'Are you sure you want activate this merchant?'
LNbits.utils
.confirmDialog(message)
.onOk(async () => {
await this.toggleMerchant()
})
},
toggleMerchant: async function () {
try {
const { data } = await LNbits.api.request(
'PUT',
`/nostrmarket/api/v1/merchant/${this.merchant.id}/toggle`,
this.g.user.wallets[0].adminkey,
)
const state = data.config.active ? 'activated' : 'disabled'
this.merchant = data
this.$q.notify({
type: 'positive',
message: `'Merchant ${state}`,
timeout: 5000
})
} catch (error) {
console.warn(error)
LNbits.utils.notifyApiError(error)
window.app = Vue.createApp({
el: '#vue',
mixins: [window.windowMixin],
data: function () {
return {
merchant: {},
shippingZones: [],
activeChatCustomer: '',
orderPubkey: null,
showKeys: false,
importKeyDialog: {
show: false,
data: {
privateKey: null
}
},
handleMerchantDeleted: function () {
this.merchant = null
this.shippingZones = []
this.activeChatCustomer = ''
this.showKeys = false
},
createMerchant: async function (privateKey) {
try {
const pubkey = nostr.getPublicKey(privateKey)
const payload = {
private_key: privateKey,
public_key: pubkey,
config: {}
}
const { data } = await LNbits.api.request(
'POST',
'/nostrmarket/api/v1/merchant',
this.g.user.wallets[0].adminkey,
payload
)
this.merchant = data
this.$q.notify({
type: 'positive',
message: 'Merchant Created!'
})
this.waitForNotifications()
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
getMerchant: async function () {
try {
const { data } = await LNbits.api.request(
'GET',
'/nostrmarket/api/v1/merchant',
this.g.user.wallets[0].inkey
)
this.merchant = data
return data
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
customerSelectedForOrder: function (customerPubkey) {
this.activeChatCustomer = customerPubkey
},
filterOrdersForCustomer: function (customerPubkey) {
this.orderPubkey = customerPubkey
},
showOrderDetails: async function (orderData) {
await this.$refs.orderListRef.orderSelected(orderData.orderId, orderData.eventId)
},
waitForNotifications: async function () {
if (!this.merchant) return
try {
const scheme = location.protocol === 'http:' ? 'ws' : 'wss'
const port = location.port ? `:${location.port}` : ''
const wsUrl = `${scheme}://${document.domain}${port}/api/v1/ws/${this.merchant.id}`
console.log('Reconnecting to websocket: ', wsUrl)
this.wsConnection = new WebSocket(wsUrl)
this.wsConnection.onmessage = async e => {
const data = JSON.parse(e.data)
if (data.type === 'dm:0') {
this.$q.notify({
timeout: 5000,
type: 'positive',
message: 'New Order'
})
await this.$refs.directMessagesRef.handleNewMessage(data)
return
}
if (data.type === 'dm:1') {
await this.$refs.directMessagesRef.handleNewMessage(data)
await this.$refs.orderListRef.addOrder(data)
return
}
if (data.type === 'dm:2') {
const orderStatus = JSON.parse(data.dm.message)
this.$q.notify({
timeout: 5000,
type: 'positive',
message: orderStatus.message
})
if (orderStatus.paid) {
await this.$refs.orderListRef.orderPaid(orderStatus.id)
}
await this.$refs.directMessagesRef.handleNewMessage(data)
return
}
if (data.type === 'dm:-1') {
await this.$refs.directMessagesRef.handleNewMessage(data)
}
// order paid
// order shipped
}
} catch (error) {
this.$q.notify({
timeout: 5000,
type: 'warning',
message: 'Failed to watch for updates',
caption: `${error}`
})
}
},
restartNostrConnection: async function () {
LNbits.utils
.confirmDialog(
'Are you sure you want to reconnect to the nostrcient extension?'
)
.onOk(async () => {
try {
await LNbits.api.request(
'PUT',
'/nostrmarket/api/v1/restart',
this.g.user.wallets[0].adminkey
)
} catch (error) {
LNbits.utils.notifyApiError(error)
}
})
}
},
created: async function () {
await this.getMerchant()
setInterval(async () => {
if (!this.wsConnection || this.wsConnection.readyState !== WebSocket.OPEN) {
await this.waitForNotifications()
}
}, 1000)
wsConnection: null
}
})
}
},
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}`
})
}
await this.createMerchant(privateKey)
},
showImportKeysDialog: async function () {
this.importKeyDialog.show = true
},
toggleShowKeys: function () {
this.showKeys = !this.showKeys
},
toggleMerchantState: async function () {
const merchant = await this.getMerchant()
if (!merchant) {
this.$q.notify({
timeout: 5000,
type: 'warning',
message: 'Cannot fetch merchant!'
})
return
}
const message = merchant.config.active
? 'New orders will not be processed. Are you sure you want to deactivate?'
: merchant.config.restore_in_progress
? 'Merchant restore from nostr in progress. Please wait!! ' +
'Activating now can lead to duplicate order processing. Click "OK" if you want to activate anyway?'
: 'Are you sure you want activate this merchant?'
merchant()
LNbits.utils.confirmDialog(message).onOk(async () => {
await this.toggleMerchant()
})
},
toggleMerchant: async function () {
try {
const {data} = await LNbits.api.request(
'PUT',
`/nostrmarket/api/v1/merchant/${this.merchant.id}/toggle`,
this.g.user.wallets[0].adminkey
)
const state = data.config.active ? 'activated' : 'disabled'
this.merchant = data
this.$q.notify({
type: 'positive',
message: `'Merchant ${state}`,
timeout: 5000
})
} catch (error) {
console.warn(error)
LNbits.utils.notifyApiError(error)
}
},
handleMerchantDeleted: function () {
this.merchant = null
this.shippingZones = []
this.activeChatCustomer = ''
this.showKeys = false
},
createMerchant: async function (privateKey) {
try {
const pubkey = nostr.getPublicKey(privateKey)
const payload = {
private_key: privateKey,
public_key: pubkey,
config: {}
}
const {data} = await LNbits.api.request(
'POST',
'/nostrmarket/api/v1/merchant',
this.g.user.wallets[0].adminkey,
payload
)
this.merchant = data
this.$q.notify({
type: 'positive',
message: 'Merchant Created!'
})
this.waitForNotifications()
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
getMerchant: async function () {
try {
const {data} = await LNbits.api.request(
'GET',
'/nostrmarket/api/v1/merchant',
this.g.user.wallets[0].inkey
)
this.merchant = data
return data
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
customerSelectedForOrder: function (customerPubkey) {
this.activeChatCustomer = customerPubkey
},
filterOrdersForCustomer: function (customerPubkey) {
this.orderPubkey = customerPubkey
},
showOrderDetails: async function (orderData) {
await this.$refs.orderListRef.orderSelected(
orderData.orderId,
orderData.eventId
)
},
waitForNotifications: async function () {
if (!this.merchant) return
try {
const scheme = location.protocol === 'http:' ? 'ws' : 'wss'
const port = location.port ? `:${location.port}` : ''
const wsUrl = `${scheme}://${document.domain}${port}/api/v1/ws/${this.merchant.id}`
console.log('Reconnecting to websocket: ', wsUrl)
this.wsConnection = new WebSocket(wsUrl)
this.wsConnection.onmessage = async e => {
const data = JSON.parse(e.data)
if (data.type === 'dm:0') {
this.$q.notify({
timeout: 5000,
type: 'positive',
message: 'New Order'
})
await this.$refs.directMessagesRef.handleNewMessage(data)
return
}
if (data.type === 'dm:1') {
await this.$refs.directMessagesRef.handleNewMessage(data)
await this.$refs.orderListRef.addOrder(data)
return
}
if (data.type === 'dm:2') {
const orderStatus = JSON.parse(data.dm.message)
this.$q.notify({
timeout: 5000,
type: 'positive',
message: orderStatus.message
})
if (orderStatus.paid) {
await this.$refs.orderListRef.orderPaid(orderStatus.id)
}
await this.$refs.directMessagesRef.handleNewMessage(data)
return
}
if (data.type === 'dm:-1') {
await this.$refs.directMessagesRef.handleNewMessage(data)
}
// order paid
// order shipped
}
} catch (error) {
this.$q.notify({
timeout: 5000,
type: 'warning',
message: 'Failed to watch for updates',
caption: `${error}`
})
}
},
restartNostrConnection: async function () {
LNbits.utils
.confirmDialog(
'Are you sure you want to reconnect to the nostrcient extension?'
)
.onOk(async () => {
try {
await LNbits.api.request(
'PUT',
'/nostrmarket/api/v1/restart',
this.g.user.wallets[0].adminkey
)
} catch (error) {
LNbits.utils.notifyApiError(error)
}
})
}
},
created: async function () {
await this.getMerchant()
setInterval(async () => {
if (
!this.wsConnection ||
this.wsConnection.readyState !== WebSocket.OPEN
) {
await this.waitForNotifications()
}
}, 1000)
}
})

View file

@ -23,7 +23,7 @@ function imgSizeFit(img, maxWidth = 1024, maxHeight = 768) {
maxWidth / img.naturalWidth,
maxHeight / img.naturalHeight
)
return { width: img.naturalWidth * ratio, height: img.naturalHeight * ratio }
return {width: img.naturalWidth * ratio, height: img.naturalHeight * ratio}
}
async function hash(string) {
@ -125,7 +125,7 @@ function isValidImageUrl(string) {
function isValidKey(key, prefix = 'n') {
try {
if (key && key.startsWith(prefix)) {
let { _, data } = NostrTools.nip19.decode(key)
let {_, data} = NostrTools.nip19.decode(key)
key = data
}
return isValidKeyHex(key)
@ -143,4 +143,4 @@ function formatCurrency(value, currency) {
style: 'currency',
currency: currency
}).format(value)
}
}

View file

@ -11,11 +11,11 @@
<script src="/nostrmarket/static/market/js/nostr.bundle.js"></script>
<script src="/nostrmarket/static/market/js/bolt11-decoder.js"></script>
<script src="/nostrmarket/static/market/js/utils.js"></script>
<link rel=icon type=image/png sizes=128x128 href="/nostrmarket/static/market/icons/favicon-128x128.png">
<link rel=icon type=image/png sizes=96x96 href="/nostrmarket/static/market/icons/favicon-96x96.png">
<link rel=icon type=image/png sizes=32x32 href="/nostrmarket/static/market/icons/favicon-32x32.png">
<link rel=icon type=image/png sizes=16x16 href="/nostrmarket/static/market/icons/favicon-16x16.png">
<link rel=icon type=image/ico href="/nostrmarket/static/market/favicon.ico">
<link rel="icon" type="image/png" sizes="128x128" href="/nostrmarket/static/market/icons/favicon-128x128.png">
<link rel="icon" type="image/png" sizes="96x96" href="/nostrmarket/static/market/icons/favicon-96x96.png">
<link rel="icon" type="image/png" sizes="32x32" href="/nostrmarket/static/market/icons/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/nostrmarket/static/market/icons/favicon-16x16.png">
<link rel="icon" type="image/ico" href="/nostrmarket/static/market/favicon.ico">
<script type="module" crossorigin src="/nostrmarket/static/market/assets/index.923cbbf9.js"></script>
<link rel="stylesheet" href="/nostrmarket/static/market/assets/index.73d462e5.css">
</head>

View file

@ -1,10 +1,9 @@
from asyncio import Queue
import asyncio
from loguru import logger
from asyncio import Queue
from lnbits.core.models import Payment
from lnbits.tasks import register_invoice_listener
from loguru import logger
from .nostr.nostr_client import NostrClient
from .services import (

View file

@ -0,0 +1,169 @@
<div>
<q-card>
<q-card-section>
<div class="row">
<div class="col-2">
<h6 class="text-subtitle1 q-my-none">Messages</h6>
</div>
<div class="col-4">
<q-badge v-if="unreadMessages" color="primary" outline
><span v-text="unreadMessages"></span>&nbsp; new</q-badge
>
</div>
<div class="col-6">
<q-btn
v-if="activePublicKey"
@click="showClientOrders"
unelevated
outline
class="float-right"
>Client Orders</q-btn
>
</div>
</div>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
</q-card-section>
<q-card-section>
<div class="row">
<div class="col-10">
<q-select
v-model="activePublicKey"
:options="customers.map(c => ({label: buildCustomerLabel(c), value: c.public_key}))"
label="Select Customer"
emit-value
@input="selectActiveCustomer()"
>
</q-select>
</div>
<div class="col-2">
<q-btn
label="Add"
color="primary"
class="float-right q-mt-md"
@click="showAddPublicKey = true"
>
<q-tooltip> Add a public key to chat with </q-tooltip>
</q-btn>
</div>
</div>
</q-card-section>
<q-card-section>
<div class="chat-container" ref="chatCard">
<div class="chat-box">
<div class="chat-messages" style="height: 45vh">
<q-chat-message
v-for="(dm, index) in messagesAsJson"
:key="index"
:name="dm.incoming ? 'customer': 'me'"
: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-card>
<div>
<q-dialog v-model="showAddPublicKey" position="top">
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
<q-form @submit="addPublicKey" class="q-gutter-md">
<q-input
filled
dense
v-model.trim="newPublicKey"
label="Public Key (hex or nsec)"
></q-input>
<div class="row q-mt-lg">
<q-btn
unelevated
color="primary"
:disable="!newPublicKey"
type="submit"
>Add</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
<q-dialog v-model="showRawMessage" position="top">
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
<q-form>
<q-input
filled
dense
type="textarea"
rows="20"
v-model.trim="rawMessage"
label="Raw Data"
></q-input>
<div class="row q-mt-lg">
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Close</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
</div>
</div>

View file

@ -13,11 +13,11 @@
<div class="col-6">
<div class="text-center q-mb-lg cursor-pointer">
<q-responsive :ratio="1" class="q-mx-xl" @click="copyText(publicKey)">
<qrcode
<lnbits-qrcode
:value="publicKey"
:options="{width: 250}"
class="rounded-borders"
></qrcode>
></lnbits-qrcode>
</q-responsive>
<small><span v-text="publicKey"></span><br />Click to copy</small>
</div>
@ -30,11 +30,7 @@
class="q-mx-xl"
@click="copyText(privateKey)"
>
<qrcode
:value="privateKey"
:options="{width: 250}"
class="rounded-borders"
></qrcode>
<lnbits-qrcode :value="privateKey"></lnbits-qrcode>
</q-responsive>
<small><span v-text="privateKey"></span><br />Click to copy</small>
</div>

View file

@ -0,0 +1,369 @@
<div>
<div class="row q-mb-md">
<div class="col-md-4 col-sm-6 q-pr-lg">
<q-select
v-model="search.publicKey"
:options="customerOptions"
label="Customer"
emit-value
class="text-wrap"
>
</q-select>
</div>
<div class="col-md-2 col-sm-6 q-pr-lg">
<q-select
v-model="search.isPaid"
:options="ternaryOptions"
label="Paid"
emit-value
>
</q-select>
</div>
<div class="col-md-2 col-sm-6 q-pr-lg">
<q-select
v-model="search.isShipped"
:options="ternaryOptions"
label="Shipped"
emit-value
>
</q-select>
</div>
<div class="col-md-4 col-sm-6">
<q-btn-dropdown
@click="getOrders()"
:disable="search.restoring"
outline
unelevated
split
class="q-pt-md float-right"
:label="search.restoring ? 'Restoring Orders...' : 'Load Orders'"
>
<q-spinner
v-if="search.restoring"
color="primary"
size="2.55em"
class="q-pt-md float-right"
></q-spinner>
<q-item @click="restoreOrders" clickable v-close-popup>
<q-item-section>
<q-item-label>Restore Orders</q-item-label>
<q-item-label caption
>Restore previous orders from Nostr</q-item-label
>
</q-item-section>
</q-item>
</q-btn-dropdown>
</div>
</div>
<div class="row q-mt-md">
<div class="col">
<q-table
flat
dense
:rows="orders"
row-key="id"
:columns="ordersTable.columns"
v-model:pagination="ordersTable.pagination"
:filter="filter"
>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn
size="sm"
color="primary"
round
dense
@click="props.row.expanded= !props.row.expanded"
:icon="props.row.expanded? 'remove' : 'add'"
/>
</q-td>
<q-td key="id" :props="props">
<span v-text="toShortId(props.row.id)"></span>
<q-badge v-if="props.row.isNew" color="orange">new</q-badge></q-td
>
<q-td key="total" :props="props">
<span v-text="satBtc(props.row.total)"></span>
</q-td>
<q-td key="fiat" :props="props">
<span v-if="props.row.extra.currency !== 'sat'">
<span v-text="orderTotal(props.row)"></span
><span v-text="props.row.extra.currency"></span>
</span>
</q-td>
<q-td key="paid" :props="props">
<q-checkbox
v-model="props.row.paid"
:label="props.row.paid ? 'Yes' : 'No'"
disable
readonly
size="sm"
></q-checkbox>
</q-td>
<q-td key="shipped" :props="props">
<q-checkbox
v-model="props.row.shipped"
@update:model-value="showShipOrderDialog(props.row)"
:label="props.row.shipped ? 'Yes' : 'No'"
size="sm"
></q-checkbox>
</q-td>
<q-td key="public_key" :props="props">
<span
@click="customerSelected(props.row.public_key)"
class="cursor-pointer"
>
<span v-text="toShortId(props.row.public_key)"></span>
</span>
</q-td>
<q-td key="event_created_at" :props="props">
<span v-text="formatDate(props.row.event_created_at)"></span>
</q-td>
</q-tr>
<q-tr v-if="props.row.expanded" :props="props">
<q-td colspan="100%">
<div class="row items-center no-wrap">
<div class="col-3 q-pr-lg">Products:</div>
<div class="col-8">
<div class="row items-center no-wrap q-mb-md">
<div class="col-1"><strong>Quantity</strong></div>
<div class="col-1"></div>
<div class="col-4"><strong>Name</strong></div>
<div class="col-2"><strong>Price</strong></div>
<div class="col-4"></div>
</div>
</div>
<div class="col-1"></div>
</div>
<div class="row items-center no-wrap q-mb-md">
<div class="col-3 q-pr-lg"></div>
<div class="col-8">
<div
v-for="item in props.row.items"
class="row items-center no-wrap q-mb-md"
>
<div class="col-1">
<span v-text="item.quantity"></span>
</div>
<div class="col-1">x</div>
<div class="col-4">
<p :title="productName(props.row, item.product_id)">
<span
v-text="shortLabel(productName(props.row, item.product_id))"
></span>
</p>
</div>
<div class="col-2">
<span
v-text="productPrice(props.row, item.product_id)"
></span>
</div>
<div class="col-4"></div>
</div>
<div
v-if="props.row.extra.shipping_cost"
class="row items-center no-wrap q-mb-md"
>
<div class="col-1"></div>
<div class="col-1"></div>
<div class="col-4">Shipping Cost</div>
<div class="col-2">
<span v-text="props.row.extra.shipping_cost"></span>
<span v-text="props.row.extra.currency"></span>
</div>
<div class="col-4"></div>
</div>
</div>
<div class="col-1"></div>
</div>
<div
v-if="props.row.extra.currency !== 'sat'"
class="row items-center no-wrap q-mb-md q-mt-md"
>
<div class="col-3 q-pr-lg">Exchange Rate (1 BTC):</div>
<div class="col-6 col-sm-8 q-pr-lg">
<q-input
filled
dense
readonly
disabled
:value="formatFiat(props.row.extra.btc_price, props.row.extra.currency)"
type="text"
></q-input>
</div>
<div class="col-3 col-sm-1"></div>
</div>
<div
v-if="props.row.extra.fail_message"
class="row items-center no-wrap q-mb-md"
>
<div class="col-3 q-pr-lg">Error:</div>
<div class="col-6 col-sm-8 q-pr-lg">
<q-badge color="pink"
><span v-text="props.row.extra.fail_message"></span
></q-badge>
</div>
<div class="col-3 col-sm-1"></div>
</div>
<div class="row items-center no-wrap q-mb-md q-mt-md">
<div class="col-3 q-pr-lg">Order ID:</div>
<div class="col-6 col-sm-8 q-pr-lg">
<q-input
filled
dense
readonly
disabled
v-model.trim="props.row.id"
type="text"
></q-input>
</div>
<div class="col-3 col-sm-1"></div>
</div>
<div class="row items-center no-wrap q-mb-md">
<div class="col-3 q-pr-lg">Customer Public Key:</div>
<div class="col-6 col-sm-8 q-pr-lg">
<q-input
filled
dense
readonly
disabled
v-model.trim="props.row.public_key"
type="text"
></q-input>
</div>
<div class="col-3 col-sm-1"></div>
</div>
<div
v-if="props.row.address"
class="row items-center no-wrap q-mb-md"
>
<div class="col-3 q-pr-lg">Address:</div>
<div class="col-6 col-sm-8 q-pr-lg">
<q-input
filled
dense
readonly
disabled
v-model.trim="props.row.address"
type="text"
></q-input>
</div>
<div class="col-3 col-sm-1"></div>
</div>
<div
v-if="props.row.contact.phone"
class="row items-center no-wrap q-mb-md"
>
<div class="col-3 q-pr-lg">Phone:</div>
<div class="col-6 col-sm-8 q-pr-lg">
<q-input
filled
dense
readonly
disabled
v-model.trim="props.row.contact.phone"
type="text"
></q-input>
</div>
<div class="col-3 col-sm-1"></div>
</div>
<div
v-if="props.row.contact.email"
class="row items-center no-wrap q-mb-md"
>
<div class="col-3 q-pr-lg">Email:</div>
<div class="col-6 col-sm-8 q-pr-lg">
<q-input
filled
dense
readonly
disabled
v-model.trim="props.row.contact.email"
type="text"
></q-input>
</div>
<div class="col-3 col-sm-1"></div>
</div>
<div class="row items-center no-wrap q-mb-md">
<div class="col-3 q-pr-lg">Shipping Zone:</div>
<div class="col-6 col-sm-8 q-pr-lg">
<q-select
:options="getStallZones(props.row.stall_id)"
filled
dense
emit-value
v-model.trim="props.row.shipping_id"
label="Shipping Zones"
></q-select>
</div>
<div class="col-3 col-sm-1"></div>
</div>
<div class="row items-center no-wrap q-mb-md">
<div class="col-3 q-pr-lg">Invoice ID:</div>
<div class="col-6 col-sm-8 q-pr-lg">
<q-input
filled
dense
readonly
disabled
v-model.trim="props.row.invoice_id"
type="text"
></q-input>
</div>
<div class="col-3"></div>
</div>
<div class="row items-center no-wrap q-mb-md">
<div class="col-3 q-pr-lg"></div>
<div class="col-9">
<q-btn
@click="reissueOrderInvoice(props.row)"
unelevated
color="primary"
type="submit"
class="float-left"
label="Reissue Invoice"
></q-btn>
</div>
</div>
</q-td>
</q-tr>
</template>
</q-table>
</div>
</div>
<q-dialog v-model="showShipDialog" position="top">
<q-card v-if="selectedOrder" class="q-pa-lg q-pt-xl" style="width: 500px">
<q-form @submit="updateOrderShipped" class="q-gutter-md">
<q-input
filled
dense
v-model.trim="shippingMessage"
label="Shipping Message"
type="textarea"
rows="4"
></q-input>
<div class="row q-mt-lg">
<q-btn
unelevated
color="primary"
type="submit"
:label="selectedOrder.shipped? 'Unship Order' : 'Ship Order'"
></q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
</div>

View file

@ -22,12 +22,13 @@
@click="openZoneDialog(zone)"
>
<q-item-section>
<q-item-label>{{zone.name}}</q-item-label>
<q-item-label caption>{{zone.countries.join(", ")}}</q-item-label>
<q-item-label><span v-text="zone.name"></span></q-item-label>
<q-item-label caption
><span v-text="zone.countries.join('', '')"></span
></q-item-label>
</q-item-section>
</q-item>
</q-list></q-btn-dropdown
>
</q-item> </q-list
></q-btn-dropdown>
<q-dialog v-model="zoneDialog.showDialog" position="top">
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">

View file

@ -0,0 +1,466 @@
<div>
<q-tabs v-model="tab" no-caps class="bg-dark text-white shadow-2">
<q-tab name="info" label="Stall Info"></q-tab>
<q-tab name="products" label="Products"></q-tab>
<q-tab name="orders" label="Orders"></q-tab>
</q-tabs>
<q-tab-panels v-model="tab">
<q-tab-panel name="info">
<div v-if="stall">
<div class="row items-center no-wrap q-mb-md">
<div class="col-3 q-pr-lg">ID:</div>
<div class="col-6 col-sm-8 q-pr-lg">
<q-input
filled
dense
readonly
disabled
v-model.trim="stall.id"
type="text"
></q-input>
</div>
<div class="col-3 col-sm-1"></div>
</div>
<div class="row items-center no-wrap q-mb-md">
<div class="col-3 q-pr-lg">Name:</div>
<div class="col-6 col-sm-8 q-pr-lg">
<q-input
filled
dense
v-model.trim="stall.name"
type="text"
></q-input>
</div>
<div class="col-3 col-sm-1"></div>
</div>
<div class="row items-center no-wrap q-mb-md">
<div class="col-3 q-pr-lg">Description:</div>
<div class="col-6 col-sm-8 q-pr-lg">
<q-input
filled
dense
v-model.trim="stall.config.description"
type="textarea"
rows="3"
label="Description"
></q-input>
</div>
<div class="col-3 col-sm-1"></div>
</div>
<div class="row items-center no-wrap q-mb-md">
<div class="col-3 q-pr-lg">Wallet:</div>
<div class="col-6 col-sm-8 q-pr-lg">
<q-select
filled
dense
emit-value
v-model="stall.wallet"
:options="walletOptions"
label="Wallet *"
>
</q-select>
</div>
<div class="col-3 col-sm-1"></div>
</div>
<div class="row items-center no-wrap q-mb-md">
<div class="col-3 q-pr-lg">Currency:</div>
<div class="col-6 col-sm-8 q-pr-lg">
<q-select
filled
dense
v-model="stall.currency"
type="text"
label="Unit"
:options="currencies"
></q-select>
</div>
<div class="col-3 col-sm-1"></div>
</div>
<div class="row items-center no-wrap q-mb-md">
<div class="col-3 q-pr-lg">Shipping Zones:</div>
<div class="col-6 col-sm-8 q-pr-lg">
<q-select
:options="filteredZoneOptions"
filled
dense
multiple
v-model.trim="stall.shipping_zones"
label="Shipping Zones"
></q-select>
</div>
<div class="col-3 col-sm-1"></div>
</div>
</div>
<div class="row items-center q-mt-xl">
<div class="col-6 q-pr-lg">
<q-btn
outline
unelevated
class="float-left"
color="primary"
@click="updateStall()"
>Update Stall</q-btn
>
</div>
<div class="col-6">
<q-btn
outline
unelevated
icon="cancel"
class="float-right"
@click="deleteStall()"
>Delete Stall</q-btn
>
</div>
</div>
</q-tab-panel>
<q-tab-panel name="products">
<div v-if="stall">
<div class="row items-center no-wrap q-mb-md">
<div class="col-3 q-pr-lg">
<q-btn-dropdown
@click="showNewProductDialog()"
outline
unelevated
split
class="float-left"
color="primary"
label="New Product"
>
<q-item @click="showNewProductDialog()" clickable v-close-popup>
<q-item-section>
<q-item-label>New Product</q-item-label>
<q-item-label caption>Create a new product</q-item-label>
</q-item-section>
</q-item>
<q-item
@click="openSelectPendingProductDialog"
clickable
v-close-popup
>
<q-item-section>
<q-item-label>Restore Product</q-item-label>
<q-item-label caption
>Restore existing product from Nostr</q-item-label
>
</q-item-section>
</q-item>
</q-btn-dropdown>
</div>
<div class="col-6 col-sm-8 q-pr-lg"></div>
<div class="col-3 col-sm-1"></div>
</div>
<div class="row items-center no-wrap q-mb-md">
<div class="col-12">
<q-table
flat
dense
:rows="products"
row-key="id"
:columns="productsTable.columns"
v-model:pagination="productsTable.pagination"
:filter="productsFilter"
>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn
size="sm"
color="grey"
dense
@click="deleteProduct(props.row.id)"
icon="delete"
/>
</q-td>
<q-td auto-width>
<q-btn
size="sm"
color="primary"
dense
@click="editProduct(props.row)"
icon="edit"
/>
</q-td>
<q-td auto-width>
<q-toggle
@update:model-value="updateProduct({ ...props.row, active: !props.row.active })"
size="xs"
checked-icon="check"
v-model="props.row.active"
color="green"
unchecked-icon="clear"
/>
</q-td>
<q-td key="id" :props="props"
><span v-text="props.row.id"></span>
</q-td>
<q-td key="name" :props="props">
<span v-text="shortLabel(props.row.name)"></span>
</q-td>
<q-td key="price" :props="props"
><span v-text="props.row.price"></span>
</q-td>
<q-td key="quantity" :props="props">
<span v-text="props.row.quantity"></span>
</q-td>
</q-tr>
</template>
</q-table>
</div>
</div>
</div>
</q-tab-panel>
<q-tab-panel name="orders">
<div v-if="stall">
<order-list
:adminkey="adminkey"
:inkey="inkey"
:stall-id="stallId"
@customer-selected="customerSelectedForOrder"
></order-list>
</div>
</q-tab-panel>
</q-tab-panels>
<q-dialog v-model="productDialog.showDialog" position="top">
<q-card
v-if="stall && productDialog.data"
class="q-pa-lg q-pt-xl"
style="width: 500px"
>
<q-form @submit="sendProductFormData" class="q-gutter-md">
<q-input
filled
dense
v-model.trim="productDialog.data.name"
label="Name"
></q-input>
<q-input
filled
dense
v-model.trim="productDialog.data.config.description"
label="Description"
></q-input>
<div class="row q-mb-sm">
<div class="col">
<q-input
filled
dense
v-model.number="productDialog.data.price"
type="number"
:label="'Price (' + stall.currency + ') *'"
:step="stall.currency != 'sat' ? '0.01' : '1'"
:mask="stall.currency != 'sat' ? '#.##' : '#'"
fill-mask="0"
reverse-fill-mask
></q-input>
</div>
<div class="col q-ml-md">
<q-input
filled
dense
v-model.number="productDialog.data.quantity"
type="number"
label="Quantity"
></q-input>
</div>
</div>
<q-expansion-item
group="advanced"
label="Categories"
caption="Add tags to producsts, make them easy to search."
>
<div class="q-pl-sm q-pt-sm">
<q-select
filled
multiple
dense
emit-value
v-model.trim="productDialog.data.categories"
use-input
use-chips
multiple
hide-dropdown-icon
input-debounce="0"
new-value-mode="add-unique"
label="Categories (Hit Enter to add)"
placeholder="crafts,robots,etc"
></q-select>
</div>
</q-expansion-item>
<q-expansion-item
group="advanced"
label="Images"
caption="Add images for product."
>
<div class="q-pl-sm q-pt-sm">
<q-input
filled
dense
v-model.trim="productDialog.data.image"
@keydown.enter="addProductImage"
type="url"
label="Image URL"
>
<q-btn @click="addProductImage" dense flat icon="add"></q-btn
></q-input>
<q-chip
v-for="imageUrl in productDialog.data.images"
:key="imageUrl"
removable
@remove="removeProductImage(imageUrl)"
color="primary"
text-color="white"
>
<span v-text="imageUrl.split('/').pop()"></span>
</q-chip>
</div>
</q-expansion-item>
<q-expansion-item
group="advanced"
label="Custom Shipping Cost"
caption="Configure custom shipping costs for this product"
>
<div
v-for="zone of productDialog.data.config.shipping"
class="row q-mb-sm q-ml-lg q-mt-sm"
>
<div class="col">
<span v-text="zone.name"></span>
</div>
<div class="col q-pr-md">
<q-input
v-model="zone.cost"
filled
dense
type="number"
label="Extra cost"
>
</q-input>
</div>
</div>
</q-expansion-item>
<q-expansion-item
group="advanced"
label="Autoreply"
caption="Autoreply when paid"
>
<q-card>
<q-card-section>
<div class="row q-mb-sm">
<div class="col">
<q-checkbox
v-model="productDialog.data.config.use_autoreply"
dense
label="Send a direct message when paid"
class="q-ml-sm"
/>
</div>
</div>
<div class="row q-mb-sm q-ml-sm">
<div class="col">
<q-input
v-model="productDialog.data.config.autoreply_message"
filled
dense
type="textarea"
rows="5"
label="Autoreply message"
hint="It can include link to a digital asset"
>
</q-input>
</div>
</div>
</q-card-section>
</q-card>
</q-expansion-item>
<div class="row q-mt-lg">
<q-btn
v-if="productDialog.data.id"
type="submit"
:label="productDialog.data.pending ? 'Restore Product' : 'Update Product'"
unelevated
color="primary"
></q-btn>
<q-btn
v-else
unelevated
color="primary"
:disable="!productDialog.data.price
|| !productDialog.data.name
|| !productDialog.data.quantity"
type="submit"
>Create Product</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
<q-dialog v-model="productDialog.showRestore" position="top">
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
<div v-if="pendingProducts && pendingProducts.length" class="row q-mt-lg">
<q-item
v-for="pendingProduct of pendingProducts"
:key="pendingProduct.id"
tag="label"
class="full-width"
v-ripple
>
<q-item-section>
<q-item-label
><span v-text="pendingProduct.name"></span
></q-item-label>
<q-item-label caption
><span v-text="pendingProduct.config?.description"></span
></q-item-label>
</q-item-section>
<q-item-section class="q-pl-xl float-right">
<q-btn
@click="openRestoreProductDialog(pendingProduct)"
v-close-popup
flat
color="green"
class="q-ml-auto float-right"
>Restore</q-btn
>
</q-item-section>
<q-item-section class="float-right">
<q-btn
@click="deleteProduct(pendingProduct.id)"
v-close-popup
color="red"
class="q-ml-auto float-right"
icon="cancel"
></q-btn>
</q-item-section>
</q-item>
</div>
<div v-else>There are no products to be restored.</div>
<div class="row q-mt-lg">
<q-btn
@click="restoreAllPendingProducts"
v-close-popup
flat
color="green"
>Restore All</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</div>
</q-card>
</q-dialog>
</div>

View file

@ -0,0 +1,215 @@
<div>
<div class="row items-center no-wrap q-mb-md">
<div class="col q-pr-lg">
<q-btn-dropdown
@click="openCreateStallDialog()"
outline
unelevated
split
class="float-left"
color="primary"
label="New Stall (Store)"
>
<q-item @click="openCreateStallDialog()" clickable v-close-popup>
<q-item-section>
<q-item-label>New Stall</q-item-label>
<q-item-label caption>Create a new stall</q-item-label>
</q-item-section>
</q-item>
<q-item @click="openSelectPendingStallDialog" clickable v-close-popup>
<q-item-section>
<q-item-label>Restore Stall</q-item-label>
<q-item-label caption
>Restore existing stall from Nostr</q-item-label
>
</q-item-section>
</q-item>
</q-btn-dropdown>
<q-input
borderless
dense
debounce="300"
v-model="filter"
placeholder="Search"
class="float-right"
>
<template v-slot:append>
<q-icon name="search"></q-icon>
</template>
</q-input>
</div>
</div>
<q-table
flat
dense
:rows="stalls"
row-key="id"
:columns="stallsTable.columns"
v-model:pagination="stallsTable.pagination"
:filter="filter"
>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn
size="sm"
color="primary"
round
dense
@click="props.row.expanded= !props.row.expanded"
:icon="props.row.expanded? 'remove' : 'add'"
/>
</q-td>
<q-td key="id" :props="props"
><span v-text="shortLabel(props.row.name)"></span
></q-td>
<q-td key="currency" :props="props"
><span v-text="props.row.currency"></span>
</q-td>
<q-td key="description" :props="props">
<span v-text="shortLabel(props.row.config.description)"></span>
</q-td>
<q-td key="shippingZones" :props="props">
<div>
<span
v-text="shortLabel(props.row.shipping_zones.filter(z => !!z.name).map(z => z.name).join(', '))"
></span>
</div>
</q-td>
</q-tr>
<q-tr v-if="props.row.expanded" :props="props">
<q-td colspan="100%">
<div class="row items-center q-mb-lg">
<div class="col-12">
<stall-details
:stall-id="props.row.id"
:adminkey="adminkey"
:inkey="inkey"
:wallet-options="walletOptions"
:zone-options="zoneOptions"
:currencies="currencies"
@stall-deleted="handleStallDeleted"
@stall-updated="handleStallUpdated"
@customer-selected-for-order="customerSelectedForOrder"
></stall-details>
</div>
</div>
</q-td>
</q-tr>
</template>
</q-table>
<div>
<q-dialog v-model="stallDialog.show" position="top">
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
<q-form @submit="sendStallFormData" class="q-gutter-md">
<q-input
filled
dense
v-model.trim="stallDialog.data.name"
label="Name"
></q-input>
<q-input
filled
dense
v-model.trim="stallDialog.data.description"
type="textarea"
rows="3"
label="Description"
></q-input>
<q-select
filled
dense
emit-value
v-model="stallDialog.data.wallet"
:options="walletOptions"
label="Wallet *"
>
</q-select>
<q-select
filled
dense
v-model="stallDialog.data.currency"
type="text"
label="Unit"
:options="currencies"
></q-select>
<q-select
:options="filteredZoneOptions"
filled
dense
multiple
v-model.trim="stallDialog.data.shippingZones"
label="Shipping Zones"
></q-select>
<div class="row q-mt-lg">
<q-btn
unelevated
color="primary"
:disable="!stallDialog.data.name
|| !stallDialog.data.currency
|| !stallDialog.data.wallet
|| !stallDialog.data.shippingZones
|| !stallDialog.data.shippingZones.length"
type="submit"
:label="stallDialog.data.id ? 'Restore Stall' : 'Create Stall'"
></q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
<q-dialog v-model="stallDialog.showRestore" position="top">
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
<div v-if="pendingStalls && pendingStalls.length" class="row q-mt-lg">
<q-item
v-for="pendingStall of pendingStalls"
:key="pendingStall.id"
tag="label"
class="full-width"
v-ripple
>
<q-item-section>
<q-item-label
><span v-text="pendingStall.name"></span
></q-item-label>
<q-item-label caption
><span v-text="pendingStall.config?.description"></span
></q-item-label>
</q-item-section>
<q-item-section class="q-pl-xl float-right">
<q-btn
@click="openRestoreStallDialog(pendingStall)"
v-close-popup
flat
color="green"
class="q-ml-auto float-right"
>Restore</q-btn
>
</q-item-section>
<q-item-section class="float-right">
<q-btn
@click="deleteStall(pendingStall)"
v-close-popup
color="red"
class="q-ml-auto float-right"
icon="cancel"
></q-btn>
</q-item-section>
</q-item>
</div>
<div v-else>There are no stalls to be restored.</div>
<div class="row q-mt-lg">
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</div>
</q-card>
</q-dialog>
</div>
</div>

View file

@ -18,14 +18,17 @@
</div>
<div class="col-6">
<q-toggle
@input="toggleMerchantState()"
size="md"
checked-icon="check"
v-model="merchant.config.active"
color="primary"
unchecked-icon="clear"
class="float-left"
/> <span v-text="merchant.config.active ? 'Accepting Orders': 'Orders Paused'"></span>
@update:model-value="toggleMerchantState()"
size="md"
checked-icon="check"
v-model="merchant.config.active"
color="primary"
unchecked-icon="clear"
class="float-left"
/>
<span
v-text="merchant.config.active ? 'Accepting Orders': 'Orders Paused'"
></span>
</div>
<div class="col-2">
<shipping-zones
@ -232,15 +235,38 @@
}
</style>
<template id="key-pair"
>{% include("nostrmarket/components/key-pair.html") %}</template
>
<template id="shipping-zones"
>{% include("nostrmarket/components/shipping-zones.html") %}</template
>
<template id="stall-details"
>{% include("nostrmarket/components/stall-details.html") %}</template
>
<template id="stall-list"
>{% include("nostrmarket/components/stall-list.html") %}</template
>
<template id="order-list"
>{% include("nostrmarket/components/order-list.html") %}</template
><template id="direct-messages"
>{% include("nostrmarket/components/direct-messages.html") %}</template
>
<template id="merchant-details"
>{% include("nostrmarket/components/merchant-details.html") %}</template
>
<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='components/key-pair/key-pair.js') }}"></script>
<script src="{{ url_for('nostrmarket_static', path='components/shipping-zones/shipping-zones.js') }}"></script>
<script src="{{ url_for('nostrmarket_static', path='components/stall-details/stall-details.js') }}"></script>
<script src="{{ url_for('nostrmarket_static', path='components/stall-list/stall-list.js') }}"></script>
<script src="{{ url_for('nostrmarket_static', path='components/order-list/order-list.js') }}"></script>
<script src="{{ url_for('nostrmarket_static', path='components/direct-messages/direct-messages.js') }}"></script>
<script src="{{ url_for('nostrmarket_static', path='components/merchant-details/merchant-details.js') }}"></script>
<script src="{{ url_for('nostrmarket_static', path='js/index.js') }}"></script>
<script src="{{ static_url_for('nostrmarket/static', 'components/key-pair.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>
{% endblock %}

View file

@ -1,36 +1,59 @@
<!DOCTYPE html>
<!doctype html>
<html>
<head>
<head>
<title>Nostr Market App</title>
<meta charset=utf-8>
<meta name=description content="A Nostr marketplace">
<meta name=format-detection content="telephone=no">
<meta name=msapplication-tap-highlight content=no>
<meta name=viewport content="user-scalable=no,initial-scale=1,maximum-scale=1,minimum-scale=1,width=device-width">
<meta charset="utf-8" />
<meta name="description" content="A Nostr marketplace" />
<meta name="format-detection" content="telephone=no" />
<meta name="msapplication-tap-highlight" content="no" />
<meta
name="viewport"
content="user-scalable=no,initial-scale=1,maximum-scale=1,minimum-scale=1,width=device-width"
/>
<script src="{{ url_for('nostrmarket_static', path='market/js/nostr.bundle.js') }}"></script>
<script src="{{ url_for('nostrmarket_static', path='market/js/bolt11-decoder.js') }}"></script>
<script src="{{ url_for('nostrmarket_static', path='market/js/utils.js') }}"></script>
<link rel=icon type=image/png sizes=128x128
href="{{ url_for('nostrmarket_static', path='market/icons/favicon-128x128.png')}}">
<link
rel="icon"
type="image/png"
sizes="128x128"
href="{{ url_for('nostrmarket_static', path='market/icons/favicon-128x128.png')}}"
/>
<link rel=icon type=image/png sizes=128x128
href="{{ url_for('nostrmarket_static', path='market/icons/favicon-96x96.png')}}">
<link rel=icon type=image/png sizes=128x128
href="{{ url_for('nostrmarket_static', path='market/icons/favicon-32x32.png')}}">
<link rel=icon type=image/png sizes=128x128 href="{{ url_for('nostrmarket_static', path='market/favicon.ico')}}">
<link
rel="icon"
type="image/png"
sizes="128x128"
href="{{ url_for('nostrmarket_static', path='market/icons/favicon-96x96.png')}}"
/>
<link
rel="icon"
type="image/png"
sizes="128x128"
href="{{ url_for('nostrmarket_static', path='market/icons/favicon-32x32.png')}}"
/>
<link
rel="icon"
type="image/png"
sizes="128x128"
href="{{ url_for('nostrmarket_static', path='market/favicon.ico')}}"
/>
<!-- Note: the .js and .css build IDs must be updated when a new version si released for 'static/market/index.html'-->
<script type="module" crossorigin
src="{{ url_for('nostrmarket_static', path='market/assets/index.923cbbf9.js')}}"></script>
<link rel="stylesheet" href="{{ url_for('nostrmarket_static', path='market/assets/index.73d462e5.css')}}">
</head>
<script
type="module"
crossorigin
src="{{ url_for('nostrmarket_static', path='market/assets/index.923cbbf9.js')}}"
></script>
<link
rel="stylesheet"
href="{{ url_for('nostrmarket_static', path='market/assets/index.73d462e5.css')}}"
/>
</head>
<body>
<div id=q-app></div>
</body>
</html>
<body>
<div id="q-app"></div>
</body>
</html>

9
toc.md
View file

@ -1,22 +1,29 @@
# Terms and Conditions for LNbits Extension
## 1. Acceptance of Terms
By installing and using the LNbits extension ("Extension"), you agree to be bound by these terms and conditions ("Terms"). If you do not agree to these Terms, do not use the Extension.
## 2. License
The Extension is free and open-source software, released under [specify the FOSS license here, e.g., GPL-3.0, MIT, etc.]. You are permitted to use, copy, modify, and distribute the Extension under the terms of that license.
## 3. No Warranty
The Extension is provided "as is" and with all faults, and the developer expressly disclaims all warranties of any kind, whether express, implied, statutory, or otherwise, including but not limited to warranties of merchantability, fitness for a particular purpose, non-infringement, and any warranties arising out of course of dealing or usage of trade. No advice or information, whether oral or written, obtained from the developer or elsewhere will create any warranty not expressly stated in this Terms.
## 4. Limitation of Liability
In no event will the developer be liable to you or any third party for any direct, indirect, incidental, special, consequential, or punitive damages, including lost profit, lost revenue, loss of data, or other damages arising out of or in connection with your use of the Extension, even if the developer has been advised of the possibility of such damages. The foregoing limitation of liability shall apply to the fullest extent permitted by law in the applicable jurisdiction.
## 5. Modification of Terms
The developer reserves the right to modify these Terms at any time. You are advised to review these Terms periodically for any changes. Changes to these Terms are effective when they are posted on the appropriate location within or associated with the Extension.
## 6. General Provisions
If any provision of these Terms is held to be invalid or unenforceable, that provision will be enforced to the maximum extent permissible, and the other provisions of these Terms will remain in full force and effect. These Terms constitute the entire agreement between you and the developer regarding the use of the Extension.
## 7. Contact Information
If you have any questions about these Terms, please contact the developer at [developer's contact information].
If you have any questions about these Terms, please contact the developer at [developer's contact information].

View file

@ -1,13 +1,8 @@
import json
from http import HTTPStatus
from fastapi import Depends, Query, Request
from fastapi import Depends, Request
from fastapi.templating import Jinja2Templates
from loguru import logger
from starlette.responses import HTMLResponse
from lnbits.core.models import User
from lnbits.decorators import check_user_exists
from starlette.responses import HTMLResponse
from . import nostrmarket_ext, nostrmarket_renderer
@ -18,7 +13,7 @@ templates = Jinja2Templates(directory="templates")
async def index(request: Request, user: User = Depends(check_user_exists)):
return nostrmarket_renderer().TemplateResponse(
"nostrmarket/index.html",
{"request": request, "user": user.dict()},
{"request": request, "user": user.json()},
)

View file

@ -4,16 +4,14 @@ from typing import List, Optional
from fastapi import Depends
from fastapi.exceptions import HTTPException
from loguru import logger
from lnbits.core.services import websocket_updater
from lnbits.decorators import (
WalletTypeInfo,
get_key_type,
require_admin_key,
require_invoice_key,
)
from lnbits.utils.exchange_rates import currencies
from loguru import logger
from . import nostr_client, nostrmarket_ext
from .crud import (
@ -71,9 +69,6 @@ from .models import (
PartialDirectMessage,
PartialMerchant,
PartialOrder,
PartialProduct,
PartialStall,
PartialZone,
PaymentOption,
PaymentRequest,
Product,
@ -81,16 +76,16 @@ from .models import (
Zone,
)
from .services import (
reply_to_structured_dm,
build_order_with_payment,
create_or_update_order_from_dm,
reply_to_structured_dm,
resubscribe_to_all_merchants,
sign_and_send_to_nostr,
subscribe_to_all_merchants,
update_merchant_to_nostr,
)
######################################## MERCHANT ########################################
######################################## MERCHANT ######################################
@nostrmarket_ext.post("/api/v1/merchant")
@ -101,16 +96,16 @@ async def api_create_merchant(
try:
merchant = await get_merchant_by_pubkey(data.public_key)
assert merchant == None, "A merchant already uses this public key"
assert merchant is None, "A merchant already uses this public key"
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant == None, "A merchant already exists for this 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,
PartialZone(
Zone(
id=f"online-{merchant.public_key}",
name="Online",
currency="sat",
@ -128,13 +123,13 @@ async def api_create_merchant(
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 create merchant",
)
) from ex
@nostrmarket_ext.get("/api/v1/merchant")
@ -145,11 +140,12 @@ async def api_get_merchant(
try:
merchant = await get_merchant_for_user(wallet.wallet.user)
if not merchant:
return
return None
merchant = await touch_merchant(wallet.wallet.user, merchant.id)
assert merchant
last_dm_time = await get_last_direct_messages_time(merchant.id)
assert merchant.time
merchant.config.restore_in_progress = (merchant.time - last_dm_time) < 30
return merchant
@ -158,7 +154,7 @@ async def api_get_merchant(
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot get merchant",
)
) from ex
@nostrmarket_ext.delete("/api/v1/merchant/{merchant_id}")
@ -186,16 +182,17 @@ async def api_delete_merchant(
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 get merchant",
)
) from ex
finally:
await subscribe_to_all_merchants()
@nostrmarket_ext.put("/api/v1/merchant/{merchant_id}/nostr")
async def api_republish_merchant(
merchant_id: str,
@ -213,13 +210,14 @@ async def api_republish_merchant(
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 republish to nostr",
)
) from ex
@nostrmarket_ext.get("/api/v1/merchant/{merchant_id}/nostr")
async def api_refresh_merchant(
@ -237,13 +235,13 @@ async def api_refresh_merchant(
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 refresh from nostr",
)
) from ex
@nostrmarket_ext.put("/api/v1/merchant/{merchant_id}/toggle")
@ -264,17 +262,17 @@ async def api_toggle_merchant(
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 get merchant",
)
) from ex
@nostrmarket_ext.delete("/api/v1/merchant/{merchant_id}/nostr")
async def api_delete_merchant(
async def api_delete_merchant_on_nostr(
merchant_id: str,
wallet: WalletTypeInfo = Depends(require_admin_key),
):
@ -290,20 +288,22 @@ async def api_delete_merchant(
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 get merchant",
)
) from ex
######################################## ZONES ########################################
@nostrmarket_ext.get("/api/v1/zone")
async def api_get_zones(wallet: WalletTypeInfo = Depends(get_key_type)) -> List[Zone]:
async def api_get_zones(
wallet: WalletTypeInfo = Depends(require_invoice_key),
) -> List[Zone]:
try:
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Merchant cannot be found"
@ -312,18 +312,18 @@ async def api_get_zones(wallet: WalletTypeInfo = Depends(get_key_type)) -> List[
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 get zone",
)
) from ex
@nostrmarket_ext.post("/api/v1/zone")
async def api_create_zone(
data: PartialZone, wallet: WalletTypeInfo = Depends(require_admin_key)
data: Zone, wallet: WalletTypeInfo = Depends(require_admin_key)
):
try:
merchant = await get_merchant_for_user(wallet.wallet.user)
@ -334,13 +334,13 @@ async def api_create_zone(
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 create zone",
)
) from ex
@nostrmarket_ext.patch("/api/v1/zone/{zone_id}")
@ -365,15 +365,14 @@ async def api_update_zone(
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=str(ex),
)
except HTTPException as ex:
raise ex
) from ex
except Exception as ex:
logger.warning(ex)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot update zone",
)
) from ex
@nostrmarket_ext.delete("/api/v1/zone/{zone_id}")
@ -394,13 +393,13 @@ async def api_delete_zone(zone_id, wallet: WalletTypeInfo = Depends(require_admi
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 delete zone",
)
) from ex
######################################## STALLS ########################################
@ -408,7 +407,7 @@ async def api_delete_zone(zone_id, wallet: WalletTypeInfo = Depends(require_admi
@nostrmarket_ext.post("/api/v1/stall")
async def api_create_stall(
data: PartialStall,
data: Stall,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> Stall:
try:
@ -430,13 +429,13 @@ async def api_create_stall(
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 create stall",
)
) from ex
@nostrmarket_ext.put("/api/v1/stall/{stall_id}")
@ -459,23 +458,24 @@ async def api_update_stall(
await update_stall(merchant.id, stall)
return stall
except HTTPException as ex:
raise ex
except (ValueError, 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 stall",
)
) from ex
@nostrmarket_ext.get("/api/v1/stall/{stall_id}")
async def api_get_stall(stall_id: str, wallet: WalletTypeInfo = Depends(get_key_type)):
async def api_get_stall(
stall_id: str, wallet: WalletTypeInfo = Depends(require_invoice_key)
):
try:
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Merchant cannot be found"
@ -490,7 +490,7 @@ async def api_get_stall(stall_id: str, wallet: WalletTypeInfo = Depends(get_key_
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=str(ex),
)
) from ex
except HTTPException as ex:
raise ex
except Exception as ex:
@ -498,12 +498,13 @@ async def api_get_stall(stall_id: str, wallet: WalletTypeInfo = Depends(get_key_
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot get stall",
)
) from ex
@nostrmarket_ext.get("/api/v1/stall")
async def api_get_stalls(
pending: Optional[bool] = False, wallet: WalletTypeInfo = Depends(get_key_type)
pending: Optional[bool] = False,
wallet: WalletTypeInfo = Depends(require_invoice_key),
):
try:
merchant = await get_merchant_for_user(wallet.wallet.user)
@ -514,13 +515,13 @@ async def api_get_stalls(
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 get stalls",
)
) from ex
@nostrmarket_ext.get("/api/v1/stall/product/{stall_id}")
@ -538,13 +539,13 @@ async def api_get_stall_products(
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 get stall products",
)
) from ex
@nostrmarket_ext.get("/api/v1/stall/order/{stall_id}")
@ -566,13 +567,13 @@ async def api_get_stall_orders(
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 get stall products",
)
) from ex
@nostrmarket_ext.delete("/api/v1/stall/{stall_id}")
@ -600,23 +601,21 @@ async def api_delete_stall(
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=str(ex),
)
except HTTPException as ex:
raise ex
) from ex
except Exception as ex:
logger.warning(ex)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot delete stall",
)
) from ex
######################################## PRODUCTS ########################################
######################################## PRODUCTS ######################################
@nostrmarket_ext.post("/api/v1/product")
async def api_create_product(
data: PartialProduct,
data: Product,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> Product:
try:
@ -639,13 +638,13 @@ async def api_create_product(
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 create product",
)
) from ex
@nostrmarket_ext.patch("/api/v1/product/{product_id}")
@ -675,13 +674,13 @@ async def api_update_product(
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 product",
)
) from ex
@nostrmarket_ext.get("/api/v1/product/{product_id}")
@ -699,13 +698,13 @@ async def api_get_product(
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 get product",
)
) from ex
@nostrmarket_ext.delete("/api/v1/product/{product_id}")
@ -731,15 +730,13 @@ async def api_delete_product(
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=str(ex),
)
except HTTPException as ex:
raise ex
) from ex
except Exception as ex:
logger.warning(ex)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot delete product",
)
) from ex
######################################## ORDERS ########################################
@ -764,15 +761,13 @@ async def api_get_order(
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=str(ex),
)
except HTTPException as ex:
raise ex
) from ex
except Exception as ex:
logger.warning(ex)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot get order",
)
) from ex
@nostrmarket_ext.get("/api/v1/order")
@ -780,7 +775,7 @@ async def api_get_orders(
paid: Optional[bool] = None,
shipped: Optional[bool] = None,
pubkey: Optional[str] = None,
wallet: WalletTypeInfo = Depends(get_key_type),
wallet: WalletTypeInfo = Depends(require_invoice_key),
):
try:
merchant = await get_merchant_for_user(wallet.wallet.user)
@ -794,13 +789,13 @@ async def api_get_orders(
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 get orders",
)
) from ex
@nostrmarket_ext.patch("/api/v1/order/{order_id}")
@ -809,7 +804,7 @@ async def api_update_order_status(
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> Order:
try:
assert data.shipped != None, "Shipped value is required for order"
assert data.shipped is not None, "Shipped value is required for order"
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Merchant cannot be found for order {data.id}"
@ -852,20 +847,20 @@ async def api_update_order_status(
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 order",
)
) from ex
@nostrmarket_ext.put("/api/v1/order/restore/{event_id}")
async def api_restore_order(
event_id: str,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> Order:
) -> Optional[Order]:
try:
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Merchant cannot be found"
@ -881,13 +876,13 @@ async def api_restore_order(
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 restore order",
)
) from ex
@nostrmarket_ext.put("/api/v1/orders/restore")
@ -906,20 +901,20 @@ async def api_restore_orders(
)
except Exception as e:
logger.debug(
f"Failed to restore order from event '{dm.event_id}': '{str(e)}'."
f"Failed to restore order from event '{dm.event_id}': '{e!s}'."
)
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 restore orders",
)
) from ex
@nostrmarket_ext.put("/api/v1/order/reissue")
@ -955,7 +950,9 @@ async def api_reissue_order_invoice(
**order_update,
)
payment_req = PaymentRequest(
id=data.id, payment_options=[PaymentOption(type="ln", link=invoice)], message=receipt
id=data.id,
payment_options=[PaymentOption(type="ln", link=invoice)],
message=receipt,
)
response = {
"type": DirectMessageType.PAYMENT_REQUEST.value,
@ -975,25 +972,25 @@ async def api_reissue_order_invoice(
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 reissue order invoice",
)
) from ex
######################################## DIRECT MESSAGES ########################################
######################################## DIRECT MESSAGES ###############################
@nostrmarket_ext.get("/api/v1/message/{public_key}")
async def api_get_messages(
public_key: str, wallet: WalletTypeInfo = Depends(get_key_type)
public_key: str, wallet: WalletTypeInfo = Depends(require_invoice_key)
) -> List[DirectMessage]:
try:
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, f"Merchant cannot be found"
assert merchant, "Merchant cannot be found"
messages = await get_direct_messages(merchant.id, public_key)
await update_customer_no_unread_messages(merchant.id, public_key)
@ -1002,13 +999,13 @@ async def api_get_messages(
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 get direct message",
)
) from ex
@nostrmarket_ext.post("/api/v1/message")
@ -1017,7 +1014,7 @@ async def api_create_message(
) -> DirectMessage:
try:
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, f"Merchant cannot be found"
assert merchant, "Merchant cannot be found"
dm_event = merchant.build_dm_event(data.message, data.public_key)
data.event_id = dm_event.id
@ -1031,38 +1028,38 @@ async def api_create_message(
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 create message",
)
) from ex
######################################## CUSTOMERS ########################################
######################################## CUSTOMERS #####################################
@nostrmarket_ext.get("/api/v1/customer")
async def api_get_customers(
wallet: WalletTypeInfo = Depends(get_key_type),
wallet: WalletTypeInfo = Depends(require_invoice_key),
) -> List[Customer]:
try:
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, f"Merchant cannot be found"
assert merchant, "Merchant cannot be found"
return await get_customers(merchant.id)
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 create message",
)
) from ex
@nostrmarket_ext.post("/api/v1/customer")
@ -1079,7 +1076,7 @@ async def api_create_customer(
assert merchant.id == data.merchant_id, "Invalid merchant id for user"
existing_customer = await get_customer(merchant.id, pubkey)
assert existing_customer == None, "This public key already exists"
assert existing_customer is None, "This public key already exists"
customer = await create_customer(
merchant.id, Customer(merchant_id=merchant.id, public_key=pubkey)
@ -1092,13 +1089,13 @@ async def api_create_customer(
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 create customer",
)
) from ex
######################################## OTHER ########################################