Product delete (#64)
* feat: restore stalls from `nostr` as pending * feat: stall and prod last update time * feat: restore products and stalls as `pending` * feat: show pending stalls * feat: restore stall * feat: restore a stall from nostr * feat: add blank `Restore Product` button * fix: handle no talls to restore case * feat: show restore dialog * feat: allow query for pending products * feat: restore products * chore: code clean-up * fix: last dm and last order query * chore: code clean-up * fix: subscribe for stalls and products on merchant create/restore * feat: add message type to orders * feat: simplify messages; code format * feat: add type to DMs; restore DMs from nostr * fix: parsing ints * fix: hide copy button if invoice not present * fix: do not generate invoice if product not found * feat: order restore: first version * refactor: move some logic into `services` * feat: improve restore UX * fix: too many calls to customer DMs * fix: allow `All` customers filter * fix: ws reconnect on server restart * fix: query for customer profiles only one * fix: unread messages per customer per merchant * fix: disable `user-profile-events` * fix: customer profile is optional * fix: get customers after new message debounced * chore: code clean-up * feat: auto-create zone * feat: fixed ID for default zone * feat: notify order paid
This commit is contained in:
parent
1cb8fe86b1
commit
51c4147e65
17 changed files with 934 additions and 610 deletions
144
crud.py
144
crud.py
|
|
@ -72,12 +72,12 @@ async def get_merchant_by_pubkey(public_key: str) -> Optional[Merchant]:
|
|||
return Merchant.from_row(row) if row else None
|
||||
|
||||
|
||||
async def get_public_keys_for_merchants() -> List[str]:
|
||||
async def get_merchants_ids_with_pubkeys() -> List[str]:
|
||||
rows = await db.fetchall(
|
||||
"""SELECT public_key FROM nostrmarket.merchants""",
|
||||
"""SELECT id, public_key FROM nostrmarket.merchants""",
|
||||
)
|
||||
|
||||
return [row[0] for row in rows]
|
||||
return [(row[0], row[1]) for row in rows]
|
||||
|
||||
|
||||
async def get_merchant_for_user(user_id: str) -> Optional[Merchant]:
|
||||
|
|
@ -100,7 +100,7 @@ async def delete_merchant(merchant_id: str) -> None:
|
|||
|
||||
|
||||
async def create_zone(merchant_id: str, data: PartialZone) -> Zone:
|
||||
zone_id = urlsafe_short_hash()
|
||||
zone_id = data.id or urlsafe_short_hash()
|
||||
await db.execute(
|
||||
f"""
|
||||
INSERT INTO nostrmarket.zones (id, merchant_id, name, currency, cost, regions)
|
||||
|
|
@ -168,12 +168,14 @@ async def delete_merchant_zones(merchant_id: str) -> None:
|
|||
|
||||
|
||||
async def create_stall(merchant_id: str, data: PartialStall) -> Stall:
|
||||
stall_id = urlsafe_short_hash()
|
||||
stall_id = data.id or urlsafe_short_hash()
|
||||
|
||||
await db.execute(
|
||||
f"""
|
||||
INSERT INTO nostrmarket.stalls (merchant_id, id, wallet, name, currency, zones, meta)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO nostrmarket.stalls
|
||||
(merchant_id, id, wallet, name, currency, pending, event_id, event_created_at, zones, meta)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO NOTHING
|
||||
""",
|
||||
(
|
||||
merchant_id,
|
||||
|
|
@ -181,6 +183,9 @@ async def create_stall(merchant_id: str, data: PartialStall) -> Stall:
|
|||
data.wallet,
|
||||
data.name,
|
||||
data.currency,
|
||||
data.pending,
|
||||
data.event_id,
|
||||
data.event_created_at,
|
||||
json.dumps(
|
||||
[z.dict() for z in data.shipping_zones]
|
||||
), # todo: cost is float. should be int for sats
|
||||
|
|
@ -204,30 +209,42 @@ async def get_stall(merchant_id: str, stall_id: str) -> Optional[Stall]:
|
|||
return Stall.from_row(row) if row else None
|
||||
|
||||
|
||||
async def get_stalls(merchant_id: str) -> List[Stall]:
|
||||
async def get_stalls(merchant_id: str, pending: Optional[bool] = False) -> List[Stall]:
|
||||
rows = await db.fetchall(
|
||||
"SELECT * FROM nostrmarket.stalls WHERE merchant_id = ?",
|
||||
(merchant_id,),
|
||||
"SELECT * FROM nostrmarket.stalls WHERE merchant_id = ? AND pending = ?",
|
||||
(merchant_id, pending,),
|
||||
)
|
||||
return [Stall.from_row(row) for row in rows]
|
||||
|
||||
async def get_last_stall_update_time(merchant_id: str) -> int:
|
||||
row = await db.fetchone(
|
||||
"""
|
||||
SELECT event_created_at FROM nostrmarket.stalls
|
||||
WHERE merchant_id = ? ORDER BY event_created_at DESC LIMIT 1
|
||||
""",
|
||||
(merchant_id,),
|
||||
)
|
||||
return row[0] or 0 if row else 0
|
||||
|
||||
async def update_stall(merchant_id: str, stall: Stall) -> Optional[Stall]:
|
||||
await db.execute(
|
||||
f"""
|
||||
UPDATE nostrmarket.stalls SET wallet = ?, name = ?, currency = ?, zones = ?, meta = ?
|
||||
UPDATE nostrmarket.stalls SET wallet = ?, name = ?, currency = ?, pending = ?, event_id = ?, event_created_at = ?, zones = ?, meta = ?
|
||||
WHERE merchant_id = ? AND id = ?
|
||||
""",
|
||||
(
|
||||
stall.wallet,
|
||||
stall.name,
|
||||
stall.currency,
|
||||
stall.pending,
|
||||
stall.event_id,
|
||||
stall.event_created_at,
|
||||
json.dumps(
|
||||
[z.dict() for z in stall.shipping_zones]
|
||||
), # todo: cost is float. should be int for sats
|
||||
json.dumps(stall.config.dict()),
|
||||
merchant_id,
|
||||
stall.id,
|
||||
stall.id
|
||||
),
|
||||
)
|
||||
return await get_stall(merchant_id, stall.id)
|
||||
|
|
@ -254,12 +271,14 @@ async def delete_merchant_stalls(merchant_id: str) -> None:
|
|||
|
||||
|
||||
async def create_product(merchant_id: str, data: PartialProduct) -> Product:
|
||||
product_id = urlsafe_short_hash()
|
||||
product_id = data.id or urlsafe_short_hash()
|
||||
|
||||
await db.execute(
|
||||
f"""
|
||||
INSERT INTO nostrmarket.products (merchant_id, id, stall_id, name, price, quantity, image_urls, category_list, meta)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO nostrmarket.products
|
||||
(merchant_id, id, stall_id, name, price, quantity, pending, event_id, event_created_at, image_urls, category_list, meta)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO NOTHING
|
||||
""",
|
||||
(
|
||||
merchant_id,
|
||||
|
|
@ -268,6 +287,9 @@ async def create_product(merchant_id: str, data: PartialProduct) -> Product:
|
|||
data.name,
|
||||
data.price,
|
||||
data.quantity,
|
||||
data.pending,
|
||||
data.event_id,
|
||||
data.event_created_at,
|
||||
json.dumps(data.images),
|
||||
json.dumps(data.categories),
|
||||
json.dumps(data.config.dict()),
|
||||
|
|
@ -283,13 +305,16 @@ async def update_product(merchant_id: str, product: Product) -> Product:
|
|||
|
||||
await db.execute(
|
||||
f"""
|
||||
UPDATE nostrmarket.products set name = ?, price = ?, quantity = ?, image_urls = ?, category_list = ?, meta = ?
|
||||
UPDATE nostrmarket.products set name = ?, price = ?, quantity = ?, pending = ?, event_id =?, event_created_at = ?, image_urls = ?, category_list = ?, meta = ?
|
||||
WHERE merchant_id = ? AND id = ?
|
||||
""",
|
||||
(
|
||||
product.name,
|
||||
product.price,
|
||||
product.quantity,
|
||||
product.pending,
|
||||
product.event_id,
|
||||
product.event_created_at,
|
||||
json.dumps(product.images),
|
||||
json.dumps(product.categories),
|
||||
json.dumps(product.config.dict()),
|
||||
|
|
@ -328,10 +353,10 @@ async def get_product(merchant_id: str, product_id: str) -> Optional[Product]:
|
|||
return Product.from_row(row) if row else None
|
||||
|
||||
|
||||
async def get_products(merchant_id: str, stall_id: str) -> List[Product]:
|
||||
async def get_products(merchant_id: str, stall_id: str, pending: Optional[bool] = False) -> List[Product]:
|
||||
rows = await db.fetchall(
|
||||
"SELECT * FROM nostrmarket.products WHERE merchant_id = ? AND stall_id = ?",
|
||||
(merchant_id, stall_id),
|
||||
"SELECT * FROM nostrmarket.products WHERE merchant_id = ? AND stall_id = ? AND pending = ?",
|
||||
(merchant_id, stall_id, pending),
|
||||
)
|
||||
return [Product.from_row(row) for row in rows]
|
||||
|
||||
|
|
@ -341,7 +366,11 @@ async def get_products_by_ids(
|
|||
) -> List[Product]:
|
||||
q = ",".join(["?"] * len(product_ids))
|
||||
rows = await db.fetchall(
|
||||
f"SELECT id, stall_id, name, price, quantity, category_list, meta FROM nostrmarket.products WHERE merchant_id = ? AND id IN ({q})",
|
||||
f"""
|
||||
SELECT id, stall_id, name, price, quantity, category_list, meta
|
||||
FROM nostrmarket.products
|
||||
WHERE merchant_id = ? AND pending = false AND id IN ({q})
|
||||
""",
|
||||
(merchant_id, *product_ids),
|
||||
)
|
||||
return [Product.from_row(row) for row in rows]
|
||||
|
|
@ -353,12 +382,21 @@ async def get_wallet_for_product(product_id: str) -> Optional[str]:
|
|||
SELECT s.wallet FROM nostrmarket.products p
|
||||
INNER JOIN nostrmarket.stalls s
|
||||
ON p.stall_id = s.id
|
||||
WHERE p.id=?
|
||||
WHERE p.id = ? AND p.pending = false AND s.pending = false
|
||||
""",
|
||||
(product_id,),
|
||||
)
|
||||
return row[0] if row else None
|
||||
|
||||
async def get_last_product_update_time(merchant_id: str) -> int:
|
||||
row = await db.fetchone(
|
||||
"""
|
||||
SELECT event_created_at FROM nostrmarket.products
|
||||
WHERE merchant_id = ? ORDER BY event_created_at DESC LIMIT 1
|
||||
""",
|
||||
(merchant_id,),
|
||||
)
|
||||
return row[0] or 0 if row else 0
|
||||
|
||||
async def delete_product(merchant_id: str, product_id: str) -> None:
|
||||
await db.execute(
|
||||
|
|
@ -456,7 +494,7 @@ async def get_orders(merchant_id: str, **kwargs) -> List[Order]:
|
|||
q = f"AND {q}"
|
||||
values = (v for v in kwargs.values() if v != None)
|
||||
rows = await db.fetchall(
|
||||
f"SELECT * FROM nostrmarket.orders WHERE merchant_id = ? {q} ORDER BY time DESC",
|
||||
f"SELECT * FROM nostrmarket.orders WHERE merchant_id = ? {q} ORDER BY event_created_at DESC",
|
||||
(merchant_id, *values),
|
||||
)
|
||||
return [Order.from_row(row) for row in rows]
|
||||
|
|
@ -479,17 +517,29 @@ async def get_orders_for_stall(
|
|||
return [Order.from_row(row) for row in rows]
|
||||
|
||||
|
||||
async def get_last_order_time(public_key: str) -> int:
|
||||
async def get_last_order_time(merchant_id: str) -> int:
|
||||
row = await db.fetchone(
|
||||
"""
|
||||
SELECT event_created_at FROM nostrmarket.orders
|
||||
WHERE merchant_public_key = ? ORDER BY event_created_at DESC LIMIT 1
|
||||
WHERE merchant_id = ? ORDER BY event_created_at DESC LIMIT 1
|
||||
""",
|
||||
(public_key,),
|
||||
(merchant_id,),
|
||||
)
|
||||
return row[0] if row else 0
|
||||
|
||||
|
||||
async def update_order(merchant_id: str, order_id: str, **kwargs) -> Optional[Order]:
|
||||
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
|
||||
await db.execute(
|
||||
f"""
|
||||
UPDATE nostrmarket.orders SET {q} WHERE merchant_id = ? and id = ?
|
||||
""",
|
||||
(*kwargs.values(), merchant_id, order_id)
|
||||
)
|
||||
|
||||
return await get_order(merchant_id, order_id)
|
||||
|
||||
|
||||
async def update_order_paid_status(order_id: str, paid: bool) -> Optional[Order]:
|
||||
await db.execute(
|
||||
f"UPDATE nostrmarket.orders SET paid = ? WHERE id = ?",
|
||||
|
|
@ -533,8 +583,8 @@ async def create_direct_message(
|
|||
dm_id = urlsafe_short_hash()
|
||||
await db.execute(
|
||||
f"""
|
||||
INSERT INTO nostrmarket.direct_messages (merchant_id, id, event_id, event_created_at, message, public_key, incoming)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO nostrmarket.direct_messages (merchant_id, id, event_id, event_created_at, message, public_key, type, incoming)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(event_id) DO NOTHING
|
||||
""",
|
||||
(
|
||||
|
|
@ -544,6 +594,7 @@ async def create_direct_message(
|
|||
dm.event_created_at,
|
||||
dm.message,
|
||||
dm.public_key,
|
||||
dm.type,
|
||||
dm.incoming,
|
||||
),
|
||||
)
|
||||
|
|
@ -586,14 +637,24 @@ async def get_direct_messages(merchant_id: str, public_key: str) -> List[DirectM
|
|||
)
|
||||
return [DirectMessage.from_row(row) for row in rows]
|
||||
|
||||
async def get_orders_from_direct_messages(merchant_id: str) -> List[DirectMessage]:
|
||||
rows = await db.fetchall(
|
||||
"SELECT * FROM nostrmarket.direct_messages WHERE merchant_id = ? AND type >= 0 ORDER BY event_created_at, type",
|
||||
(merchant_id),
|
||||
)
|
||||
return [DirectMessage.from_row(row) for row in rows]
|
||||
|
||||
async def get_last_direct_messages_time(public_key: str) -> int:
|
||||
|
||||
|
||||
|
||||
|
||||
async def get_last_direct_messages_time(merchant_id: str) -> int:
|
||||
row = await db.fetchone(
|
||||
"""
|
||||
SELECT event_created_at FROM nostrmarket.direct_messages
|
||||
WHERE public_key = ? ORDER BY event_created_at DESC LIMIT 1
|
||||
WHERE merchant_id = ? ORDER BY event_created_at DESC LIMIT 1
|
||||
""",
|
||||
(public_key,),
|
||||
(merchant_id,),
|
||||
)
|
||||
return row[0] if row else 0
|
||||
|
||||
|
|
@ -644,8 +705,13 @@ async def get_customers(merchant_id: str) -> List[Customer]:
|
|||
return [Customer.from_row(row) for row in rows]
|
||||
|
||||
|
||||
async def get_all_customers() -> List[Customer]:
|
||||
rows = await db.fetchall("SELECT * FROM nostrmarket.customers")
|
||||
async def get_all_unique_customers() -> List[Customer]:
|
||||
q = """
|
||||
SELECT public_key, MAX(merchant_id) as merchant_id, MAX(event_created_at)
|
||||
FROM nostrmarket.customers
|
||||
GROUP BY public_key
|
||||
"""
|
||||
rows = await db.fetchall(q)
|
||||
return [Customer.from_row(row) for row in rows]
|
||||
|
||||
|
||||
|
|
@ -658,15 +724,15 @@ async def update_customer_profile(
|
|||
)
|
||||
|
||||
|
||||
async def increment_customer_unread_messages(public_key: str):
|
||||
async def increment_customer_unread_messages(merchant_id: str, public_key: str):
|
||||
await db.execute(
|
||||
f"UPDATE nostrmarket.customers SET unread_messages = unread_messages + 1 WHERE public_key = ?",
|
||||
(public_key,),
|
||||
f"UPDATE nostrmarket.customers SET unread_messages = unread_messages + 1 WHERE merchant_id = ? AND public_key = ?",
|
||||
(merchant_id, public_key,),
|
||||
)
|
||||
|
||||
|
||||
async def update_customer_no_unread_messages(public_key: str):
|
||||
#??? two merchants
|
||||
async def update_customer_no_unread_messages(merchant_id: str, public_key: str):
|
||||
await db.execute(
|
||||
f"UPDATE nostrmarket.customers SET unread_messages = 0 WHERE public_key = ?",
|
||||
(public_key,),
|
||||
f"UPDATE nostrmarket.customers SET unread_messages = 0 WHERE merchant_id =? AND public_key = ?",
|
||||
(merchant_id, public_key,),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -75,14 +75,6 @@ def copy_x(output, x32, y32, data):
|
|||
return 1
|
||||
|
||||
|
||||
def order_from_json(s: str) -> Tuple[Optional[Any], Optional[str]]:
|
||||
try:
|
||||
order = json.loads(s)
|
||||
return (order, s) if (type(order) is dict) and "items" in order else (None, s)
|
||||
except ValueError:
|
||||
return None, s
|
||||
|
||||
|
||||
def normalize_public_key(pubkey: str) -> str:
|
||||
if pubkey.startswith("npub1"):
|
||||
_, decoded_data = bech32_decode(pubkey)
|
||||
|
|
|
|||
|
|
@ -141,3 +141,30 @@ 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;"
|
||||
)
|
||||
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;"
|
||||
)
|
||||
await db.execute(
|
||||
"ALTER TABLE nostrmarket.products ADD COLUMN event_created_at INTEGER;"
|
||||
)
|
||||
|
||||
|
||||
async def m003_update_direct_message_type(db):
|
||||
await db.execute(
|
||||
"ALTER TABLE nostrmarket.direct_messages ADD COLUMN type INTEGER NOT NULL DEFAULT -1;"
|
||||
)
|
||||
51
models.py
51
models.py
|
|
@ -1,8 +1,9 @@
|
|||
import json
|
||||
import time
|
||||
from abc import abstractmethod
|
||||
from enum import Enum
|
||||
from sqlite3 import Row
|
||||
from typing import List, Optional, Tuple
|
||||
from typing import Any, List, Optional, Tuple
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
|
@ -120,6 +121,7 @@ class Merchant(PartialMerchant, Nostrable):
|
|||
|
||||
######################################## ZONES ########################################
|
||||
class PartialZone(BaseModel):
|
||||
id: Optional[str]
|
||||
name: Optional[str]
|
||||
currency: str
|
||||
cost: float
|
||||
|
|
@ -140,19 +142,22 @@ class Zone(PartialZone):
|
|||
|
||||
|
||||
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):
|
||||
id: Optional[str]
|
||||
wallet: str
|
||||
name: str
|
||||
currency: str = "sat"
|
||||
shipping_zones: List[Zone] = []
|
||||
config: StallConfig = StallConfig()
|
||||
pending: bool = False
|
||||
|
||||
"""Last published nostr event for this Stall"""
|
||||
event_id: Optional[str]
|
||||
event_created_at: Optional[int]
|
||||
|
||||
def validate_stall(self):
|
||||
for z in self.shipping_zones:
|
||||
|
|
@ -189,7 +194,7 @@ class Stall(PartialStall, Nostrable):
|
|||
pubkey=pubkey,
|
||||
created_at=round(time.time()),
|
||||
kind=5,
|
||||
tags=[["e", self.config.event_id]],
|
||||
tags=[["e", self.event_id]],
|
||||
content=f"Stall '{self.name}' deleted",
|
||||
)
|
||||
delete_event.id = delete_event.event_id
|
||||
|
|
@ -204,24 +209,29 @@ class Stall(PartialStall, Nostrable):
|
|||
return stall
|
||||
|
||||
|
||||
######################################## STALLS ########################################
|
||||
######################################## PRODUCTS ########################################
|
||||
|
||||
|
||||
class ProductConfig(BaseModel):
|
||||
event_id: Optional[str]
|
||||
description: Optional[str]
|
||||
currency: Optional[str]
|
||||
|
||||
|
||||
class PartialProduct(BaseModel):
|
||||
id: Optional[str]
|
||||
stall_id: str
|
||||
name: str
|
||||
categories: List[str] = []
|
||||
images: List[str] = []
|
||||
price: float
|
||||
quantity: int
|
||||
pending: bool = False
|
||||
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
|
||||
|
|
@ -255,7 +265,7 @@ class Product(PartialProduct, Nostrable):
|
|||
pubkey=pubkey,
|
||||
created_at=round(time.time()),
|
||||
kind=5,
|
||||
tags=[["e", self.config.event_id]],
|
||||
tags=[["e", self.event_id]],
|
||||
content=f"Product '{self.name}' deleted",
|
||||
)
|
||||
delete_event.id = delete_event.event_id
|
||||
|
|
@ -300,7 +310,7 @@ class OrderExtra(BaseModel):
|
|||
|
||||
@classmethod
|
||||
async def from_products(cls, products: List[Product]):
|
||||
currency = products[0].config.currency
|
||||
currency = products[0].config.currency if len(products) else "sat"
|
||||
exchange_rate = (
|
||||
(await btc_price(currency)) if currency and currency != "sat" else 1
|
||||
)
|
||||
|
|
@ -401,14 +411,34 @@ class PaymentRequest(BaseModel):
|
|||
######################################## MESSAGE ########################################
|
||||
|
||||
|
||||
|
||||
class DirectMessageType(Enum):
|
||||
"""Various types os direct messages."""
|
||||
PLAIN_TEXT = -1
|
||||
CUSTOMER_ORDER = 0
|
||||
PAYMENT_REQUEST = 1
|
||||
ORDER_PAID_OR_SHIPPED = 2
|
||||
|
||||
class PartialDirectMessage(BaseModel):
|
||||
event_id: Optional[str]
|
||||
event_created_at: Optional[int]
|
||||
message: str
|
||||
public_key: str
|
||||
type: int = DirectMessageType.PLAIN_TEXT.value
|
||||
incoming: bool = False
|
||||
time: Optional[int]
|
||||
|
||||
@classmethod
|
||||
def parse_message(cls, msg) -> Tuple[DirectMessageType, Optional[Any]]:
|
||||
try:
|
||||
msg_json = json.loads(msg)
|
||||
if "type" in msg_json:
|
||||
return DirectMessageType(msg_json["type"]), msg_json
|
||||
|
||||
return DirectMessageType.PLAIN_TEXT, None
|
||||
except Exception:
|
||||
return DirectMessageType.PLAIN_TEXT, None
|
||||
|
||||
|
||||
class DirectMessage(PartialDirectMessage):
|
||||
id: str
|
||||
|
|
@ -419,6 +449,7 @@ class DirectMessage(PartialDirectMessage):
|
|||
return dm
|
||||
|
||||
|
||||
|
||||
######################################## CUSTOMERS ########################################
|
||||
|
||||
|
||||
|
|
@ -437,5 +468,5 @@ class Customer(BaseModel):
|
|||
@classmethod
|
||||
def from_row(cls, row: Row) -> "Customer":
|
||||
customer = cls(**dict(row))
|
||||
customer.profile = CustomerProfile(**json.loads(row["meta"]))
|
||||
customer.profile = CustomerProfile(**json.loads(row["meta"])) if "meta" in row else None
|
||||
return customer
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import asyncio
|
|||
import json
|
||||
from asyncio import Queue
|
||||
from threading import Thread
|
||||
from typing import Callable
|
||||
from typing import Callable, List
|
||||
|
||||
from loguru import logger
|
||||
from websocket import WebSocketApp
|
||||
|
|
@ -18,11 +18,6 @@ class NostrClient:
|
|||
self.send_req_queue: Queue = Queue()
|
||||
self.ws: WebSocketApp = None
|
||||
|
||||
async def restart(self):
|
||||
await self.send_req_queue.put(ValueError("Restarting NostrClient..."))
|
||||
await self.recieve_event_queue.put(ValueError("Restarting NostrClient..."))
|
||||
self.ws.close()
|
||||
self.ws = None
|
||||
|
||||
async def connect_to_nostrclient_ws(
|
||||
self, on_open: Callable, on_message: Callable
|
||||
|
|
@ -96,36 +91,80 @@ class NostrClient:
|
|||
["REQ", f"direct-messages-out:{public_key}", out_messages_filter]
|
||||
)
|
||||
|
||||
async def subscribe_to_merchant_events(self, public_key: str, since: int):
|
||||
logger.debug(f"Subscribed to direct-messages '{public_key}'.")
|
||||
|
||||
async def subscribe_to_stall_events(self, public_key: str, since: int):
|
||||
stall_filter = {"kinds": [30017], "authors": [public_key]}
|
||||
product_filter = {"kinds": [30018], "authors": [public_key]}
|
||||
if since and since != 0:
|
||||
stall_filter["since"] = since
|
||||
|
||||
await self.send_req_queue.put(
|
||||
["REQ", f"stall-events:{public_key}", stall_filter]
|
||||
)
|
||||
|
||||
logger.debug(f"Subscribed to stall-events: '{public_key}'.")
|
||||
|
||||
async def subscribe_to_product_events(self, public_key: str, since: int):
|
||||
product_filter = {"kinds": [30018], "authors": [public_key]}
|
||||
if since and since != 0:
|
||||
product_filter["since"] = since
|
||||
|
||||
await self.send_req_queue.put(
|
||||
["REQ", f"product-events:{public_key}", product_filter]
|
||||
)
|
||||
logger.debug(f"Subscribed to product-events: '{public_key}'.")
|
||||
|
||||
|
||||
async def subscribe_to_user_profile(self, public_key: str, since: int):
|
||||
profile_filter = {"kinds": [0], "authors": [public_key]}
|
||||
if since and since != 0:
|
||||
profile_filter["since"] = since + 1
|
||||
|
||||
await self.send_req_queue.put(
|
||||
["REQ", f"user-profile-events:{public_key}", profile_filter]
|
||||
)
|
||||
# Disabled for now. The number of clients can grow large.
|
||||
# Some relays only allow a small number of subscriptions.
|
||||
# There is the risk that more important subscriptions will be blocked.
|
||||
# await self.send_req_queue.put(
|
||||
# ["REQ", f"user-profile-events:{public_key}", profile_filter]
|
||||
# )
|
||||
|
||||
async def unsubscribe_from_direct_messages(self, public_key: str):
|
||||
await self.send_req_queue.put(["CLOSE", f"direct-messages-in:{public_key}"])
|
||||
await self.send_req_queue.put(["CLOSE", f"direct-messages-out:{public_key}"])
|
||||
|
||||
logger.debug(f"Unsubscribed from direct-messages '{public_key}'.")
|
||||
|
||||
async def unsubscribe_from_merchant_events(self, public_key: str):
|
||||
await self.send_req_queue.put(["CLOSE", f"stall-events:{public_key}"])
|
||||
await self.send_req_queue.put(["CLOSE", f"product-events:{public_key}"])
|
||||
|
||||
def stop(self):
|
||||
try:
|
||||
self.ws.close()
|
||||
except Exception as ex:
|
||||
logger.warning(ex)
|
||||
logger.debug(f"Unsubscribed from stall-events and product-events '{public_key}'.")
|
||||
|
||||
async def restart(self, public_keys: List[str]):
|
||||
await self.unsubscribe_merchants(public_keys)
|
||||
# Give some time for the CLOSE events to propagate before restarting
|
||||
await asyncio.sleep(10)
|
||||
|
||||
logger.info("Restating NostrClient...")
|
||||
await self.send_req_queue.put(ValueError("Restarting NostrClient..."))
|
||||
await self.recieve_event_queue.put(ValueError("Restarting NostrClient..."))
|
||||
|
||||
self.ws.close()
|
||||
self.ws = None
|
||||
|
||||
|
||||
async def stop(self, public_keys: List[str]):
|
||||
await self.unsubscribe_merchants(public_keys)
|
||||
|
||||
# Give some time for the CLOSE events to propagate before closing the connection
|
||||
await asyncio.sleep(10)
|
||||
self.ws.close()
|
||||
self.ws = None
|
||||
|
||||
async def unsubscribe_merchants(self, public_keys: List[str]):
|
||||
for pk in public_keys:
|
||||
try:
|
||||
await self.unsubscribe_from_direct_messages(pk)
|
||||
await self.unsubscribe_from_merchant_events(pk)
|
||||
except Exception as ex:
|
||||
logger.warning(ex)
|
||||
|
||||
|
|
|
|||
214
services.py
214
services.py
|
|
@ -3,6 +3,7 @@ from typing import List, Optional, Tuple
|
|||
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.bolt11 import decode
|
||||
from lnbits.core import create_invoice, get_wallet
|
||||
from lnbits.core.services import websocketUpdater
|
||||
|
||||
|
|
@ -12,6 +13,8 @@ from .crud import (
|
|||
create_customer,
|
||||
create_direct_message,
|
||||
create_order,
|
||||
create_product,
|
||||
create_stall,
|
||||
get_customer,
|
||||
get_merchant_by_pubkey,
|
||||
get_order,
|
||||
|
|
@ -23,17 +26,21 @@ from .crud import (
|
|||
get_zone,
|
||||
increment_customer_unread_messages,
|
||||
update_customer_profile,
|
||||
update_order,
|
||||
update_order_paid_status,
|
||||
update_order_shipped_status,
|
||||
update_product,
|
||||
update_product_quantity,
|
||||
update_stall,
|
||||
)
|
||||
from .helpers import order_from_json
|
||||
from .models import (
|
||||
Customer,
|
||||
DirectMessage,
|
||||
DirectMessageType,
|
||||
Merchant,
|
||||
Nostrable,
|
||||
Order,
|
||||
OrderContact,
|
||||
OrderExtra,
|
||||
OrderItem,
|
||||
OrderStatusUpdate,
|
||||
|
|
@ -42,6 +49,7 @@ from .models import (
|
|||
PaymentOption,
|
||||
PaymentRequest,
|
||||
Product,
|
||||
Stall,
|
||||
)
|
||||
from .nostr.event import NostrEvent
|
||||
|
||||
|
|
@ -126,10 +134,12 @@ async def update_merchant_to_nostr(
|
|||
products = await get_products(merchant.id, stall.id)
|
||||
for product in products:
|
||||
event = await sign_and_send_to_nostr(merchant, product, delete_merchant)
|
||||
product.config.event_id = event.id
|
||||
product.event_id = event.id
|
||||
product.event_created_at = event.created_at
|
||||
await update_product(merchant.id, product)
|
||||
event = await sign_and_send_to_nostr(merchant, stall, delete_merchant)
|
||||
stall.config.event_id = event.id
|
||||
stall.event_id = event.id
|
||||
stall.event_created_at = event.created_at
|
||||
await update_stall(merchant.id, stall)
|
||||
if delete_merchant:
|
||||
# merchant profile updates not supported yet
|
||||
|
|
@ -163,6 +173,16 @@ async def handle_order_paid(order_id: str, merchant_pubkey: str):
|
|||
# todo: lock
|
||||
success, message = await update_products_for_order(merchant, order)
|
||||
await notify_client_of_order_status(order, merchant, success, message)
|
||||
|
||||
await websocketUpdater(
|
||||
merchant.id,
|
||||
json.dumps(
|
||||
{
|
||||
"type": "order-paid",
|
||||
"orderId": order_id,
|
||||
}
|
||||
),
|
||||
)
|
||||
except Exception as ex:
|
||||
logger.warning(ex)
|
||||
|
||||
|
|
@ -179,12 +199,26 @@ async def notify_client_of_order_status(
|
|||
shipped=order.shipped,
|
||||
)
|
||||
dm_content = json.dumps(
|
||||
order_status.dict(), separators=(",", ":"), ensure_ascii=False
|
||||
{"type": DirectMessageType.ORDER_PAID_OR_SHIPPED.value, **order_status.dict()},
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
)
|
||||
else:
|
||||
dm_content = f"Order cannot be fulfilled. Reason: {message}"
|
||||
|
||||
dm_event = merchant.build_dm_event(dm_content, order.public_key)
|
||||
|
||||
dm = PartialDirectMessage(
|
||||
event_id=dm_event.id,
|
||||
event_created_at=dm_event.created_at,
|
||||
message=dm_content,
|
||||
public_key=order.public_key,
|
||||
type=DirectMessageType.ORDER_PAID_OR_SHIPPED.value
|
||||
if success
|
||||
else DirectMessageType.PLAIN_TEXT.value,
|
||||
)
|
||||
await create_direct_message(merchant.id, dm)
|
||||
|
||||
await nostr_client.publish_nostr_event(dm_event)
|
||||
|
||||
|
||||
|
|
@ -201,7 +235,7 @@ async def update_products_for_order(
|
|||
for p in products:
|
||||
product = await update_product_quantity(p.id, p.quantity)
|
||||
event = await sign_and_send_to_nostr(merchant, product)
|
||||
product.config.event_id = event.id
|
||||
product.event_id = event.id
|
||||
await update_product(merchant.id, product)
|
||||
|
||||
return True, "ok"
|
||||
|
|
@ -233,19 +267,84 @@ async def compute_products_new_quantity(
|
|||
async def process_nostr_message(msg: str):
|
||||
try:
|
||||
type, *rest = json.loads(msg)
|
||||
|
||||
if type.upper() == "EVENT":
|
||||
subscription_id, event = rest
|
||||
event = NostrEvent(**event)
|
||||
print("kind: ", event.kind, ": ", msg)
|
||||
if event.kind == 0:
|
||||
await _handle_customer_profile_update(event)
|
||||
if event.kind == 4:
|
||||
elif event.kind == 4:
|
||||
_, merchant_public_key = subscription_id.split(":")
|
||||
await _handle_nip04_message(merchant_public_key, event)
|
||||
elif event.kind == 30017:
|
||||
await _handle_stall(event)
|
||||
elif event.kind == 30018:
|
||||
await _handle_product(event)
|
||||
return
|
||||
|
||||
except Exception as ex:
|
||||
logger.warning(ex)
|
||||
|
||||
|
||||
async def create_or_update_order_from_dm(merchant_id: str, merchant_pubkey: str, dm: DirectMessage):
|
||||
type, value = PartialDirectMessage.parse_message(dm.message)
|
||||
if "id" not in value:
|
||||
return
|
||||
|
||||
if type == DirectMessageType.CUSTOMER_ORDER:
|
||||
order = await extract_order_from_dm(merchant_id, merchant_pubkey, dm, value)
|
||||
new_order = await create_order(merchant_id, order)
|
||||
if new_order.stall_id == "None" and order.stall_id != "None":
|
||||
await update_order(merchant_id, order.id, **{
|
||||
"stall_id": order.stall_id,
|
||||
"extra_data": json.dumps(order.extra.dict())
|
||||
})
|
||||
return
|
||||
|
||||
if type == DirectMessageType.PAYMENT_REQUEST:
|
||||
payment_request = PaymentRequest(**value)
|
||||
pr = next((o.link for o in payment_request.payment_options if o.type == "ln"), None)
|
||||
if not pr:
|
||||
return
|
||||
invoice = decode(pr)
|
||||
await update_order(merchant_id, payment_request.id, **{
|
||||
"total": invoice.amount_msat / 1000,
|
||||
"invoice_id": invoice.payment_hash
|
||||
})
|
||||
return
|
||||
|
||||
if type == DirectMessageType.ORDER_PAID_OR_SHIPPED:
|
||||
order_update = OrderStatusUpdate(**value)
|
||||
if order_update.paid:
|
||||
await update_order_paid_status(order_update.id, True)
|
||||
if order_update.shipped:
|
||||
await update_order_shipped_status(merchant_id, order_update.id, True)
|
||||
|
||||
|
||||
async def extract_order_from_dm(merchant_id: str, merchant_pubkey: str, dm: DirectMessage, value):
|
||||
order_items = [OrderItem(**i) for i in value.get("items", [])]
|
||||
products = await get_products_by_ids(merchant_id, [p.product_id for p in order_items])
|
||||
extra = await OrderExtra.from_products(products)
|
||||
order = Order(
|
||||
id=value.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=value.get("shipping_id", "None"),
|
||||
items=order_items,
|
||||
contact=OrderContact(**value.get("contact")) if value.get("contact") else None,
|
||||
address=value.get("address"),
|
||||
stall_id=products[0].stall_id if len(products) else "None",
|
||||
invoice_id="None",
|
||||
total=0,
|
||||
extra=extra
|
||||
)
|
||||
|
||||
return order
|
||||
|
||||
|
||||
async def _handle_nip04_message(merchant_public_key: str, event: NostrEvent):
|
||||
merchant = await get_merchant_by_pubkey(merchant_public_key)
|
||||
assert merchant, f"Merchant not found for public key '{merchant_public_key}'"
|
||||
|
|
@ -270,7 +369,7 @@ async def _handle_incoming_dms(
|
|||
if not customer:
|
||||
await _handle_new_customer(event, merchant)
|
||||
else:
|
||||
await increment_customer_unread_messages(event.pubkey)
|
||||
await increment_customer_unread_messages(merchant.id, event.pubkey)
|
||||
|
||||
dm_reply = await _handle_dirrect_message(
|
||||
merchant.id,
|
||||
|
|
@ -282,6 +381,14 @@ async def _handle_incoming_dms(
|
|||
)
|
||||
if dm_reply:
|
||||
dm_event = merchant.build_dm_event(dm_reply, event.pubkey)
|
||||
dm = PartialDirectMessage(
|
||||
event_id=dm_event.id,
|
||||
event_created_at=dm_event.created_at,
|
||||
message=dm_reply,
|
||||
public_key=event.pubkey,
|
||||
type=DirectMessageType.PAYMENT_REQUEST.value,
|
||||
)
|
||||
await create_direct_message(merchant.id, dm)
|
||||
await nostr_client.publish_nostr_event(dm_event)
|
||||
|
||||
|
||||
|
|
@ -289,12 +396,14 @@ 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)
|
||||
if len(sent_to) != 0:
|
||||
dm = PartialDirectMessage(
|
||||
event_id=event.id,
|
||||
event_created_at=event.created_at,
|
||||
message=clear_text_msg, # exclude if json
|
||||
message=clear_text_msg,
|
||||
public_key=sent_to[0],
|
||||
type=type.value
|
||||
)
|
||||
await create_direct_message(merchant.id, dm)
|
||||
|
||||
|
|
@ -307,22 +416,24 @@ async def _handle_dirrect_message(
|
|||
event_created_at: int,
|
||||
msg: str,
|
||||
) -> Optional[str]:
|
||||
order, text_msg = order_from_json(msg)
|
||||
type, order = PartialDirectMessage.parse_message(msg)
|
||||
try:
|
||||
dm = PartialDirectMessage(
|
||||
event_id=event_id,
|
||||
event_created_at=event_created_at,
|
||||
message=text_msg,
|
||||
message=msg,
|
||||
public_key=from_pubkey,
|
||||
incoming=True,
|
||||
type=type.value,
|
||||
)
|
||||
await create_direct_message(merchant_id, dm)
|
||||
new_dm = await create_direct_message(merchant_id, dm)
|
||||
# todo: do the same for new order
|
||||
await websocketUpdater(
|
||||
merchant_id,
|
||||
json.dumps({"type": "new-direct-message", "customerPubkey": from_pubkey}),
|
||||
json.dumps({"type": "new-direct-message", "customerPubkey": from_pubkey, "data": new_dm.dict()}),
|
||||
)
|
||||
|
||||
if order:
|
||||
if type == DirectMessageType.CUSTOMER_ORDER:
|
||||
order["public_key"] = from_pubkey
|
||||
order["merchant_public_key"] = merchant_public_key
|
||||
order["event_id"] = event_id
|
||||
|
|
@ -338,17 +449,23 @@ async def _handle_dirrect_message(
|
|||
async def _handle_new_order(order: PartialOrder) -> Optional[str]:
|
||||
order.validate_order()
|
||||
|
||||
first_product_id = order.items[0].product_id
|
||||
wallet_id = await get_wallet_for_product(first_product_id)
|
||||
assert wallet_id, f"Cannot find wallet id for product id: {first_product_id}"
|
||||
try:
|
||||
first_product_id = order.items[0].product_id
|
||||
wallet_id = await get_wallet_for_product(first_product_id)
|
||||
assert wallet_id, f"Cannot find wallet id for product id: {first_product_id}"
|
||||
|
||||
wallet = await get_wallet(wallet_id)
|
||||
assert wallet, f"Cannot find wallet for product id: {first_product_id}"
|
||||
wallet = await get_wallet(wallet_id)
|
||||
assert wallet, f"Cannot find wallet for product id: {first_product_id}"
|
||||
|
||||
new_order = await create_new_order(order.merchant_public_key, order)
|
||||
if new_order:
|
||||
return json.dumps(new_order.dict(), separators=(",", ":"), ensure_ascii=False)
|
||||
|
||||
payment_req = await create_new_order(order.merchant_public_key, order)
|
||||
except Exception as e:
|
||||
payment_req = PaymentRequest(id=order.id, message=str(e), payment_options=[])
|
||||
|
||||
if payment_req:
|
||||
response = {"type": DirectMessageType.PAYMENT_REQUEST.value, **payment_req.dict()}
|
||||
return json.dumps(response, separators=(",", ":"), ensure_ascii=False)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
|
|
@ -372,3 +489,58 @@ async def _handle_customer_profile_update(event: NostrEvent):
|
|||
)
|
||||
except Exception as ex:
|
||||
logger.warning(ex)
|
||||
|
||||
|
||||
async def _handle_stall(event: NostrEvent):
|
||||
try:
|
||||
merchant = await get_merchant_by_pubkey(event.pubkey)
|
||||
assert merchant, f"Merchant not found for public key '{event.pubkey}'"
|
||||
stall_json = json.loads(event.content)
|
||||
|
||||
if "id" not in stall_json:
|
||||
return
|
||||
|
||||
stall = Stall(
|
||||
id=stall_json["id"],
|
||||
name=stall_json.get("name", "Recoverd Stall (no name)"),
|
||||
wallet="",
|
||||
currency=stall_json.get("currency", "sat"),
|
||||
shipping_zones=stall_json.get("shipping", []),
|
||||
pending=True,
|
||||
event_id=event.id,
|
||||
event_created_at=event.created_at,
|
||||
)
|
||||
stall.config.description = stall_json.get("description", "")
|
||||
await create_stall(merchant.id, stall)
|
||||
|
||||
except Exception as ex:
|
||||
logger.error(ex)
|
||||
|
||||
|
||||
async def _handle_product(event: NostrEvent):
|
||||
try:
|
||||
merchant = await get_merchant_by_pubkey(event.pubkey)
|
||||
assert merchant, f"Merchant not found for public key '{event.pubkey}'"
|
||||
product_json = json.loads(event.content)
|
||||
|
||||
assert "id" in product_json, "Product is missing ID"
|
||||
assert "stall_id" in product_json, "Product is missing Stall ID"
|
||||
|
||||
product = Product(
|
||||
id=product_json["id"],
|
||||
stall_id=product_json["stall_id"],
|
||||
name=product_json.get("name", "Recoverd Product (no name)"),
|
||||
images=product_json.get("images", []),
|
||||
categories=event.tag_values("t"),
|
||||
price=product_json.get("price", 0),
|
||||
quantity=product_json.get("quantity", 0),
|
||||
pending=True,
|
||||
event_id=event.id,
|
||||
event_created_at=event.created_at,
|
||||
)
|
||||
product.config.description = product_json.get("description", "")
|
||||
product.config.currency = product_json.get("currency", "sat")
|
||||
await create_product(merchant.id, product)
|
||||
|
||||
except Exception as ex:
|
||||
logger.error(ex)
|
||||
|
|
|
|||
|
|
@ -110,7 +110,8 @@
|
|||
</q-inner-loading>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn outline color="grey" @click="copyText(qrCodeDialog.data.payment_request)">Copy invoice</q-btn>
|
||||
<q-btn v-if="qrCodeDialog.data.payment_request" outline color="grey"
|
||||
@click="copyText(qrCodeDialog.data.payment_request)">Copy invoice</q-btn>
|
||||
<q-btn @click="closeQrCodeDialog" flat color="grey" class="q-ml-auto">Close</q-btn>
|
||||
</div>
|
||||
</q-card>
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ async function directMessages(path) {
|
|||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
},
|
||||
|
||||
sendDirectMesage: async function () {
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
|
|
@ -106,12 +107,13 @@ async function directMessages(path) {
|
|||
this.showAddPublicKey = false
|
||||
}
|
||||
},
|
||||
handleNewMessage: async function (data) {
|
||||
if (data.customerPubkey === this.activePublicKey) {
|
||||
await this.getDirectMessages(this.activePublicKey)
|
||||
} else {
|
||||
await this.getCustomers()
|
||||
handleNewMessage: async function (dm) {
|
||||
if (dm.customerPubkey === this.activePublicKey) {
|
||||
this.messages.push(dm.data)
|
||||
this.focusOnChatBox(this.messages.length - 1)
|
||||
// focus back on input box
|
||||
}
|
||||
this.getCustomersDebounced()
|
||||
},
|
||||
showClientOrders: function () {
|
||||
this.$emit('customer-selected', this.activePublicKey)
|
||||
|
|
@ -133,6 +135,7 @@ async function directMessages(path) {
|
|||
},
|
||||
created: async function () {
|
||||
await this.getCustomers()
|
||||
this.getCustomersDebounced = _.debounce(this.getCustomers, 2000, false)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,72 +1,47 @@
|
|||
<div>
|
||||
<div class="row q-mb-md">
|
||||
<div class="col-3 q-pr-lg">
|
||||
<q-select
|
||||
v-model="search.publicKey"
|
||||
:options="customers.map(c => ({label: buildCustomerLabel(c), value: c.public_key}))"
|
||||
label="Customer"
|
||||
emit-value
|
||||
class="text-wrap"
|
||||
>
|
||||
<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-3 q-pr-lg">
|
||||
<q-select
|
||||
v-model="search.isPaid"
|
||||
:options="ternaryOptions"
|
||||
label="Paid"
|
||||
emit-value
|
||||
>
|
||||
<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-3 q-pr-lg">
|
||||
<q-select
|
||||
v-model="search.isShipped"
|
||||
:options="ternaryOptions"
|
||||
label="Shipped"
|
||||
emit-value
|
||||
>
|
||||
<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-3">
|
||||
<q-btn
|
||||
unelevated
|
||||
outline
|
||||
icon="search"
|
||||
@click="getOrders()"
|
||||
class="float-right"
|
||||
>Search Orders</q-btn
|
||||
>
|
||||
<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...' : 'Search 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"
|
||||
>
|
||||
<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="accent"
|
||||
round
|
||||
dense
|
||||
@click="props.row.expanded= !props.row.expanded"
|
||||
:icon="props.row.expanded? 'remove' : 'add'"
|
||||
/>
|
||||
<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">
|
||||
{{toShortId(props.row.id)}}
|
||||
<q-badge v-if="props.row.isNew" color="orange">new</q-badge></q-td
|
||||
>
|
||||
<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>
|
||||
|
|
@ -77,33 +52,21 @@
|
|||
</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-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-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"
|
||||
>
|
||||
<span @click="customerSelected(props.row.public_key)" class="cursor-pointer">
|
||||
{{toShortId(props.row.public_key)}}
|
||||
</span>
|
||||
</q-td>
|
||||
<q-td key="time" :props="props">
|
||||
{{formatDate(props.row.time)}}
|
||||
<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">
|
||||
|
|
@ -124,10 +87,7 @@
|
|||
<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 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">
|
||||
|
|
@ -141,34 +101,18 @@
|
|||
</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 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>
|
||||
<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 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>
|
||||
<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>
|
||||
|
|
@ -176,14 +120,7 @@
|
|||
<div 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>
|
||||
<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>
|
||||
|
|
@ -191,63 +128,29 @@
|
|||
<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>
|
||||
<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.contact.phone"
|
||||
class="row items-center no-wrap q-mb-md"
|
||||
>
|
||||
<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>
|
||||
<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 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>
|
||||
<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">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>
|
||||
<q-input filled dense readonly disabled v-model.trim="props.row.invoice_id" type="text"></q-input>
|
||||
</div>
|
||||
<div class="col-3 col-sm-1"></div>
|
||||
</div>
|
||||
|
|
@ -260,28 +163,16 @@
|
|||
<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>
|
||||
<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 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
|
||||
>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -8,8 +8,8 @@ async function orderList(path) {
|
|||
watch: {
|
||||
customerPubkeyFilter: async function (n) {
|
||||
this.search.publicKey = n
|
||||
this.search.isPaid = {label: 'All', id: null}
|
||||
this.search.isShipped = {label: 'All', id: null}
|
||||
this.search.isPaid = { label: 'All', id: null }
|
||||
this.search.isShipped = { label: 'All', id: null }
|
||||
await this.getOrders()
|
||||
}
|
||||
},
|
||||
|
|
@ -22,7 +22,7 @@ async function orderList(path) {
|
|||
showShipDialog: false,
|
||||
filter: '',
|
||||
search: {
|
||||
publicKey: '',
|
||||
publicKey: null,
|
||||
isPaid: {
|
||||
label: 'All',
|
||||
id: null
|
||||
|
|
@ -30,7 +30,8 @@ async function orderList(path) {
|
|||
isShipped: {
|
||||
label: 'All',
|
||||
id: null
|
||||
}
|
||||
},
|
||||
restoring: false
|
||||
},
|
||||
customers: [],
|
||||
ternaryOptions: [
|
||||
|
|
@ -92,10 +93,10 @@ async function orderList(path) {
|
|||
field: 'pubkey'
|
||||
},
|
||||
{
|
||||
name: 'time',
|
||||
name: 'event_created_at',
|
||||
align: 'left',
|
||||
label: 'Date',
|
||||
field: 'time'
|
||||
label: 'Created At',
|
||||
field: 'event_created_at'
|
||||
}
|
||||
],
|
||||
pagination: {
|
||||
|
|
@ -104,6 +105,13 @@ async function orderList(path) {
|
|||
}
|
||||
}
|
||||
},
|
||||
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)
|
||||
|
|
@ -156,28 +164,48 @@ async function orderList(path) {
|
|||
if (this.search.isShipped.id) {
|
||||
query.push(`shipped=${this.search.isShipped.id}`)
|
||||
}
|
||||
const {data} = await LNbits.api.request(
|
||||
const { data } = await LNbits.api.request(
|
||||
'GET',
|
||||
`/nostrmarket/api/v1/${ordersPath}?${query.join('&')}`,
|
||||
this.inkey
|
||||
)
|
||||
this.orders = data.map(s => ({...s, expanded: false}))
|
||||
this.orders = data.map(s => ({ ...s, expanded: false }))
|
||||
console.log("### this.orders", this.orders)
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
},
|
||||
getOrder: async function (orderId) {
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
const { data } = await LNbits.api.request(
|
||||
'GET',
|
||||
`/nostrmarket/api/v1/order/${orderId}`,
|
||||
this.inkey
|
||||
)
|
||||
return {...data, expanded: false, isNew: true}
|
||||
return { ...data, expanded: false, isNew: true }
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
},
|
||||
restoreOrders: async function () {
|
||||
try {
|
||||
this.search.restoring = true
|
||||
await LNbits.api.request(
|
||||
'PUT',
|
||||
`/nostrmarket/api/v1/order/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
|
||||
}
|
||||
},
|
||||
updateOrderShipped: async function () {
|
||||
this.selectedOrder.shipped = !this.selectedOrder.shipped
|
||||
try {
|
||||
|
|
@ -213,8 +241,8 @@ async function orderList(path) {
|
|||
showShipOrderDialog: function (order) {
|
||||
this.selectedOrder = order
|
||||
this.shippingMessage = order.shipped
|
||||
? `The order has been shipped! Order ID: '${order.id}' `
|
||||
: `The order has NOT yet been shipped! Order ID: '${order.id}'`
|
||||
? 'The order has been shipped!'
|
||||
: 'The order has NOT yet been shipped!'
|
||||
|
||||
// do not change the status yet
|
||||
this.selectedOrder.shipped = !order.shipped
|
||||
|
|
@ -225,7 +253,7 @@ async function orderList(path) {
|
|||
},
|
||||
getCustomers: async function () {
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
const { data } = await LNbits.api.request(
|
||||
'GET',
|
||||
'/nostrmarket/api/v1/customer',
|
||||
this.inkey
|
||||
|
|
@ -244,6 +272,12 @@ async function orderList(path) {
|
|||
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 () {
|
||||
|
|
|
|||
|
|
@ -10,54 +10,29 @@
|
|||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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 filled dense emit-value v-model="stall.wallet" :options="walletOptions" label="Wallet *">
|
||||
</q-select>
|
||||
</div>
|
||||
<div class="col-3 col-sm-1"></div>
|
||||
|
|
@ -65,51 +40,25 @@
|
|||
<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>
|
||||
<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>
|
||||
<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
|
||||
>
|
||||
<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
|
||||
>
|
||||
<q-btn unelevated color="pink" icon="cancel" class="float-right" @click="deleteStall()">Delete Stall</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</q-tab-panel>
|
||||
|
|
@ -117,14 +66,23 @@
|
|||
<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
|
||||
>
|
||||
|
||||
<q-btn-dropdown @click="showNewProductDialog()" unelevated split color="green" class="float-left"
|
||||
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>
|
||||
|
|
@ -132,34 +90,15 @@
|
|||
|
||||
<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"
|
||||
>
|
||||
<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-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-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>
|
||||
|
|
@ -186,112 +125,74 @@
|
|||
</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>
|
||||
<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" 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.name" label="Name"></q-input>
|
||||
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="productDialog.data.config.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-input filled dense v-model.trim="productDialog.data.config.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-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-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"
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
<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-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
|
||||
<q-btn v-else unelevated color="primary" :disable="!productDialog.data.price
|
||||
|| !productDialog.data.name
|
||||
|| !productDialog.data.quantity"
|
||||
type="submit"
|
||||
>Create Product</q-btn
|
||||
>
|
||||
|| !productDialog.data.quantity" type="submit">Create Product</q-btn>
|
||||
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||
>Cancel</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>
|
||||
<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 v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</div>
|
||||
|
|
@ -20,8 +20,10 @@ async function stallDetails(path) {
|
|||
tab: 'products',
|
||||
stall: null,
|
||||
products: [],
|
||||
pendingProducts: [],
|
||||
productDialog: {
|
||||
showDialog: false,
|
||||
showRestore: false,
|
||||
url: true,
|
||||
data: {
|
||||
id: null,
|
||||
|
|
@ -106,15 +108,15 @@ async function stallDetails(path) {
|
|||
mapStall: function (stall) {
|
||||
stall.shipping_zones.forEach(
|
||||
z =>
|
||||
(z.label = z.name
|
||||
? `${z.name} (${z.countries.join(', ')})`
|
||||
: z.countries.join(', '))
|
||||
(z.label = z.name
|
||||
? `${z.name} (${z.countries.join(', ')})`
|
||||
: z.countries.join(', '))
|
||||
)
|
||||
return stall
|
||||
},
|
||||
getStall: async function () {
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
const { data } = await LNbits.api.request(
|
||||
'GET',
|
||||
'/nostrmarket/api/v1/stall/' + this.stallId,
|
||||
this.inkey
|
||||
|
|
@ -126,7 +128,7 @@ async function stallDetails(path) {
|
|||
},
|
||||
updateStall: async function () {
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
const { data } = await LNbits.api.request(
|
||||
'PUT',
|
||||
'/nostrmarket/api/v1/stall/' + this.stallId,
|
||||
this.adminkey,
|
||||
|
|
@ -189,14 +191,14 @@ async function stallDetails(path) {
|
|||
this.productDialog.data.images.splice(index, 1)
|
||||
}
|
||||
},
|
||||
getProducts: async function () {
|
||||
getProducts: async function (pending = false) {
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
const { data } = await LNbits.api.request(
|
||||
'GET',
|
||||
'/nostrmarket/api/v1/stall/product/' + this.stall.id,
|
||||
`/nostrmarket/api/v1/stall/product/${this.stall.id}?pending=${pending}`,
|
||||
this.inkey
|
||||
)
|
||||
this.products = data
|
||||
return data
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
|
|
@ -215,6 +217,7 @@ async function stallDetails(path) {
|
|||
}
|
||||
this.productDialog.showDialog = false
|
||||
if (this.productDialog.data.id) {
|
||||
data.pending = false
|
||||
this.updateProduct(data)
|
||||
} else {
|
||||
this.createProduct(data)
|
||||
|
|
@ -222,7 +225,7 @@ async function stallDetails(path) {
|
|||
},
|
||||
updateProduct: async function (product) {
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
const { data } = await LNbits.api.request(
|
||||
'PATCH',
|
||||
'/nostrmarket/api/v1/product/' + product.id,
|
||||
this.adminkey,
|
||||
|
|
@ -231,6 +234,8 @@ async function stallDetails(path) {
|
|||
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',
|
||||
|
|
@ -244,7 +249,7 @@ async function stallDetails(path) {
|
|||
},
|
||||
createProduct: async function (payload) {
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
const { data } = await LNbits.api.request(
|
||||
'POST',
|
||||
'/nostrmarket/api/v1/product',
|
||||
this.adminkey,
|
||||
|
|
@ -262,7 +267,7 @@ async function stallDetails(path) {
|
|||
}
|
||||
},
|
||||
editProduct: async function (product) {
|
||||
this.productDialog.data = {...product}
|
||||
this.productDialog.data = { ...product }
|
||||
this.productDialog.showDialog = true
|
||||
},
|
||||
deleteProduct: async function (productId) {
|
||||
|
|
@ -289,8 +294,8 @@ async function stallDetails(path) {
|
|||
}
|
||||
})
|
||||
},
|
||||
showNewProductDialog: async function () {
|
||||
this.productDialog.data = {
|
||||
showNewProductDialog: async function (data) {
|
||||
this.productDialog.data = data || {
|
||||
id: null,
|
||||
name: '',
|
||||
description: '',
|
||||
|
|
@ -305,13 +310,21 @@ async function stallDetails(path) {
|
|||
}
|
||||
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)
|
||||
},
|
||||
customerSelectedForOrder: function (customerPubkey) {
|
||||
this.$emit('customer-selected-for-order', customerPubkey)
|
||||
}
|
||||
},
|
||||
created: async function () {
|
||||
await this.getStall()
|
||||
await this.getProducts()
|
||||
this.products = await this.getProducts()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,23 @@
|
|||
<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 (Store)</q-btn
|
||||
>
|
||||
<q-input
|
||||
borderless
|
||||
dense
|
||||
debounce="300"
|
||||
v-model="filter"
|
||||
placeholder="Search"
|
||||
class="float-right"
|
||||
>
|
||||
|
||||
<q-btn-dropdown @click="openCreateStallDialog()" unelevated split color="green" class="float-left"
|
||||
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>
|
||||
|
|
@ -23,26 +25,13 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<q-table
|
||||
flat
|
||||
dense
|
||||
:data="stalls"
|
||||
row-key="id"
|
||||
:columns="stallsTable.columns"
|
||||
:pagination.sync="stallsTable.pagination"
|
||||
:filter="filter"
|
||||
>
|
||||
<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-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"> {{props.row.name}} </q-td>
|
||||
|
|
@ -61,17 +50,10 @@
|
|||
<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>
|
||||
<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>
|
||||
|
|
@ -83,64 +65,54 @@
|
|||
<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-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>
|
||||
<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
|
||||
<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
|
||||
>
|
||||
|| !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>
|
||||
</div>
|
||||
|
|
@ -9,9 +9,11 @@ async function stallList(path) {
|
|||
return {
|
||||
filter: '',
|
||||
stalls: [],
|
||||
pendingStalls: [],
|
||||
currencies: [],
|
||||
stallDialog: {
|
||||
show: false,
|
||||
showRestore: false,
|
||||
data: {
|
||||
name: '',
|
||||
description: '',
|
||||
|
|
@ -69,7 +71,7 @@ async function stallList(path) {
|
|||
},
|
||||
methods: {
|
||||
sendStallFormData: async function () {
|
||||
await this.createStall({
|
||||
const stallData = {
|
||||
name: this.stallDialog.data.name,
|
||||
wallet: this.stallDialog.data.wallet,
|
||||
currency: this.stallDialog.data.currency,
|
||||
|
|
@ -77,11 +79,18 @@ async function stallList(path) {
|
|||
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(
|
||||
const { data } = await LNbits.api.request(
|
||||
'POST',
|
||||
'/nostrmarket/api/v1/stall',
|
||||
this.adminkey,
|
||||
|
|
@ -98,39 +107,86 @@ async function stallList(path) {
|
|||
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(
|
||||
const { data } = await LNbits.api.request(
|
||||
'GET',
|
||||
'/nostrmarket/api/v1/currencies',
|
||||
this.inkey
|
||||
)
|
||||
|
||||
this.currencies = ['sat', ...data]
|
||||
return ['sat', ...data]
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
return []
|
||||
},
|
||||
getStalls: async function () {
|
||||
getStalls: async function (pending = false) {
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
const { data } = await LNbits.api.request(
|
||||
'GET',
|
||||
'/nostrmarket/api/v1/stall',
|
||||
`/nostrmarket/api/v1/stall?pending=${pending}`,
|
||||
this.inkey
|
||||
)
|
||||
this.stalls = data.map(s => ({...s, expanded: false}))
|
||||
return data.map(s => ({ ...s, expanded: false }))
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
return []
|
||||
},
|
||||
getZones: async function () {
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
const { data } = await LNbits.api.request(
|
||||
'GET',
|
||||
'/nostrmarket/api/v1/zone',
|
||||
this.inkey
|
||||
)
|
||||
this.zoneOptions = data.map(z => ({
|
||||
return data.map(z => ({
|
||||
...z,
|
||||
label: z.name
|
||||
? `${z.name} (${z.countries.join(', ')})`
|
||||
|
|
@ -139,6 +195,7 @@ async function stallList(path) {
|
|||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
return []
|
||||
},
|
||||
handleStallDeleted: function (stallId) {
|
||||
this.stalls = _.reject(this.stalls, function (obj) {
|
||||
|
|
@ -152,9 +209,9 @@ async function stallList(path) {
|
|||
this.stalls.splice(index, 1, stall)
|
||||
}
|
||||
},
|
||||
openCreateStallDialog: async function () {
|
||||
await this.getCurrencies()
|
||||
await this.getZones()
|
||||
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',
|
||||
|
|
@ -162,7 +219,7 @@ async function stallList(path) {
|
|||
})
|
||||
return
|
||||
}
|
||||
this.stallDialog.data = {
|
||||
this.stallDialog.data = stallData || {
|
||||
name: '',
|
||||
description: '',
|
||||
wallet: null,
|
||||
|
|
@ -171,14 +228,35 @@ async function stallList(path) {
|
|||
}
|
||||
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)
|
||||
}
|
||||
},
|
||||
created: async function () {
|
||||
await this.getStalls()
|
||||
await this.getCurrencies()
|
||||
await this.getZones()
|
||||
this.stalls = await this.getStalls()
|
||||
this.currencies = await this.getCurrencies()
|
||||
this.zoneOptions = await this.getZones()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,8 @@ const merchant = async () => {
|
|||
data: {
|
||||
privateKey: null
|
||||
}
|
||||
}
|
||||
},
|
||||
wsConnection: null
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
|
@ -114,8 +115,9 @@ const merchant = async () => {
|
|||
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}`
|
||||
const wsConnection = new WebSocket(wsUrl)
|
||||
wsConnection.onmessage = async e => {
|
||||
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 === 'new-order') {
|
||||
this.$q.notify({
|
||||
|
|
@ -124,10 +126,20 @@ const merchant = async () => {
|
|||
message: 'New Order'
|
||||
})
|
||||
await this.$refs.orderListRef.addOrder(data)
|
||||
} else if (data.type === 'order-paid') {
|
||||
this.$q.notify({
|
||||
timeout: 5000,
|
||||
type: 'positive',
|
||||
message: 'Order Paid'
|
||||
})
|
||||
await this.$refs.orderListRef.addOrder(data)
|
||||
} else if (data.type === 'new-direct-message') {
|
||||
await this.$refs.directMessagesRef.handleNewMessage(data)
|
||||
}
|
||||
// order paid
|
||||
// order shipped
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
this.$q.notify({
|
||||
timeout: 5000,
|
||||
|
|
@ -157,7 +169,11 @@ const merchant = async () => {
|
|||
},
|
||||
created: async function () {
|
||||
await this.getMerchant()
|
||||
await this.waitForNotifications()
|
||||
setInterval(async () => {
|
||||
if (!this.wsConnection || this.wsConnection.readyState !== WebSocket.OPEN) {
|
||||
await this.waitForNotifications()
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
26
tasks.py
26
tasks.py
|
|
@ -4,10 +4,12 @@ from lnbits.core.models import Payment
|
|||
from lnbits.tasks import register_invoice_listener
|
||||
|
||||
from .crud import (
|
||||
get_all_customers,
|
||||
get_all_unique_customers,
|
||||
get_last_direct_messages_time,
|
||||
get_last_order_time,
|
||||
get_public_keys_for_merchants,
|
||||
get_last_product_update_time,
|
||||
get_last_stall_update_time,
|
||||
get_merchants_ids_with_pubkeys,
|
||||
)
|
||||
from .nostr.nostr_client import NostrClient
|
||||
from .services import handle_order_paid, process_nostr_message
|
||||
|
|
@ -35,15 +37,23 @@ async def on_invoice_paid(payment: Payment) -> None:
|
|||
|
||||
|
||||
async def wait_for_nostr_events(nostr_client: NostrClient):
|
||||
public_keys = await get_public_keys_for_merchants()
|
||||
for p in public_keys:
|
||||
last_order_time = await get_last_order_time(p)
|
||||
last_dm_time = await get_last_direct_messages_time(p)
|
||||
merchant_ids = await get_merchants_ids_with_pubkeys()
|
||||
for id, pk in merchant_ids:
|
||||
last_order_time = await get_last_order_time(id)
|
||||
last_dm_time = await get_last_direct_messages_time(id)
|
||||
since = max(last_order_time, last_dm_time)
|
||||
|
||||
await nostr_client.subscribe_to_direct_messages(p, since)
|
||||
await nostr_client.subscribe_to_direct_messages(pk, since)
|
||||
|
||||
customers = await get_all_customers()
|
||||
for id, pk in merchant_ids:
|
||||
last_stall_update = await get_last_stall_update_time(id)
|
||||
await nostr_client.subscribe_to_stall_events(pk, last_stall_update)
|
||||
|
||||
for id, pk in merchant_ids:
|
||||
last_product_update = await get_last_product_update_time(id)
|
||||
await nostr_client.subscribe_to_product_events(pk, last_product_update)
|
||||
|
||||
customers = await get_all_unique_customers()
|
||||
for c in customers:
|
||||
await nostr_client.subscribe_to_user_profile(c.public_key, c.event_created_at)
|
||||
|
||||
|
|
|
|||
114
views_api.py
114
views_api.py
|
|
@ -1,6 +1,6 @@
|
|||
import json
|
||||
from http import HTTPStatus
|
||||
from typing import List, Optional, Union
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import Depends
|
||||
from fastapi.exceptions import HTTPException
|
||||
|
|
@ -37,9 +37,11 @@ from .crud import (
|
|||
get_direct_messages,
|
||||
get_merchant_by_pubkey,
|
||||
get_merchant_for_user,
|
||||
get_merchants_ids_with_pubkeys,
|
||||
get_order,
|
||||
get_orders,
|
||||
get_orders_for_stall,
|
||||
get_orders_from_direct_messages,
|
||||
get_product,
|
||||
get_products,
|
||||
get_stall,
|
||||
|
|
@ -57,6 +59,7 @@ from .helpers import normalize_public_key
|
|||
from .models import (
|
||||
Customer,
|
||||
DirectMessage,
|
||||
DirectMessageType,
|
||||
Merchant,
|
||||
Order,
|
||||
OrderStatusUpdate,
|
||||
|
|
@ -69,7 +72,11 @@ from .models import (
|
|||
Stall,
|
||||
Zone,
|
||||
)
|
||||
from .services import sign_and_send_to_nostr, update_merchant_to_nostr
|
||||
from .services import (
|
||||
create_or_update_order_from_dm,
|
||||
sign_and_send_to_nostr,
|
||||
update_merchant_to_nostr,
|
||||
)
|
||||
|
||||
######################################## MERCHANT ########################################
|
||||
|
||||
|
|
@ -88,6 +95,20 @@ async def api_create_merchant(
|
|||
assert merchant == None, "A merchant already exists for this user"
|
||||
|
||||
merchant = await create_merchant(wallet.wallet.user, data)
|
||||
|
||||
await create_zone(
|
||||
merchant.id,
|
||||
PartialZone(
|
||||
id="online",
|
||||
name="Online",
|
||||
currency="sat",
|
||||
cost=0,
|
||||
countries=["Free (digital)"],
|
||||
),
|
||||
)
|
||||
|
||||
await nostr_client.subscribe_to_stall_events(data.public_key, 0)
|
||||
await nostr_client.subscribe_to_product_events(data.public_key, 0)
|
||||
await nostr_client.subscribe_to_direct_messages(data.public_key, 0)
|
||||
|
||||
return merchant
|
||||
|
|
@ -138,6 +159,7 @@ async def api_delete_merchant(
|
|||
await delete_merchant_zones(merchant.id)
|
||||
|
||||
await nostr_client.unsubscribe_from_direct_messages(merchant.public_key)
|
||||
await nostr_client.unsubscribe_from_merchant_events(merchant.public_key)
|
||||
await delete_merchant(merchant.id)
|
||||
except AssertionError as ex:
|
||||
raise HTTPException(
|
||||
|
|
@ -317,6 +339,7 @@ async def api_create_stall(
|
|||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
) -> Stall:
|
||||
try:
|
||||
# shipping_zones = await
|
||||
data.validate_stall()
|
||||
|
||||
merchant = await get_merchant_for_user(wallet.wallet.user)
|
||||
|
|
@ -325,7 +348,7 @@ async def api_create_stall(
|
|||
|
||||
event = await sign_and_send_to_nostr(merchant, stall)
|
||||
|
||||
stall.config.event_id = event.id
|
||||
stall.event_id = event.id
|
||||
await update_stall(merchant.id, stall)
|
||||
|
||||
return stall
|
||||
|
|
@ -359,7 +382,7 @@ async def api_update_stall(
|
|||
|
||||
event = await sign_and_send_to_nostr(merchant, stall)
|
||||
|
||||
stall.config.event_id = event.id
|
||||
stall.event_id = event.id
|
||||
await update_stall(merchant.id, stall)
|
||||
|
||||
return stall
|
||||
|
|
@ -406,11 +429,13 @@ async def api_get_stall(stall_id: str, wallet: WalletTypeInfo = Depends(get_key_
|
|||
|
||||
|
||||
@nostrmarket_ext.get("/api/v1/stall")
|
||||
async def api_get_stalls(wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||
async def api_get_stalls(
|
||||
pending: Optional[bool] = False, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
):
|
||||
try:
|
||||
merchant = await get_merchant_for_user(wallet.wallet.user)
|
||||
assert merchant, "Merchant cannot be found"
|
||||
stalls = await get_stalls(merchant.id)
|
||||
stalls = await get_stalls(merchant.id, pending)
|
||||
return stalls
|
||||
except AssertionError as ex:
|
||||
raise HTTPException(
|
||||
|
|
@ -428,12 +453,13 @@ async def api_get_stalls(wallet: WalletTypeInfo = Depends(get_key_type)):
|
|||
@nostrmarket_ext.get("/api/v1/stall/product/{stall_id}")
|
||||
async def api_get_stall_products(
|
||||
stall_id: str,
|
||||
pending: Optional[bool] = False,
|
||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
||||
):
|
||||
try:
|
||||
merchant = await get_merchant_for_user(wallet.wallet.user)
|
||||
assert merchant, "Merchant cannot be found"
|
||||
products = await get_products(merchant.id, stall_id)
|
||||
products = await get_products(merchant.id, stall_id, pending)
|
||||
return products
|
||||
except AssertionError as ex:
|
||||
raise HTTPException(
|
||||
|
|
@ -494,7 +520,7 @@ async def api_delete_stall(
|
|||
|
||||
event = await sign_and_send_to_nostr(merchant, stall, True)
|
||||
|
||||
stall.config.event_id = event.id
|
||||
stall.event_id = event.id
|
||||
await update_stall(merchant.id, stall)
|
||||
|
||||
except AssertionError as ex:
|
||||
|
|
@ -532,7 +558,7 @@ async def api_create_product(
|
|||
|
||||
event = await sign_and_send_to_nostr(merchant, product)
|
||||
|
||||
product.config.event_id = event.id
|
||||
product.event_id = event.id
|
||||
await update_product(merchant.id, product)
|
||||
|
||||
return product
|
||||
|
|
@ -568,7 +594,7 @@ async def api_update_product(
|
|||
|
||||
product = await update_product(merchant.id, product)
|
||||
event = await sign_and_send_to_nostr(merchant, product)
|
||||
product.config.event_id = event.id
|
||||
product.event_id = event.id
|
||||
await update_product(merchant.id, product)
|
||||
|
||||
return product
|
||||
|
|
@ -626,9 +652,7 @@ async def api_delete_product(
|
|||
)
|
||||
|
||||
await delete_product(merchant.id, product_id)
|
||||
event = await sign_and_send_to_nostr(merchant, product, True)
|
||||
product.config.event_id = event.id
|
||||
await update_product(merchant.id, product)
|
||||
await sign_and_send_to_nostr(merchant, product, True)
|
||||
|
||||
except AssertionError as ex:
|
||||
raise HTTPException(
|
||||
|
|
@ -720,9 +744,23 @@ async def api_update_order_status(
|
|||
assert order, "Cannot find updated order"
|
||||
|
||||
data.paid = order.paid
|
||||
dm_content = json.dumps(data.dict(), separators=(",", ":"), ensure_ascii=False)
|
||||
dm_content = json.dumps(
|
||||
{"type": DirectMessageType.ORDER_PAID_OR_SHIPPED.value, **data.dict()},
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
dm_event = merchant.build_dm_event(dm_content, order.public_key)
|
||||
|
||||
dm = PartialDirectMessage(
|
||||
event_id=dm_event.id,
|
||||
event_created_at=dm_event.created_at,
|
||||
message=dm_content,
|
||||
public_key=order.public_key,
|
||||
type=DirectMessageType.ORDER_PAID_OR_SHIPPED.value,
|
||||
)
|
||||
await create_direct_message(merchant.id, dm)
|
||||
|
||||
await nostr_client.publish_nostr_event(dm_event)
|
||||
|
||||
return order
|
||||
|
|
@ -740,6 +778,38 @@ async def api_update_order_status(
|
|||
)
|
||||
|
||||
|
||||
@nostrmarket_ext.put("/api/v1/order/restore")
|
||||
async def api_restore_orders(
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
) -> Order:
|
||||
try:
|
||||
merchant = await get_merchant_for_user(wallet.wallet.user)
|
||||
assert merchant, "Merchant cannot be found"
|
||||
|
||||
dms = await get_orders_from_direct_messages(merchant.id)
|
||||
for dm in dms:
|
||||
try:
|
||||
await create_or_update_order_from_dm(
|
||||
merchant.id, merchant.public_key, dm
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
f"Failed to restore order friom event '{dm.event_id}': '{str(e)}'."
|
||||
)
|
||||
|
||||
except AssertionError 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 restore orders",
|
||||
)
|
||||
|
||||
|
||||
######################################## DIRECT MESSAGES ########################################
|
||||
|
||||
|
||||
|
|
@ -752,7 +822,7 @@ async def api_get_messages(
|
|||
assert merchant, f"Merchant cannot be found"
|
||||
|
||||
messages = await get_direct_messages(merchant.id, public_key)
|
||||
await update_customer_no_unread_messages(public_key)
|
||||
await update_customer_no_unread_messages(merchant.id, public_key)
|
||||
return messages
|
||||
except AssertionError as ex:
|
||||
raise HTTPException(
|
||||
|
|
@ -822,7 +892,7 @@ async def api_get_customers(
|
|||
|
||||
|
||||
@nostrmarket_ext.post("/api/v1/customer")
|
||||
async def api_createcustomer(
|
||||
async def api_create_customer(
|
||||
data: Customer,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
) -> Customer:
|
||||
|
|
@ -867,7 +937,9 @@ async def api_list_currencies_available():
|
|||
@nostrmarket_ext.put("/api/v1/restart")
|
||||
async def restart_nostr_client(wallet: WalletTypeInfo = Depends(require_admin_key)):
|
||||
try:
|
||||
await nostr_client.restart()
|
||||
ids = await get_merchants_ids_with_pubkeys()
|
||||
merchant_public_keys = [id[0] for id in ids]
|
||||
await nostr_client.restart(merchant_public_keys)
|
||||
except Exception as ex:
|
||||
logger.warning(ex)
|
||||
|
||||
|
|
@ -880,5 +952,11 @@ async def api_stop(wallet: WalletTypeInfo = Depends(check_admin)):
|
|||
except Exception as ex:
|
||||
logger.warning(ex)
|
||||
|
||||
nostr_client.stop()
|
||||
try:
|
||||
ids = await get_merchants_ids_with_pubkeys()
|
||||
merchant_public_keys = [id[0] for id in ids]
|
||||
await nostr_client.stop(merchant_public_keys)
|
||||
except Exception as ex:
|
||||
logger.warning(ex)
|
||||
|
||||
return {"success": True}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue