commit
f13d5ec7ff
15 changed files with 1739 additions and 39 deletions
184
crud.py
184
crud.py
|
|
@ -1,10 +1,20 @@
|
|||
import json
|
||||
import time
|
||||
from typing import List, Optional
|
||||
|
||||
from lnbits.helpers import urlsafe_short_hash
|
||||
|
||||
from . import db
|
||||
from .models import Merchant, PartialMerchant, PartialZone, Zone
|
||||
from .models import (
|
||||
Merchant,
|
||||
PartialMerchant,
|
||||
PartialProduct,
|
||||
PartialStall,
|
||||
PartialZone,
|
||||
Product,
|
||||
Stall,
|
||||
Zone,
|
||||
)
|
||||
|
||||
######################################## MERCHANT ########################################
|
||||
|
||||
|
|
@ -51,15 +61,7 @@ async def create_zone(user_id: str, data: PartialZone) -> Zone:
|
|||
zone_id = urlsafe_short_hash()
|
||||
await db.execute(
|
||||
f"""
|
||||
INSERT INTO nostrmarket.zones (
|
||||
id,
|
||||
user_id,
|
||||
name,
|
||||
currency,
|
||||
cost,
|
||||
regions
|
||||
|
||||
)
|
||||
INSERT INTO nostrmarket.zones (id, user_id, name, currency, cost, regions)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
|
|
@ -104,4 +106,166 @@ async def get_zones(user_id: str) -> List[Zone]:
|
|||
|
||||
|
||||
async def delete_zone(zone_id: str) -> None:
|
||||
# todo: add user_id
|
||||
await db.execute("DELETE FROM nostrmarket.zones WHERE id = ?", (zone_id,))
|
||||
|
||||
|
||||
######################################## STALL ########################################
|
||||
|
||||
|
||||
async def create_stall(user_id: str, data: PartialStall) -> Stall:
|
||||
stall_id = urlsafe_short_hash()
|
||||
|
||||
await db.execute(
|
||||
f"""
|
||||
INSERT INTO nostrmarket.stalls (user_id, id, wallet, name, currency, zones, meta)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
user_id,
|
||||
stall_id,
|
||||
data.wallet,
|
||||
data.name,
|
||||
data.currency,
|
||||
json.dumps(
|
||||
[z.dict() for z in data.shipping_zones]
|
||||
), # todo: cost is float. should be int for sats
|
||||
json.dumps(data.config.dict()),
|
||||
),
|
||||
)
|
||||
|
||||
stall = await get_stall(user_id, stall_id)
|
||||
assert stall, "Newly created stall couldn't be retrieved"
|
||||
return stall
|
||||
|
||||
|
||||
async def get_stall(user_id: str, stall_id: str) -> Optional[Stall]:
|
||||
row = await db.fetchone(
|
||||
"SELECT * FROM nostrmarket.stalls WHERE user_id = ? AND id = ?",
|
||||
(
|
||||
user_id,
|
||||
stall_id,
|
||||
),
|
||||
)
|
||||
return Stall.from_row(row) if row else None
|
||||
|
||||
|
||||
async def get_stalls(user_id: str) -> List[Stall]:
|
||||
rows = await db.fetchall(
|
||||
"SELECT * FROM nostrmarket.stalls WHERE user_id = ?",
|
||||
(user_id,),
|
||||
)
|
||||
return [Stall.from_row(row) for row in rows]
|
||||
|
||||
|
||||
async def update_stall(user_id: str, stall: Stall) -> Optional[Stall]:
|
||||
await db.execute(
|
||||
f"""
|
||||
UPDATE nostrmarket.stalls SET wallet = ?, name = ?, currency = ?, zones = ?, meta = ?
|
||||
WHERE user_id = ? AND id = ?
|
||||
""",
|
||||
(
|
||||
stall.wallet,
|
||||
stall.name,
|
||||
stall.currency,
|
||||
json.dumps(
|
||||
[z.dict() for z in stall.shipping_zones]
|
||||
), # todo: cost is float. should be int for sats
|
||||
json.dumps(stall.config.dict()),
|
||||
user_id,
|
||||
stall.id,
|
||||
),
|
||||
)
|
||||
return await get_stall(user_id, stall.id)
|
||||
|
||||
|
||||
async def delete_stall(user_id: str, stall_id: str) -> None:
|
||||
await db.execute(
|
||||
"DELETE FROM nostrmarket.stalls WHERE user_id =? AND id = ?",
|
||||
(
|
||||
user_id,
|
||||
stall_id,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
######################################## STALL ########################################
|
||||
|
||||
|
||||
async def create_product(user_id: str, data: PartialProduct) -> Product:
|
||||
product_id = urlsafe_short_hash()
|
||||
|
||||
await db.execute(
|
||||
f"""
|
||||
INSERT INTO nostrmarket.products (user_id, id, stall_id, name, category_list, description, images, price, quantity)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
user_id,
|
||||
product_id,
|
||||
data.stall_id,
|
||||
data.name,
|
||||
json.dumps(data.categories),
|
||||
data.description,
|
||||
data.image,
|
||||
data.price,
|
||||
data.quantity,
|
||||
),
|
||||
)
|
||||
product = await get_product(user_id, product_id)
|
||||
assert product, "Newly created product couldn't be retrieved"
|
||||
|
||||
return product
|
||||
|
||||
|
||||
async def update_product(user_id: str, product: Product) -> Product:
|
||||
|
||||
await db.execute(
|
||||
f"""
|
||||
UPDATE nostrmarket.products set name = ?, category_list = ?, description = ?, images = ?, price = ?, quantity = ?
|
||||
WHERE user_id = ? AND id = ?
|
||||
""",
|
||||
(
|
||||
product.name,
|
||||
json.dumps(product.categories),
|
||||
product.description,
|
||||
product.image,
|
||||
product.price,
|
||||
product.quantity,
|
||||
user_id,
|
||||
product.id,
|
||||
),
|
||||
)
|
||||
updated_product = await get_product(user_id, product.id)
|
||||
assert updated_product, "Updated product couldn't be retrieved"
|
||||
|
||||
return updated_product
|
||||
|
||||
|
||||
async def get_product(user_id: str, product_id: str) -> Optional[Product]:
|
||||
row = await db.fetchone(
|
||||
"SELECT * FROM nostrmarket.products WHERE user_id =? AND id = ?",
|
||||
(
|
||||
user_id,
|
||||
product_id,
|
||||
),
|
||||
)
|
||||
return Product.from_row(row) if row else None
|
||||
|
||||
|
||||
async def get_products(user_id: str, stall_id: str) -> List[Product]:
|
||||
rows = await db.fetchall(
|
||||
"SELECT * FROM nostrmarket.products WHERE user_id = ? AND stall_id = ?",
|
||||
(user_id, stall_id),
|
||||
)
|
||||
return [Product.from_row(row) for row in rows]
|
||||
|
||||
|
||||
async def delete_product(user_id: str, product_id: str) -> None:
|
||||
await db.execute(
|
||||
"DELETE FROM nostrmarket.products WHERE user_id =? AND id = ?",
|
||||
(
|
||||
user_id,
|
||||
product_id,
|
||||
),
|
||||
)
|
||||
|
|
|
|||
81
helpers.py
Normal file
81
helpers.py
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import base64
|
||||
import json
|
||||
import secrets
|
||||
from typing import Optional
|
||||
|
||||
import secp256k1
|
||||
from cffi import FFI
|
||||
from cryptography.hazmat.primitives import padding
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
|
||||
|
||||
def get_shared_secret(privkey: str, pubkey: str):
|
||||
point = secp256k1.PublicKey(bytes.fromhex("02" + pubkey), True)
|
||||
return point.ecdh(bytes.fromhex(privkey), hashfn=copy_x)
|
||||
|
||||
|
||||
def decrypt_message(encoded_message: str, encryption_key) -> str:
|
||||
encoded_data = encoded_message.split("?iv=")
|
||||
encoded_content, encoded_iv = encoded_data[0], encoded_data[1]
|
||||
|
||||
iv = base64.b64decode(encoded_iv)
|
||||
cipher = Cipher(algorithms.AES(encryption_key), modes.CBC(iv))
|
||||
encrypted_content = base64.b64decode(encoded_content)
|
||||
|
||||
decryptor = cipher.decryptor()
|
||||
decrypted_message = decryptor.update(encrypted_content) + decryptor.finalize()
|
||||
|
||||
unpadder = padding.PKCS7(128).unpadder()
|
||||
unpadded_data = unpadder.update(decrypted_message) + unpadder.finalize()
|
||||
|
||||
return unpadded_data.decode()
|
||||
|
||||
|
||||
def encrypt_message(message: str, encryption_key, iv: Optional[bytes]) -> str:
|
||||
padder = padding.PKCS7(128).padder()
|
||||
padded_data = padder.update(message.encode()) + padder.finalize()
|
||||
|
||||
iv = iv if iv else secrets.token_bytes(16)
|
||||
cipher = Cipher(algorithms.AES(encryption_key), modes.CBC(iv))
|
||||
|
||||
encryptor = cipher.encryptor()
|
||||
encrypted_message = encryptor.update(padded_data) + encryptor.finalize()
|
||||
|
||||
return f"{base64.b64encode(encrypted_message).decode()}?iv={base64.b64encode(iv).decode()}"
|
||||
|
||||
|
||||
def sign_message_hash(private_key: str, hash: bytes) -> str:
|
||||
privkey = secp256k1.PrivateKey(bytes.fromhex(private_key))
|
||||
sig = privkey.schnorr_sign(hash, None, raw=True)
|
||||
return sig.hex()
|
||||
|
||||
|
||||
def test_decrypt_encrypt(encoded_message: str, encryption_key):
|
||||
msg = decrypt_message(encoded_message, encryption_key)
|
||||
|
||||
# ecrypt using the same initialisation vector
|
||||
iv = base64.b64decode(encoded_message.split("?iv=")[1])
|
||||
ecrypted_msg = encrypt_message(msg, encryption_key, iv)
|
||||
assert (
|
||||
encoded_message == ecrypted_msg
|
||||
), f"expected '{encoded_message}', but got '{ecrypted_msg}'"
|
||||
print("### test_decrypt_encrypt", encoded_message == ecrypted_msg)
|
||||
|
||||
|
||||
ffi = FFI()
|
||||
|
||||
|
||||
@ffi.callback(
|
||||
"int (unsigned char *, const unsigned char *, const unsigned char *, void *)"
|
||||
)
|
||||
def copy_x(output, x32, y32, data):
|
||||
ffi.memmove(output, x32, 32)
|
||||
return 1
|
||||
|
||||
|
||||
def is_json(string: str):
|
||||
try:
|
||||
json.loads(string)
|
||||
except ValueError as e:
|
||||
return False
|
||||
return True
|
||||
|
|
@ -18,15 +18,17 @@ async def m001_initial(db):
|
|||
"""
|
||||
Initial stalls table.
|
||||
"""
|
||||
# user_id, id, wallet, name, currency, zones, meta
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE nostrmarket.stalls (
|
||||
user_id TEXT NOT NULL,
|
||||
id TEXT PRIMARY KEY,
|
||||
wallet TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
currency TEXT,
|
||||
shipping_zones TEXT NOT NULL,
|
||||
rating REAL DEFAULT 0
|
||||
zones TEXT NOT NULL DEFAULT '[]',
|
||||
meta TEXT NOT NULL DEFAULT '{}'
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
|
@ -37,15 +39,15 @@ async def m001_initial(db):
|
|||
await db.execute(
|
||||
f"""
|
||||
CREATE TABLE nostrmarket.products (
|
||||
user_id TEXT NOT NULL,
|
||||
id TEXT PRIMARY KEY,
|
||||
stall_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
categories TEXT,
|
||||
category_list TEXT DEFAULT '[]',
|
||||
description TEXT,
|
||||
image TEXT,
|
||||
images TEXT DEFAULT '[]',
|
||||
price REAL NOT NULL,
|
||||
quantity INTEGER NOT NULL,
|
||||
rating REAL DEFAULT 0
|
||||
quantity INTEGER NOT NULL
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
|
|
|||
139
models.py
139
models.py
|
|
@ -1,10 +1,13 @@
|
|||
import json
|
||||
import time
|
||||
from sqlite3 import Row
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .helpers import sign_message_hash
|
||||
from .nostr.event import NostrEvent
|
||||
|
||||
|
||||
######################################## MERCHANT ########################################
|
||||
class MerchantConfig(BaseModel):
|
||||
|
|
@ -20,6 +23,9 @@ class PartialMerchant(BaseModel):
|
|||
class Merchant(PartialMerchant):
|
||||
id: str
|
||||
|
||||
def sign_hash(self, hash: bytes) -> str:
|
||||
return sign_message_hash(self.private_key, hash)
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: Row) -> "Merchant":
|
||||
merchant = cls(**dict(row))
|
||||
|
|
@ -43,3 +49,134 @@ class Zone(PartialZone):
|
|||
zone = cls(**dict(row))
|
||||
zone.countries = json.loads(row["regions"])
|
||||
return zone
|
||||
|
||||
|
||||
######################################## STALLS ########################################
|
||||
|
||||
|
||||
class StallConfig(BaseModel):
|
||||
"""Last published nostr event id for this Stall"""
|
||||
|
||||
event_id: Optional[str]
|
||||
image_url: Optional[str]
|
||||
description: Optional[str]
|
||||
|
||||
|
||||
class PartialStall(BaseModel):
|
||||
wallet: str
|
||||
name: str
|
||||
currency: str = "sat"
|
||||
shipping_zones: List[Zone] = []
|
||||
config: StallConfig = StallConfig()
|
||||
|
||||
def validate_stall(self):
|
||||
for z in self.shipping_zones:
|
||||
if z.currency != self.currency:
|
||||
raise ValueError(
|
||||
f"Sipping zone '{z.name}' has different currency than stall."
|
||||
)
|
||||
|
||||
|
||||
class Stall(PartialStall):
|
||||
id: str
|
||||
|
||||
def to_nostr_event(self, pubkey: str) -> NostrEvent:
|
||||
content = {
|
||||
"name": self.name,
|
||||
"description": self.config.description,
|
||||
"currency": self.currency,
|
||||
"shipping": [dict(z) for z in self.shipping_zones],
|
||||
}
|
||||
event = NostrEvent(
|
||||
pubkey=pubkey,
|
||||
created_at=round(time.time()),
|
||||
kind=30005,
|
||||
tags=[["d", self.id]],
|
||||
content=json.dumps(content, separators=(",", ":"), ensure_ascii=False),
|
||||
)
|
||||
event.id = event.event_id
|
||||
|
||||
return event
|
||||
|
||||
def to_nostr_delete_event(self, pubkey: str) -> NostrEvent:
|
||||
delete_event = NostrEvent(
|
||||
pubkey=pubkey,
|
||||
created_at=round(time.time()),
|
||||
kind=5,
|
||||
tags=[["e", self.config.event_id]],
|
||||
content=f"Stall '{self.name}' deleted",
|
||||
)
|
||||
delete_event.id = delete_event.event_id
|
||||
|
||||
return delete_event
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: Row) -> "Stall":
|
||||
stall = cls(**dict(row))
|
||||
stall.config = StallConfig(**json.loads(row["meta"]))
|
||||
stall.shipping_zones = [Zone(**z) for z in json.loads(row["zones"])]
|
||||
return stall
|
||||
|
||||
|
||||
######################################## STALLS ########################################
|
||||
|
||||
|
||||
class PartialProduct(BaseModel):
|
||||
stall_id: str
|
||||
name: str
|
||||
categories: List[str] = []
|
||||
description: Optional[str]
|
||||
image: Optional[str]
|
||||
price: float
|
||||
quantity: int
|
||||
|
||||
def validate_product(self):
|
||||
if self.image:
|
||||
image_is_url = self.image.startswith("https://") or self.image.startswith(
|
||||
"http://"
|
||||
)
|
||||
|
||||
if not image_is_url:
|
||||
|
||||
def size(b64string):
|
||||
return int((len(b64string) * 3) / 4 - b64string.count("=", -2))
|
||||
|
||||
image_size = size(self.image) / 1024
|
||||
if image_size > 100:
|
||||
raise ValueError(
|
||||
f"""
|
||||
Image size is too big, {int(image_size)}Kb.
|
||||
Max: 100kb, Compress the image at https://tinypng.com, or use an URL."""
|
||||
)
|
||||
|
||||
|
||||
class Product(PartialProduct):
|
||||
id: str
|
||||
|
||||
def to_nostr_event(self, pubkey: str) -> NostrEvent:
|
||||
content = {
|
||||
"stall_id": self.stall_id,
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"image": self.image,
|
||||
"price": self.price,
|
||||
"quantity": self.quantity,
|
||||
}
|
||||
categories = [["t", tag] for tag in self.categories]
|
||||
|
||||
event = NostrEvent(
|
||||
pubkey=pubkey,
|
||||
created_at=round(time.time()),
|
||||
kind=30005,
|
||||
tags=[["d", self.id]] + categories,
|
||||
content=json.dumps(content, separators=(",", ":"), ensure_ascii=False),
|
||||
)
|
||||
event.id = event.event_id
|
||||
|
||||
return event
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: Row) -> "Product":
|
||||
product = cls(**dict(row))
|
||||
product.categories = json.loads(row["category_list"])
|
||||
return product
|
||||
|
|
|
|||
57
nostr/event.py
Normal file
57
nostr/event.py
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import hashlib
|
||||
import json
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
from secp256k1 import PublicKey
|
||||
|
||||
|
||||
class NostrEvent(BaseModel):
|
||||
id: str = ""
|
||||
pubkey: str
|
||||
created_at: int
|
||||
kind: int
|
||||
tags: List[List[str]] = []
|
||||
content: str = ""
|
||||
sig: Optional[str]
|
||||
|
||||
def serialize(self) -> List:
|
||||
return [0, self.pubkey, self.created_at, self.kind, self.tags, self.content]
|
||||
|
||||
def serialize_json(self) -> str:
|
||||
e = self.serialize()
|
||||
return json.dumps(e, separators=(",", ":"), ensure_ascii=False)
|
||||
|
||||
@property
|
||||
def event_id(self) -> str:
|
||||
data = self.serialize_json()
|
||||
id = hashlib.sha256(data.encode()).hexdigest()
|
||||
return id
|
||||
|
||||
def check_signature(self):
|
||||
event_id = self.event_id
|
||||
if self.id != event_id:
|
||||
raise ValueError(
|
||||
f"Invalid event id. Expected: '{event_id}' got '{self.id}'"
|
||||
)
|
||||
try:
|
||||
pub_key = PublicKey(bytes.fromhex("02" + self.pubkey), True)
|
||||
except Exception:
|
||||
raise ValueError(
|
||||
f"Invalid public key: '{self.pubkey}' for event '{self.id}'"
|
||||
)
|
||||
|
||||
valid_signature = pub_key.schnorr_verify(
|
||||
bytes.fromhex(event_id), bytes.fromhex(self.sig), None, raw=True
|
||||
)
|
||||
if not valid_signature:
|
||||
raise ValueError(f"Invalid signature: '{self.sig}' for event '{self.id}'")
|
||||
|
||||
def stringify(self) -> str:
|
||||
return json.dumps(dict(self))
|
||||
|
||||
def tag_values(self, tag_name: str) -> List[str]:
|
||||
return [t[1] for t in self.tags if t[0] == tag_name]
|
||||
|
||||
def has_tag_value(self, tag_name: str, tag_value: str) -> bool:
|
||||
return tag_value in self.tag_values(tag_name)
|
||||
21
nostr/nostr_client.py
Normal file
21
nostr/nostr_client.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import httpx
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.app import settings
|
||||
from lnbits.helpers import url_for
|
||||
|
||||
from .event import NostrEvent
|
||||
|
||||
|
||||
async def publish_nostr_event(e: NostrEvent):
|
||||
url = url_for("/nostrclient/api/v1/publish", external=True)
|
||||
data = dict(e)
|
||||
print("### published", dict(data))
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
await client.post(
|
||||
url,
|
||||
json=data,
|
||||
)
|
||||
except Exception as ex:
|
||||
logger.warning(ex)
|
||||
|
|
@ -4,7 +4,6 @@
|
|||
unelevated
|
||||
color="primary"
|
||||
icon="public"
|
||||
label="Shipping Zones"
|
||||
@click="openZoneDialog()"
|
||||
>
|
||||
<q-list>
|
||||
|
|
@ -48,8 +47,8 @@
|
|||
v-model="zoneDialog.data.countries"
|
||||
></q-select>
|
||||
<q-select
|
||||
v-if="!zoneDialog.data.id"
|
||||
style="width: 100px"
|
||||
:disabled="!!zoneDialog.data.id"
|
||||
:readonly="!!zoneDialog.data.id"
|
||||
filled
|
||||
dense
|
||||
v-model="zoneDialog.data.currency"
|
||||
|
|
@ -60,7 +59,7 @@
|
|||
<q-input
|
||||
filled
|
||||
dense
|
||||
:label="'Amount (' + zoneDialog.data.currency + ') *'"
|
||||
:label="'Cost of Shipping (' + zoneDialog.data.currency + ') *'"
|
||||
fill-mask="0"
|
||||
reverse-fill-mask
|
||||
:step="zoneDialog.data.currency != 'sat' ? '0.01' : '1'"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,291 @@
|
|||
<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">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
|
||||
unelevated
|
||||
color="secondary"
|
||||
class="float-left"
|
||||
@click="updateStall()"
|
||||
>Update Stall</q-btn
|
||||
>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<q-btn
|
||||
unelevated
|
||||
color="pink"
|
||||
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
|
||||
unelevated
|
||||
color="green"
|
||||
icon="plus"
|
||||
class="float-left"
|
||||
@click="showNewProductDialog()"
|
||||
>New Product</q-btn
|
||||
>
|
||||
</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="pink"
|
||||
dense
|
||||
@click="deleteProduct(props.row.id)"
|
||||
icon="delete"
|
||||
/>
|
||||
</q-td>
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
size="sm"
|
||||
color="accent"
|
||||
dense
|
||||
@click="editProduct(props.row)"
|
||||
icon="edit"
|
||||
/>
|
||||
</q-td>
|
||||
|
||||
<q-td key="id" :props="props"> {{props.row.id}} </q-td>
|
||||
<q-td key="name" :props="props"> {{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-td key="categories" :props="props">
|
||||
<div>
|
||||
{{props.row.categories.filter(c => c).join(', ')}}
|
||||
</div>
|
||||
</q-td>
|
||||
<q-td key="description" :props="props">
|
||||
{{props.row.description}}
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
</q-table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-tab-panel>
|
||||
<q-tab-panel name="orders">
|
||||
<div v-if="stall"></div>
|
||||
</q-tab-panel>
|
||||
</q-tab-panels>
|
||||
<q-dialog v-model="productDialog.showDialog" position="top">
|
||||
<q-card v-if="stall" 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.description"
|
||||
label="Description"
|
||||
></q-input>
|
||||
<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>
|
||||
<q-toggle
|
||||
:label="`${productDialog.url ? 'Insert image URL' : 'Upload image file'}`"
|
||||
v-model="productDialog.url"
|
||||
></q-toggle>
|
||||
<q-input
|
||||
v-if="productDialog.url"
|
||||
filled
|
||||
dense
|
||||
v-model.trim="productDialog.data.image"
|
||||
type="url"
|
||||
label="Image URL"
|
||||
></q-input>
|
||||
<q-file
|
||||
v-else
|
||||
class="q-pr-md"
|
||||
filled
|
||||
dense
|
||||
capture="environment"
|
||||
accept="image/jpeg, image/png"
|
||||
:max-file-size="3*1024**2"
|
||||
label="Small image (optional)"
|
||||
clearable
|
||||
@input="imageAdded"
|
||||
@clear="imageCleared"
|
||||
>
|
||||
<template v-if="productDialog.data.image" v-slot:before>
|
||||
<img style="height: 1em" :src="productDialog.data.image" />
|
||||
</template>
|
||||
<template v-if="productDialog.data.image" v-slot:append>
|
||||
<q-icon
|
||||
name="cancel"
|
||||
@click.stop.prevent="imageCleared"
|
||||
class="cursor-pointer"
|
||||
/>
|
||||
</template>
|
||||
</q-file>
|
||||
|
||||
<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>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="productDialog.data.quantity"
|
||||
type="number"
|
||||
label="Quantity"
|
||||
></q-input>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
v-if="productDialog.data.id"
|
||||
unelevated
|
||||
color="primary"
|
||||
type="submit"
|
||||
>Update Product</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>
|
||||
</div>
|
||||
|
|
@ -1,15 +1,309 @@
|
|||
async function stallDetails(path) {
|
||||
const template = await loadTemplateAsync(path)
|
||||
|
||||
const pica = window.pica()
|
||||
|
||||
Vue.component('stall-details', {
|
||||
name: 'stall-details',
|
||||
template,
|
||||
|
||||
//props: ['stall-id', 'adminkey', 'inkey', 'wallet-options'],
|
||||
props: [
|
||||
'stall-id',
|
||||
'adminkey',
|
||||
'inkey',
|
||||
'wallet-options',
|
||||
'zone-options',
|
||||
'currencies'
|
||||
],
|
||||
data: function () {
|
||||
return {
|
||||
tab: 'info',
|
||||
relay: null
|
||||
tab: 'products',
|
||||
stall: null,
|
||||
products: [],
|
||||
productDialog: {
|
||||
showDialog: false,
|
||||
url: true,
|
||||
data: {
|
||||
id: null,
|
||||
name: '',
|
||||
description: '',
|
||||
categories: [],
|
||||
image: null,
|
||||
price: 0,
|
||||
quantity: 0
|
||||
}
|
||||
},
|
||||
productsFilter: '',
|
||||
productsTable: {
|
||||
columns: [
|
||||
{
|
||||
name: 'delete',
|
||||
align: 'left',
|
||||
label: '',
|
||||
field: ''
|
||||
},
|
||||
{
|
||||
name: 'edit',
|
||||
align: 'left',
|
||||
label: '',
|
||||
field: ''
|
||||
},
|
||||
|
||||
{
|
||||
name: 'id',
|
||||
align: 'left',
|
||||
label: 'ID',
|
||||
field: 'id'
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
align: 'left',
|
||||
label: 'Name',
|
||||
field: 'name'
|
||||
},
|
||||
{
|
||||
name: 'price',
|
||||
align: 'left',
|
||||
label: 'Price',
|
||||
field: 'price'
|
||||
},
|
||||
{
|
||||
name: 'quantity',
|
||||
align: 'left',
|
||||
label: 'Quantity',
|
||||
field: 'quantity'
|
||||
},
|
||||
{
|
||||
name: 'categories',
|
||||
align: 'left',
|
||||
label: 'Categories',
|
||||
field: 'categories'
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
align: 'left',
|
||||
label: 'Description',
|
||||
field: 'description'
|
||||
}
|
||||
],
|
||||
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
|
||||
},
|
||||
getStall: async function () {
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
'/nostrmarket/api/v1/stall/' + this.stallId,
|
||||
this.inkey
|
||||
)
|
||||
this.stall = this.mapStall(data)
|
||||
|
||||
console.log('### this.stall', this.stall)
|
||||
} 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)
|
||||
}
|
||||
})
|
||||
},
|
||||
imageAdded(file) {
|
||||
const image = new Image()
|
||||
image.src = URL.createObjectURL(file)
|
||||
image.onload = async () => {
|
||||
let fit = imgSizeFit(image)
|
||||
let canvas = document.createElement('canvas')
|
||||
canvas.setAttribute('width', fit.width)
|
||||
canvas.setAttribute('height', fit.height)
|
||||
output = await pica.resize(image, canvas)
|
||||
this.productDialog.data.image = output.toDataURL('image/jpeg', 0.4)
|
||||
this.productDialog = {...this.productDialog}
|
||||
}
|
||||
},
|
||||
imageCleared() {
|
||||
this.productDialog.data.image = null
|
||||
this.productDialog = {...this.productDialog}
|
||||
},
|
||||
getProducts: async function () {
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
'/nostrmarket/api/v1/product/' + this.stall.id,
|
||||
this.inkey
|
||||
)
|
||||
this.products = data
|
||||
|
||||
console.log('### this.products', this.products)
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
},
|
||||
sendProductFormData: function () {
|
||||
var data = {
|
||||
stall_id: this.stall.id,
|
||||
id: this.productDialog.data.id,
|
||||
name: this.productDialog.data.name,
|
||||
description: this.productDialog.data.description,
|
||||
categories: this.productDialog.data.categories,
|
||||
|
||||
image: this.productDialog.data.image,
|
||||
price: this.productDialog.data.price,
|
||||
quantity: this.productDialog.data.quantity
|
||||
}
|
||||
this.productDialog.showDialog = false
|
||||
if (this.productDialog.data.id) {
|
||||
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)
|
||||
}
|
||||
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) {
|
||||
this.productDialog.data = {...product}
|
||||
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 () {
|
||||
this.productDialog.data = {
|
||||
id: null,
|
||||
name: '',
|
||||
description: '',
|
||||
categories: [],
|
||||
image: null,
|
||||
price: 0,
|
||||
quantity: 0
|
||||
}
|
||||
this.productDialog.showDialog = true
|
||||
}
|
||||
},
|
||||
created: async function () {
|
||||
await this.getStall()
|
||||
await this.getProducts()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
149
static/components/stall-list/stall-list.html
Normal file
149
static/components/stall-list/stall-list.html
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
<div>
|
||||
<div class="row items-center no-wrap q-mb-md">
|
||||
<div class="col q-pr-lg">
|
||||
<q-btn
|
||||
@click="openCreateStallDialog"
|
||||
unelevated
|
||||
color="green"
|
||||
class="float-left"
|
||||
>New Stall</q-btn
|
||||
>
|
||||
<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="accent"
|
||||
round
|
||||
dense
|
||||
@click="props.row.expanded= !props.row.expanded"
|
||||
:icon="props.row.expanded? 'remove' : 'add'"
|
||||
/>
|
||||
</q-td>
|
||||
|
||||
<q-td key="id" :props="props">
|
||||
<a style="color: unset" :href="props.row.id" target="_blank">
|
||||
{{props.row.name}}</a
|
||||
>
|
||||
</q-td>
|
||||
|
||||
<q-td key="description" :props="props">
|
||||
{{props.row.config.description}}
|
||||
</q-td>
|
||||
<q-td key="shippingZones" :props="props">
|
||||
<div>
|
||||
{{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"
|
||||
></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"
|
||||
>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>
|
||||
</div>
|
||||
</div>
|
||||
175
static/components/stall-list/stall-list.js
Normal file
175
static/components/stall-list/stall-list.js
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
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: [],
|
||||
currencies: [],
|
||||
stallDialog: {
|
||||
show: 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: '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 () {
|
||||
await this.createStall({
|
||||
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
|
||||
}
|
||||
})
|
||||
},
|
||||
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)
|
||||
}
|
||||
},
|
||||
getCurrencies: async function () {
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
'/nostrmarket/api/v1/currencies',
|
||||
this.inkey
|
||||
)
|
||||
|
||||
this.currencies = ['sat', ...data]
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
},
|
||||
getStalls: async function () {
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
'/nostrmarket/api/v1/stall',
|
||||
this.inkey
|
||||
)
|
||||
this.stalls = data.map(s => ({...s, expanded: false}))
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
},
|
||||
getZones: async function () {
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
'/nostrmarket/api/v1/zone',
|
||||
this.inkey
|
||||
)
|
||||
this.zoneOptions = data.map(z => ({
|
||||
...z,
|
||||
label: z.name
|
||||
? `${z.name} (${z.countries.join(', ')})`
|
||||
: z.countries.join(', ')
|
||||
}))
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
},
|
||||
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 () {
|
||||
await this.getCurrencies()
|
||||
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 = {
|
||||
name: '',
|
||||
description: '',
|
||||
wallet: null,
|
||||
currency: 'sat',
|
||||
shippingZones: []
|
||||
}
|
||||
this.stallDialog.show = true
|
||||
}
|
||||
},
|
||||
created: async function () {
|
||||
await this.getStalls()
|
||||
await this.getCurrencies()
|
||||
await this.getZones()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -1,9 +1,10 @@
|
|||
const merchant = async () => {
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
|
||||
await stallDetails('static/components/stall-details/stall-details.html')
|
||||
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')
|
||||
|
||||
const nostr = window.NostrTools
|
||||
|
||||
|
|
@ -14,16 +15,49 @@ const merchant = async () => {
|
|||
return {
|
||||
merchant: {},
|
||||
shippingZones: [],
|
||||
showKeys: false
|
||||
showKeys: false,
|
||||
importKeyDialog: {
|
||||
show: false,
|
||||
data: {
|
||||
privateKey: null
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
generateKeys: async function () {
|
||||
const privkey = nostr.generatePrivateKey()
|
||||
const pubkey = nostr.getPublicKey(privkey)
|
||||
|
||||
const payload = {private_key: privkey, public_key: pubkey, config: {}}
|
||||
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
|
||||
},
|
||||
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',
|
||||
|
|
@ -33,10 +67,13 @@ const merchant = async () => {
|
|||
this.merchant = data
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message: 'Keys generated!'
|
||||
message: 'Merchant Created!'
|
||||
})
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
this.$q.notify({
|
||||
type: 'negative',
|
||||
message: `${error}`
|
||||
})
|
||||
}
|
||||
},
|
||||
getMerchant: async function () {
|
||||
|
|
|
|||
|
|
@ -16,3 +16,12 @@ function loadTemplateAsync(path) {
|
|||
|
||||
return result
|
||||
}
|
||||
|
||||
function imgSizeFit(img, maxWidth = 1024, maxHeight = 768) {
|
||||
let ratio = Math.min(
|
||||
1,
|
||||
maxWidth / img.naturalWidth,
|
||||
maxHeight / img.naturalHeight
|
||||
)
|
||||
return {width: img.naturalWidth * ratio, height: img.naturalHeight * ratio}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@
|
|||
<div class="row">
|
||||
<div class="col-12">
|
||||
<q-btn
|
||||
disabled
|
||||
@click="showImportKeysDialog"
|
||||
label="Import Key"
|
||||
color="primary"
|
||||
class="float-left"
|
||||
|
|
@ -61,20 +61,22 @@
|
|||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="row">
|
||||
<div class="col-6"></div>
|
||||
<div class="col-4">
|
||||
<div class="col-8"></div>
|
||||
<div class="col-2">
|
||||
<shipping-zones
|
||||
:inkey="g.user.wallets[0].inkey"
|
||||
:adminkey="g.user.wallets[0].adminkey"
|
||||
class="float-right"
|
||||
></shipping-zones>
|
||||
</div>
|
||||
<div class="col-2">
|
||||
<q-btn
|
||||
@click="showKeys = !showKeys"
|
||||
:label="showKeys ? 'Hide Keys' : 'Show Keys'"
|
||||
icon="vpn_key"
|
||||
color="primary"
|
||||
class="float-right"
|
||||
>
|
||||
<q-tooltip> Show Public or Private keys </q-tooltip>
|
||||
<q-tooltip> Show Public and Private keys </q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -86,6 +88,15 @@
|
|||
></key-pair>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
<q-card class="q-mt-lg">
|
||||
<q-card-section>
|
||||
<stall-list
|
||||
:adminkey="g.user.wallets[0].adminkey"
|
||||
:inkey="g.user.wallets[0].inkey"
|
||||
:wallet-options="g.user.walletOptions"
|
||||
></stall-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -102,14 +113,43 @@
|
|||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
<div>
|
||||
<q-dialog v-model="importKeyDialog.show" position="top">
|
||||
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
|
||||
<q-form @submit="importKeys" class="q-gutter-md">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="importKeyDialog.data.privateKey"
|
||||
label="Private Key (hex or nsec)"
|
||||
></q-input>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
:disable="!importKeyDialog.data.privateKey"
|
||||
type="submit"
|
||||
>Import</q-btn
|
||||
>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock%}{% block scripts %} {{ window_vars(user) }}
|
||||
<!-- todo: serve locally -->
|
||||
<script src="https://unpkg.com/nostr-tools/lib/nostr.bundle.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/pica@6.1.1/dist/pica.min.js"></script>
|
||||
|
||||
<script src="{{ url_for('nostrmarket_static', path='js/utils.js') }}"></script>
|
||||
<script src="{{ url_for('nostrmarket_static', path='components/stall-details/stall-details.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='js/index.js') }}"></script>
|
||||
|
||||
{% endblock %}
|
||||
|
|
|
|||
246
views_api.py
246
views_api.py
|
|
@ -1,3 +1,4 @@
|
|||
import json
|
||||
from http import HTTPStatus
|
||||
from typing import List, Optional
|
||||
|
||||
|
|
@ -16,14 +17,33 @@ from lnbits.utils.exchange_rates import currencies
|
|||
from . import nostrmarket_ext
|
||||
from .crud import (
|
||||
create_merchant,
|
||||
create_product,
|
||||
create_stall,
|
||||
create_zone,
|
||||
delete_product,
|
||||
delete_stall,
|
||||
delete_zone,
|
||||
get_merchant_for_user,
|
||||
get_products,
|
||||
get_stall,
|
||||
get_stalls,
|
||||
get_zone,
|
||||
get_zones,
|
||||
update_product,
|
||||
update_stall,
|
||||
update_zone,
|
||||
)
|
||||
from .models import Merchant, PartialMerchant, PartialZone, Zone
|
||||
from .models import (
|
||||
Merchant,
|
||||
PartialMerchant,
|
||||
PartialProduct,
|
||||
PartialStall,
|
||||
PartialZone,
|
||||
Product,
|
||||
Stall,
|
||||
Zone,
|
||||
)
|
||||
from .nostr.nostr_client import publish_nostr_event
|
||||
|
||||
######################################## MERCHANT ########################################
|
||||
|
||||
|
|
@ -138,6 +158,230 @@ async def api_delete_zone(zone_id, wallet: WalletTypeInfo = Depends(require_admi
|
|||
)
|
||||
|
||||
|
||||
######################################## STALLS ########################################
|
||||
|
||||
|
||||
@nostrmarket_ext.post("/api/v1/stall")
|
||||
async def api_create_stall(
|
||||
data: PartialStall,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
) -> Stall:
|
||||
try:
|
||||
data.validate_stall()
|
||||
|
||||
print("### stall", json.dumps(data.dict()))
|
||||
merchant = await get_merchant_for_user(wallet.wallet.user)
|
||||
assert merchant, "Cannot find merchat for stall"
|
||||
|
||||
stall = await create_stall(wallet.wallet.user, data=data)
|
||||
|
||||
event = stall.to_nostr_event(merchant.public_key)
|
||||
event.sig = merchant.sign_hash(bytes.fromhex(event.id))
|
||||
await publish_nostr_event(event)
|
||||
|
||||
stall.config.event_id = event.id
|
||||
await update_stall(wallet.wallet.user, stall)
|
||||
|
||||
return stall
|
||||
except ValueError as ex:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=str(ex),
|
||||
)
|
||||
except Exception as ex:
|
||||
logger.warning(ex)
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
detail="Cannot create stall",
|
||||
)
|
||||
|
||||
|
||||
@nostrmarket_ext.put("/api/v1/stall/{stall_id}")
|
||||
async def api_update_stall(
|
||||
data: Stall,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
) -> Stall:
|
||||
try:
|
||||
data.validate_stall()
|
||||
|
||||
merchant = await get_merchant_for_user(wallet.wallet.user)
|
||||
assert merchant, "Cannot find merchat for stall"
|
||||
|
||||
event = data.to_nostr_event(merchant.public_key)
|
||||
event.sig = merchant.sign_hash(bytes.fromhex(event.id))
|
||||
|
||||
data.config.event_id = event.id
|
||||
# data.config.event_created_at =
|
||||
stall = await update_stall(wallet.wallet.user, data)
|
||||
assert stall, "Cannot update stall"
|
||||
|
||||
await publish_nostr_event(event)
|
||||
|
||||
return stall
|
||||
except HTTPException as ex:
|
||||
raise ex
|
||||
except ValueError as ex:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=str(ex),
|
||||
)
|
||||
except Exception as ex:
|
||||
logger.warning(ex)
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
detail="Cannot update stall",
|
||||
)
|
||||
|
||||
|
||||
@nostrmarket_ext.get("/api/v1/stall/{stall_id}")
|
||||
async def api_get_stall(stall_id: str, wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||
try:
|
||||
stall = await get_stall(wallet.wallet.user, stall_id)
|
||||
if not stall:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND,
|
||||
detail="Stall does not exist.",
|
||||
)
|
||||
return stall
|
||||
except HTTPException as ex:
|
||||
raise ex
|
||||
except Exception as ex:
|
||||
logger.warning(ex)
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
detail="Cannot get stall",
|
||||
)
|
||||
|
||||
|
||||
@nostrmarket_ext.get("/api/v1/stall")
|
||||
async def api_get_stalls(wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||
try:
|
||||
stalls = await get_stalls(wallet.wallet.user)
|
||||
return stalls
|
||||
except Exception as ex:
|
||||
logger.warning(ex)
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
detail="Cannot get stalls",
|
||||
)
|
||||
|
||||
|
||||
@nostrmarket_ext.delete("/api/v1/stall/{stall_id}")
|
||||
async def api_delete_stall(
|
||||
stall_id: str, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
):
|
||||
try:
|
||||
stall = await get_stall(wallet.wallet.user, stall_id)
|
||||
if not stall:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND,
|
||||
detail="Stall does not exist.",
|
||||
)
|
||||
|
||||
merchant = await get_merchant_for_user(wallet.wallet.user)
|
||||
assert merchant, "Cannot find merchat for stall"
|
||||
|
||||
await delete_stall(wallet.wallet.user, stall_id)
|
||||
|
||||
delete_event = stall.to_nostr_delete_event(merchant.public_key)
|
||||
delete_event.sig = merchant.sign_hash(bytes.fromhex(delete_event.id))
|
||||
|
||||
await publish_nostr_event(delete_event)
|
||||
|
||||
except HTTPException as ex:
|
||||
raise ex
|
||||
except Exception as ex:
|
||||
logger.warning(ex)
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
detail="Cannot delete stall",
|
||||
)
|
||||
|
||||
|
||||
######################################## PRODUCTS ########################################
|
||||
|
||||
|
||||
@nostrmarket_ext.post("/api/v1/product")
|
||||
async def api_create_product(
|
||||
data: PartialProduct,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
) -> Product:
|
||||
try:
|
||||
data.validate_product()
|
||||
product = await create_product(wallet.wallet.user, data=data)
|
||||
|
||||
return product
|
||||
except ValueError as ex:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=str(ex),
|
||||
)
|
||||
except Exception as ex:
|
||||
logger.warning(ex)
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
detail="Cannot create product",
|
||||
)
|
||||
|
||||
|
||||
@nostrmarket_ext.patch("/api/v1/product/{product_id}")
|
||||
async def api_update_product(
|
||||
product_id: str,
|
||||
product: Product,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
) -> Product:
|
||||
try:
|
||||
product.validate_product()
|
||||
product = await update_product(wallet.wallet.user, product)
|
||||
|
||||
return product
|
||||
except ValueError as ex:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=str(ex),
|
||||
)
|
||||
except Exception as ex:
|
||||
logger.warning(ex)
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
detail="Cannot update product",
|
||||
)
|
||||
|
||||
|
||||
@nostrmarket_ext.get("/api/v1/product/{stall_id}")
|
||||
async def api_get_product(
|
||||
stall_id: str,
|
||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
||||
):
|
||||
try:
|
||||
products = await get_products(wallet.wallet.user, stall_id)
|
||||
return products
|
||||
except Exception as ex:
|
||||
logger.warning(ex)
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
detail="Cannot get product",
|
||||
)
|
||||
|
||||
|
||||
@nostrmarket_ext.delete("/api/v1/product/{product_id}")
|
||||
async def api_delete_product(
|
||||
product_id: str,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
):
|
||||
try:
|
||||
await delete_product(wallet.wallet.user, product_id)
|
||||
except Exception as ex:
|
||||
logger.warning(ex)
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
detail="Cannot delete product",
|
||||
)
|
||||
|
||||
|
||||
######################################## OTHER ########################################
|
||||
|
||||
|
||||
@nostrmarket_ext.get("/api/v1/currencies")
|
||||
async def api_list_currencies_available():
|
||||
return list(currencies.keys())
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue