Merge pull request #908 from lnbits/diagon-alley
WIP: Diagon alley Extension
This commit is contained in:
commit
1c6fc8a178
9
lnbits/extensions/market/README.md
Normal file
9
lnbits/extensions/market/README.md
Normal file
|
@ -0,0 +1,9 @@
|
|||
<h1>Market</h1>
|
||||
<h2>A movable market stand</h2>
|
||||
Make a list of products to sell, point the list to an relay (or many), stack sats.
|
||||
Market is a movable market stand, for anon transactions. You then give permission for an relay to list those products. Delivery addresses are sent through the Lightning Network.
|
||||
<img src="https://i.imgur.com/P1tvBSG.png">
|
||||
|
||||
<h2>API endpoints</h2>
|
||||
|
||||
<code>curl -X GET http://YOUR-TOR-ADDRESS</code>
|
43
lnbits/extensions/market/__init__.py
Normal file
43
lnbits/extensions/market/__init__.py
Normal file
|
@ -0,0 +1,43 @@
|
|||
import asyncio
|
||||
|
||||
from fastapi import APIRouter
|
||||
from starlette.staticfiles import StaticFiles
|
||||
|
||||
from lnbits.db import Database
|
||||
from lnbits.helpers import template_renderer
|
||||
from lnbits.tasks import catch_everything_and_restart
|
||||
|
||||
db = Database("ext_market")
|
||||
|
||||
market_ext: APIRouter = APIRouter(prefix="/market", tags=["market"])
|
||||
|
||||
market_static_files = [
|
||||
{
|
||||
"path": "/market/static",
|
||||
"app": StaticFiles(directory="lnbits/extensions/market/static"),
|
||||
"name": "market_static",
|
||||
}
|
||||
]
|
||||
|
||||
# if 'nostradmin' not in LNBITS_ADMIN_EXTENSIONS:
|
||||
# @market_ext.get("/", response_class=HTMLResponse)
|
||||
# async def index(request: Request):
|
||||
# return template_renderer().TemplateResponse(
|
||||
# "error.html", {"request": request, "err": "Ask system admin to enable NostrAdmin!"}
|
||||
# )
|
||||
# else:
|
||||
|
||||
|
||||
def market_renderer():
|
||||
return template_renderer(["lnbits/extensions/market/templates"])
|
||||
# return template_renderer(["lnbits/extensions/market/templates"])
|
||||
|
||||
|
||||
from .tasks import wait_for_paid_invoices
|
||||
from .views import * # noqa
|
||||
from .views_api import * # noqa
|
||||
|
||||
|
||||
def market_start():
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))
|
6
lnbits/extensions/market/config.json
Normal file
6
lnbits/extensions/market/config.json
Normal file
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "Marketplace",
|
||||
"short_description": "Webshop/market on LNbits",
|
||||
"tile": "/market/static/images/bitcoin-shop.png",
|
||||
"contributors": ["benarc", "talvasconcelos"]
|
||||
}
|
492
lnbits/extensions/market/crud.py
Normal file
492
lnbits/extensions/market/crud.py
Normal file
|
@ -0,0 +1,492 @@
|
|||
from base64 import urlsafe_b64encode
|
||||
from typing import List, Optional, Union
|
||||
from uuid import uuid4
|
||||
|
||||
# from lnbits.db import open_ext_db
|
||||
from lnbits.db import SQLITE
|
||||
from lnbits.helpers import urlsafe_short_hash
|
||||
from lnbits.settings import WALLET
|
||||
|
||||
from . import db
|
||||
from .models import (
|
||||
ChatMessage,
|
||||
CreateChatMessage,
|
||||
CreateMarket,
|
||||
CreateMarketStalls,
|
||||
Market,
|
||||
MarketSettings,
|
||||
OrderDetail,
|
||||
Orders,
|
||||
Products,
|
||||
Stalls,
|
||||
Zones,
|
||||
createOrder,
|
||||
createOrderDetails,
|
||||
createProduct,
|
||||
createStalls,
|
||||
createZones,
|
||||
)
|
||||
|
||||
###Products
|
||||
|
||||
|
||||
async def create_market_product(data: createProduct) -> Products:
|
||||
product_id = urlsafe_short_hash()
|
||||
await db.execute(
|
||||
f"""
|
||||
INSERT INTO market.products (id, stall, product, categories, description, image, price, quantity)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
product_id,
|
||||
data.stall,
|
||||
data.product,
|
||||
data.categories,
|
||||
data.description,
|
||||
data.image,
|
||||
data.price,
|
||||
data.quantity,
|
||||
),
|
||||
)
|
||||
product = await get_market_product(product_id)
|
||||
assert product, "Newly created product couldn't be retrieved"
|
||||
return product
|
||||
|
||||
|
||||
async def update_market_product(product_id: str, **kwargs) -> Optional[Products]:
|
||||
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
|
||||
|
||||
await db.execute(
|
||||
f"UPDATE market.products SET {q} WHERE id = ?",
|
||||
(*kwargs.values(), product_id),
|
||||
)
|
||||
row = await db.fetchone("SELECT * FROM market.products WHERE id = ?", (product_id,))
|
||||
|
||||
return Products(**row) if row else None
|
||||
|
||||
|
||||
async def get_market_product(product_id: str) -> Optional[Products]:
|
||||
row = await db.fetchone("SELECT * FROM market.products WHERE id = ?", (product_id,))
|
||||
return Products(**row) if row else None
|
||||
|
||||
|
||||
async def get_market_products(stall_ids: Union[str, List[str]]) -> List[Products]:
|
||||
if isinstance(stall_ids, str):
|
||||
stall_ids = [stall_ids]
|
||||
|
||||
# with open_ext_db("market") as db:
|
||||
q = ",".join(["?"] * len(stall_ids))
|
||||
rows = await db.fetchall(
|
||||
f"""
|
||||
SELECT * FROM market.products WHERE stall IN ({q})
|
||||
""",
|
||||
(*stall_ids,),
|
||||
)
|
||||
return [Products(**row) for row in rows]
|
||||
|
||||
|
||||
async def delete_market_product(product_id: str) -> None:
|
||||
await db.execute("DELETE FROM market.products WHERE id = ?", (product_id,))
|
||||
|
||||
|
||||
###zones
|
||||
|
||||
|
||||
async def create_market_zone(user, data: createZones) -> Zones:
|
||||
zone_id = urlsafe_short_hash()
|
||||
await db.execute(
|
||||
f"""
|
||||
INSERT INTO market.zones (
|
||||
id,
|
||||
"user",
|
||||
cost,
|
||||
countries
|
||||
|
||||
)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
(zone_id, user, data.cost, data.countries.lower()),
|
||||
)
|
||||
|
||||
zone = await get_market_zone(zone_id)
|
||||
assert zone, "Newly created zone couldn't be retrieved"
|
||||
return zone
|
||||
|
||||
|
||||
async def update_market_zone(zone_id: str, **kwargs) -> Optional[Zones]:
|
||||
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
|
||||
await db.execute(
|
||||
f"UPDATE market.zones SET {q} WHERE id = ?",
|
||||
(*kwargs.values(), zone_id),
|
||||
)
|
||||
row = await db.fetchone("SELECT * FROM market.zones WHERE id = ?", (zone_id,))
|
||||
return Zones(**row) if row else None
|
||||
|
||||
|
||||
async def get_market_zone(zone_id: str) -> Optional[Zones]:
|
||||
row = await db.fetchone("SELECT * FROM market.zones WHERE id = ?", (zone_id,))
|
||||
return Zones(**row) if row else None
|
||||
|
||||
|
||||
async def get_market_zones(user: str) -> List[Zones]:
|
||||
rows = await db.fetchall('SELECT * FROM market.zones WHERE "user" = ?', (user,))
|
||||
return [Zones(**row) for row in rows]
|
||||
|
||||
|
||||
async def delete_market_zone(zone_id: str) -> None:
|
||||
await db.execute("DELETE FROM market.zones WHERE id = ?", (zone_id,))
|
||||
|
||||
|
||||
###Stalls
|
||||
|
||||
|
||||
async def create_market_stall(data: createStalls) -> Stalls:
|
||||
stall_id = urlsafe_short_hash()
|
||||
await db.execute(
|
||||
f"""
|
||||
INSERT INTO market.stalls (
|
||||
id,
|
||||
wallet,
|
||||
name,
|
||||
currency,
|
||||
publickey,
|
||||
relays,
|
||||
shippingzones
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
stall_id,
|
||||
data.wallet,
|
||||
data.name,
|
||||
data.currency,
|
||||
data.publickey,
|
||||
data.relays,
|
||||
data.shippingzones,
|
||||
),
|
||||
)
|
||||
|
||||
stall = await get_market_stall(stall_id)
|
||||
assert stall, "Newly created stall couldn't be retrieved"
|
||||
return stall
|
||||
|
||||
|
||||
async def update_market_stall(stall_id: str, **kwargs) -> Optional[Stalls]:
|
||||
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
|
||||
await db.execute(
|
||||
f"UPDATE market.stalls SET {q} WHERE id = ?",
|
||||
(*kwargs.values(), stall_id),
|
||||
)
|
||||
row = await db.fetchone("SELECT * FROM market.stalls WHERE id = ?", (stall_id,))
|
||||
return Stalls(**row) if row else None
|
||||
|
||||
|
||||
async def get_market_stall(stall_id: str) -> Optional[Stalls]:
|
||||
row = await db.fetchone("SELECT * FROM market.stalls WHERE id = ?", (stall_id,))
|
||||
return Stalls(**row) if row else None
|
||||
|
||||
|
||||
async def get_market_stalls(wallet_ids: Union[str, List[str]]) -> List[Stalls]:
|
||||
q = ",".join(["?"] * len(wallet_ids))
|
||||
rows = await db.fetchall(
|
||||
f"SELECT * FROM market.stalls WHERE wallet IN ({q})", (*wallet_ids,)
|
||||
)
|
||||
return [Stalls(**row) for row in rows]
|
||||
|
||||
|
||||
async def get_market_stalls_by_ids(stall_ids: Union[str, List[str]]) -> List[Stalls]:
|
||||
q = ",".join(["?"] * len(stall_ids))
|
||||
rows = await db.fetchall(
|
||||
f"SELECT * FROM market.stalls WHERE id IN ({q})", (*stall_ids,)
|
||||
)
|
||||
return [Stalls(**row) for row in rows]
|
||||
|
||||
|
||||
async def delete_market_stall(stall_id: str) -> None:
|
||||
await db.execute("DELETE FROM market.stalls WHERE id = ?", (stall_id,))
|
||||
|
||||
|
||||
###Orders
|
||||
|
||||
|
||||
async def create_market_order(data: createOrder, invoiceid: str):
|
||||
returning = "" if db.type == SQLITE else "RETURNING ID"
|
||||
method = db.execute if db.type == SQLITE else db.fetchone
|
||||
|
||||
result = await (method)(
|
||||
f"""
|
||||
INSERT INTO market.orders (wallet, shippingzone, address, email, total, invoiceid, paid, shipped)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
{returning}
|
||||
""",
|
||||
(
|
||||
data.wallet,
|
||||
data.shippingzone,
|
||||
data.address,
|
||||
data.email,
|
||||
data.total,
|
||||
invoiceid,
|
||||
False,
|
||||
False,
|
||||
),
|
||||
)
|
||||
if db.type == SQLITE:
|
||||
return result._result_proxy.lastrowid
|
||||
else:
|
||||
return result[0]
|
||||
|
||||
|
||||
async def create_market_order_details(order_id: str, data: List[createOrderDetails]):
|
||||
for item in data:
|
||||
item_id = urlsafe_short_hash()
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO market.order_details (id, order_id, product_id, quantity)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
item_id,
|
||||
order_id,
|
||||
item.product_id,
|
||||
item.quantity,
|
||||
),
|
||||
)
|
||||
order_details = await get_market_order_details(order_id)
|
||||
return order_details
|
||||
|
||||
|
||||
async def get_market_order_details(order_id: str) -> List[OrderDetail]:
|
||||
rows = await db.fetchall(
|
||||
f"SELECT * FROM market.order_details WHERE order_id = ?", (order_id,)
|
||||
)
|
||||
|
||||
return [OrderDetail(**row) for row in rows]
|
||||
|
||||
|
||||
async def get_market_order(order_id: str) -> Optional[Orders]:
|
||||
row = await db.fetchone("SELECT * FROM market.orders WHERE id = ?", (order_id,))
|
||||
return Orders(**row) if row else None
|
||||
|
||||
|
||||
async def get_market_order_invoiceid(invoice_id: str) -> Optional[Orders]:
|
||||
row = await db.fetchone(
|
||||
"SELECT * FROM market.orders WHERE invoiceid = ?", (invoice_id,)
|
||||
)
|
||||
return Orders(**row) if row else None
|
||||
|
||||
|
||||
async def set_market_order_paid(payment_hash: str):
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE market.orders
|
||||
SET paid = true
|
||||
WHERE invoiceid = ?
|
||||
""",
|
||||
(payment_hash,),
|
||||
)
|
||||
|
||||
|
||||
async def set_market_order_pubkey(payment_hash: str, pubkey: str):
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE market.orders
|
||||
SET pubkey = ?
|
||||
WHERE invoiceid = ?
|
||||
""",
|
||||
(
|
||||
pubkey,
|
||||
payment_hash,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def update_market_product_stock(products):
|
||||
|
||||
q = "\n".join(
|
||||
[f"""WHEN id='{p.product_id}' THEN quantity - {p.quantity}""" for p in products]
|
||||
)
|
||||
v = ",".join(["?"] * len(products))
|
||||
|
||||
await db.execute(
|
||||
f"""
|
||||
UPDATE market.products
|
||||
SET quantity=(CASE
|
||||
{q}
|
||||
END)
|
||||
WHERE id IN ({v});
|
||||
""",
|
||||
(*[p.product_id for p in products],),
|
||||
)
|
||||
|
||||
|
||||
async def get_market_orders(wallet_ids: Union[str, List[str]]) -> List[Orders]:
|
||||
if isinstance(wallet_ids, str):
|
||||
wallet_ids = [wallet_ids]
|
||||
|
||||
q = ",".join(["?"] * len(wallet_ids))
|
||||
rows = await db.fetchall(
|
||||
f"SELECT * FROM market.orders WHERE wallet IN ({q})", (*wallet_ids,)
|
||||
)
|
||||
#
|
||||
return [Orders(**row) for row in rows]
|
||||
|
||||
|
||||
async def delete_market_order(order_id: str) -> None:
|
||||
await db.execute("DELETE FROM market.orders WHERE id = ?", (order_id,))
|
||||
|
||||
|
||||
### Market/Marketplace
|
||||
|
||||
|
||||
async def get_market_markets(user: str) -> List[Market]:
|
||||
rows = await db.fetchall("SELECT * FROM market.markets WHERE usr = ?", (user,))
|
||||
return [Market(**row) for row in rows]
|
||||
|
||||
|
||||
async def get_market_market(market_id: str) -> Optional[Market]:
|
||||
row = await db.fetchone("SELECT * FROM market.markets WHERE id = ?", (market_id,))
|
||||
return Market(**row) if row else None
|
||||
|
||||
|
||||
async def get_market_market_stalls(market_id: str):
|
||||
rows = await db.fetchall(
|
||||
"SELECT * FROM market.market_stalls WHERE marketid = ?", (market_id,)
|
||||
)
|
||||
|
||||
ids = [row["stallid"] for row in rows]
|
||||
|
||||
return await get_market_stalls_by_ids(ids)
|
||||
|
||||
|
||||
async def create_market_market(data: CreateMarket):
|
||||
market_id = urlsafe_short_hash()
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO market.markets (id, usr, name)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
(
|
||||
market_id,
|
||||
data.usr,
|
||||
data.name,
|
||||
),
|
||||
)
|
||||
market = await get_market_market(market_id)
|
||||
assert market, "Newly created market couldn't be retrieved"
|
||||
return market
|
||||
|
||||
|
||||
async def create_market_market_stalls(market_id: str, data: List[str]):
|
||||
for stallid in data:
|
||||
id = urlsafe_short_hash()
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO market.market_stalls (id, marketid, stallid)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
(
|
||||
id,
|
||||
market_id,
|
||||
stallid,
|
||||
),
|
||||
)
|
||||
market_stalls = await get_market_market_stalls(market_id)
|
||||
return market_stalls
|
||||
|
||||
|
||||
async def update_market_market(market_id: str, name: str):
|
||||
await db.execute(
|
||||
"UPDATE market.markets SET name = ? WHERE id = ?",
|
||||
(name, market_id),
|
||||
)
|
||||
await db.execute(
|
||||
"DELETE FROM market.market_stalls WHERE marketid = ?",
|
||||
(market_id,),
|
||||
)
|
||||
|
||||
market = await get_market_market(market_id)
|
||||
return market
|
||||
|
||||
|
||||
### CHAT / MESSAGES
|
||||
|
||||
|
||||
async def create_chat_message(data: CreateChatMessage):
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO market.messages (msg, pubkey, id_conversation)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
(
|
||||
data.msg,
|
||||
data.pubkey,
|
||||
data.room_name,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def get_market_latest_chat_messages(room_name: str):
|
||||
rows = await db.fetchall(
|
||||
"SELECT * FROM market.messages WHERE id_conversation = ? ORDER BY timestamp DESC LIMIT 20",
|
||||
(room_name,),
|
||||
)
|
||||
|
||||
return [ChatMessage(**row) for row in rows]
|
||||
|
||||
|
||||
async def get_market_chat_messages(room_name: str):
|
||||
rows = await db.fetchall(
|
||||
"SELECT * FROM market.messages WHERE id_conversation = ? ORDER BY timestamp DESC",
|
||||
(room_name,),
|
||||
)
|
||||
|
||||
return [ChatMessage(**row) for row in rows]
|
||||
|
||||
|
||||
async def get_market_chat_by_merchant(ids: List[str]) -> List[ChatMessage]:
|
||||
|
||||
q = ",".join(["?"] * len(ids))
|
||||
rows = await db.fetchall(
|
||||
f"SELECT * FROM market.messages WHERE id_conversation IN ({q})",
|
||||
(*ids,),
|
||||
)
|
||||
return [ChatMessage(**row) for row in rows]
|
||||
|
||||
|
||||
async def get_market_settings(user) -> Optional[MarketSettings]:
|
||||
row = await db.fetchone(
|
||||
"""SELECT * FROM market.settings WHERE "user" = ?""", (user,)
|
||||
)
|
||||
|
||||
return MarketSettings(**row) if row else None
|
||||
|
||||
|
||||
async def create_market_settings(user: str, data):
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO market.settings ("user", currency, fiat_base_multiplier)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
(
|
||||
user,
|
||||
data.currency,
|
||||
data.fiat_base_multiplier,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def set_market_settings(user: str, data):
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE market.settings
|
||||
SET currency = ?, fiat_base_multiplier = ?
|
||||
WHERE "user" = ?;
|
||||
""",
|
||||
(
|
||||
data.currency,
|
||||
data.fiat_base_multiplier,
|
||||
user,
|
||||
),
|
||||
)
|
156
lnbits/extensions/market/migrations.py
Normal file
156
lnbits/extensions/market/migrations.py
Normal file
|
@ -0,0 +1,156 @@
|
|||
async def m001_initial(db):
|
||||
"""
|
||||
Initial Market settings table.
|
||||
"""
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE market.settings (
|
||||
"user" TEXT PRIMARY KEY,
|
||||
currency TEXT DEFAULT 'sat',
|
||||
fiat_base_multiplier INTEGER DEFAULT 1
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
"""
|
||||
Initial stalls table.
|
||||
"""
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE market.stalls (
|
||||
id TEXT PRIMARY KEY,
|
||||
wallet TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
currency TEXT,
|
||||
publickey TEXT,
|
||||
relays TEXT,
|
||||
shippingzones TEXT NOT NULL,
|
||||
rating INTEGER DEFAULT 0
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
"""
|
||||
Initial products table.
|
||||
"""
|
||||
await db.execute(
|
||||
f"""
|
||||
CREATE TABLE market.products (
|
||||
id TEXT PRIMARY KEY,
|
||||
stall TEXT NOT NULL REFERENCES {db.references_schema}stalls (id) ON DELETE CASCADE,
|
||||
product TEXT NOT NULL,
|
||||
categories TEXT,
|
||||
description TEXT,
|
||||
image TEXT,
|
||||
price INTEGER NOT NULL,
|
||||
quantity INTEGER NOT NULL,
|
||||
rating INTEGER DEFAULT 0
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
"""
|
||||
Initial zones table.
|
||||
"""
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE market.zones (
|
||||
id TEXT PRIMARY KEY,
|
||||
"user" TEXT NOT NULL,
|
||||
cost TEXT NOT NULL,
|
||||
countries TEXT NOT NULL
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
"""
|
||||
Initial orders table.
|
||||
"""
|
||||
await db.execute(
|
||||
f"""
|
||||
CREATE TABLE market.orders (
|
||||
id {db.serial_primary_key},
|
||||
wallet TEXT NOT NULL,
|
||||
username TEXT,
|
||||
pubkey TEXT,
|
||||
shippingzone TEXT NOT NULL,
|
||||
address TEXT NOT NULL,
|
||||
email TEXT NOT NULL,
|
||||
total INTEGER NOT NULL,
|
||||
invoiceid TEXT NOT NULL,
|
||||
paid BOOLEAN NOT NULL,
|
||||
shipped BOOLEAN NOT NULL,
|
||||
time TIMESTAMP NOT NULL DEFAULT """
|
||||
+ db.timestamp_now
|
||||
+ """
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
"""
|
||||
Initial order details table.
|
||||
"""
|
||||
await db.execute(
|
||||
f"""
|
||||
CREATE TABLE market.order_details (
|
||||
id TEXT PRIMARY KEY,
|
||||
order_id INTEGER NOT NULL REFERENCES {db.references_schema}orders (id) ON DELETE CASCADE,
|
||||
product_id TEXT NOT NULL REFERENCES {db.references_schema}products (id) ON DELETE CASCADE,
|
||||
quantity INTEGER NOT NULL
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
"""
|
||||
Initial market table.
|
||||
"""
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE market.markets (
|
||||
id TEXT PRIMARY KEY,
|
||||
usr TEXT NOT NULL,
|
||||
name TEXT
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
"""
|
||||
Initial market stalls table.
|
||||
"""
|
||||
await db.execute(
|
||||
f"""
|
||||
CREATE TABLE market.market_stalls (
|
||||
id TEXT PRIMARY KEY,
|
||||
marketid TEXT NOT NULL REFERENCES {db.references_schema}markets (id) ON DELETE CASCADE,
|
||||
stallid TEXT NOT NULL REFERENCES {db.references_schema}stalls (id) ON DELETE CASCADE
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
"""
|
||||
Initial chat messages table.
|
||||
"""
|
||||
await db.execute(
|
||||
f"""
|
||||
CREATE TABLE market.messages (
|
||||
id {db.serial_primary_key},
|
||||
msg TEXT NOT NULL,
|
||||
pubkey TEXT NOT NULL,
|
||||
id_conversation TEXT NOT NULL,
|
||||
timestamp TIMESTAMP NOT NULL DEFAULT """
|
||||
+ db.timestamp_now
|
||||
+ """
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
if db.type != "SQLITE":
|
||||
"""
|
||||
Create indexes for message fetching
|
||||
"""
|
||||
await db.execute(
|
||||
"CREATE INDEX idx_messages_timestamp ON market.messages (timestamp DESC)"
|
||||
)
|
||||
await db.execute(
|
||||
"CREATE INDEX idx_messages_conversations ON market.messages (id_conversation)"
|
||||
)
|
135
lnbits/extensions/market/models.py
Normal file
135
lnbits/extensions/market/models.py
Normal file
|
@ -0,0 +1,135 @@
|
|||
from typing import List, Optional
|
||||
|
||||
from fastapi.param_functions import Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class MarketSettings(BaseModel):
|
||||
user: str
|
||||
currency: str
|
||||
fiat_base_multiplier: int
|
||||
|
||||
|
||||
class SetSettings(BaseModel):
|
||||
currency: str
|
||||
fiat_base_multiplier: int = Query(100, ge=1)
|
||||
|
||||
|
||||
class Stalls(BaseModel):
|
||||
id: str
|
||||
wallet: str
|
||||
name: str
|
||||
currency: str
|
||||
publickey: Optional[str]
|
||||
relays: Optional[str]
|
||||
shippingzones: str
|
||||
|
||||
|
||||
class createStalls(BaseModel):
|
||||
wallet: str = Query(...)
|
||||
name: str = Query(...)
|
||||
currency: str = Query("sat")
|
||||
publickey: str = Query(None)
|
||||
relays: str = Query(None)
|
||||
shippingzones: str = Query(...)
|
||||
|
||||
|
||||
class createProduct(BaseModel):
|
||||
stall: str = Query(...)
|
||||
product: str = Query(...)
|
||||
categories: str = Query(None)
|
||||
description: str = Query(None)
|
||||
image: str = Query(None)
|
||||
price: float = Query(0, ge=0)
|
||||
quantity: int = Query(0, ge=0)
|
||||
|
||||
|
||||
class Products(BaseModel):
|
||||
id: str
|
||||
stall: str
|
||||
product: str
|
||||
categories: Optional[str]
|
||||
description: Optional[str]
|
||||
image: Optional[str]
|
||||
price: float
|
||||
quantity: int
|
||||
|
||||
|
||||
class createZones(BaseModel):
|
||||
cost: float = Query(0, ge=0)
|
||||
countries: str = Query(...)
|
||||
|
||||
|
||||
class Zones(BaseModel):
|
||||
id: str
|
||||
user: str
|
||||
cost: float
|
||||
countries: str
|
||||
|
||||
|
||||
class OrderDetail(BaseModel):
|
||||
id: str
|
||||
order_id: str
|
||||
product_id: str
|
||||
quantity: int
|
||||
|
||||
|
||||
class createOrderDetails(BaseModel):
|
||||
product_id: str = Query(...)
|
||||
quantity: int = Query(..., ge=1)
|
||||
|
||||
|
||||
class createOrder(BaseModel):
|
||||
wallet: str = Query(...)
|
||||
username: str = Query(None)
|
||||
pubkey: str = Query(None)
|
||||
shippingzone: str = Query(...)
|
||||
address: str = Query(...)
|
||||
email: str = Query(...)
|
||||
total: int = Query(...)
|
||||
products: List[createOrderDetails]
|
||||
|
||||
|
||||
class Orders(BaseModel):
|
||||
id: str
|
||||
wallet: str
|
||||
username: Optional[str]
|
||||
pubkey: Optional[str]
|
||||
shippingzone: str
|
||||
address: str
|
||||
email: str
|
||||
total: int
|
||||
invoiceid: str
|
||||
paid: bool
|
||||
shipped: bool
|
||||
time: int
|
||||
|
||||
|
||||
class CreateMarket(BaseModel):
|
||||
usr: str = Query(...)
|
||||
name: str = Query(None)
|
||||
stalls: List[str] = Query(...)
|
||||
|
||||
|
||||
class Market(BaseModel):
|
||||
id: str
|
||||
usr: str
|
||||
name: Optional[str]
|
||||
|
||||
|
||||
class CreateMarketStalls(BaseModel):
|
||||
stallid: str
|
||||
|
||||
|
||||
class ChatMessage(BaseModel):
|
||||
id: str
|
||||
msg: str
|
||||
pubkey: str
|
||||
id_conversation: str
|
||||
timestamp: int
|
||||
|
||||
|
||||
class CreateChatMessage(BaseModel):
|
||||
msg: str = Query(..., min_length=1)
|
||||
pubkey: str = Query(...)
|
||||
room_name: str = Query(...)
|
91
lnbits/extensions/market/notifier.py
Normal file
91
lnbits/extensions/market/notifier.py
Normal file
|
@ -0,0 +1,91 @@
|
|||
## adapted from https://github.com/Sentymental/chat-fastapi-websocket
|
||||
"""
|
||||
Create a class Notifier that will handle messages
|
||||
and delivery to the specific person
|
||||
"""
|
||||
|
||||
import json
|
||||
from collections import defaultdict
|
||||
|
||||
from fastapi import WebSocket
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.extensions.market.crud import create_chat_message
|
||||
from lnbits.extensions.market.models import CreateChatMessage
|
||||
|
||||
|
||||
class Notifier:
|
||||
"""
|
||||
Manages chatrooms, sessions and members.
|
||||
|
||||
Methods:
|
||||
- get_notification_generator(self): async generator with notification messages
|
||||
- get_members(self, room_name: str): get members in room
|
||||
- push(message: str, room_name: str): push message
|
||||
- connect(websocket: WebSocket, room_name: str): connect to room
|
||||
- remove(websocket: WebSocket, room_name: str): remove
|
||||
- _notify(message: str, room_name: str): notifier
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
# Create sessions as a dict:
|
||||
self.sessions: dict = defaultdict(dict)
|
||||
|
||||
# Create notification generator:
|
||||
self.generator = self.get_notification_generator()
|
||||
|
||||
async def get_notification_generator(self):
|
||||
"""Notification Generator"""
|
||||
|
||||
while True:
|
||||
message = yield
|
||||
msg = message["message"]
|
||||
room_name = message["room_name"]
|
||||
await self._notify(msg, room_name)
|
||||
|
||||
def get_members(self, room_name: str):
|
||||
"""Get all members in a room"""
|
||||
|
||||
try:
|
||||
logger.info(f"Looking for members in room: {room_name}")
|
||||
return self.sessions[room_name]
|
||||
|
||||
except Exception:
|
||||
logger.exception(f"There is no member in room: {room_name}")
|
||||
return None
|
||||
|
||||
async def push(self, message: str, room_name: str = None):
|
||||
"""Push a message"""
|
||||
|
||||
message_body = {"message": message, "room_name": room_name}
|
||||
await self.generator.asend(message_body)
|
||||
|
||||
async def connect(self, websocket: WebSocket, room_name: str):
|
||||
"""Connect to room"""
|
||||
|
||||
await websocket.accept()
|
||||
if self.sessions[room_name] == {} or len(self.sessions[room_name]) == 0:
|
||||
self.sessions[room_name] = []
|
||||
|
||||
self.sessions[room_name].append(websocket)
|
||||
print(f"Connections ...: {self.sessions[room_name]}")
|
||||
|
||||
def remove(self, websocket: WebSocket, room_name: str):
|
||||
"""Remove websocket from room"""
|
||||
|
||||
self.sessions[room_name].remove(websocket)
|
||||
print(f"Connection removed...\nOpen connections...: {self.sessions[room_name]}")
|
||||
|
||||
async def _notify(self, message: str, room_name: str):
|
||||
"""Notifier"""
|
||||
d = json.loads(message)
|
||||
d["room_name"] = room_name
|
||||
db_msg = CreateChatMessage.parse_obj(d)
|
||||
await create_chat_message(data=db_msg)
|
||||
|
||||
remaining_sessions = []
|
||||
while len(self.sessions[room_name]) > 0:
|
||||
websocket = self.sessions[room_name].pop()
|
||||
await websocket.send_text(message)
|
||||
remaining_sessions.append(websocket)
|
||||
self.sessions[room_name] = remaining_sessions
|
BIN
lnbits/extensions/market/static/images/bitcoin-shop.png
Normal file
BIN
lnbits/extensions/market/static/images/bitcoin-shop.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 5.9 KiB |
BIN
lnbits/extensions/market/static/images/placeholder.png
Normal file
BIN
lnbits/extensions/market/static/images/placeholder.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.8 KiB |
42
lnbits/extensions/market/tasks.py
Normal file
42
lnbits/extensions/market/tasks.py
Normal file
|
@ -0,0 +1,42 @@
|
|||
import asyncio
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.models import Payment
|
||||
from lnbits.tasks import register_invoice_listener
|
||||
|
||||
from .crud import (
|
||||
get_market_order_details,
|
||||
get_market_order_invoiceid,
|
||||
set_market_order_paid,
|
||||
update_market_product_stock,
|
||||
)
|
||||
|
||||
|
||||
async def wait_for_paid_invoices():
|
||||
invoice_queue = asyncio.Queue()
|
||||
register_invoice_listener(invoice_queue)
|
||||
|
||||
while True:
|
||||
payment = await invoice_queue.get()
|
||||
await on_invoice_paid(payment)
|
||||
|
||||
|
||||
async def on_invoice_paid(payment: Payment) -> None:
|
||||
if not payment.extra:
|
||||
return
|
||||
|
||||
if payment.extra.get("tag") != "market":
|
||||
return
|
||||
|
||||
order = await get_market_order_invoiceid(payment.payment_hash)
|
||||
if not order:
|
||||
logger.error("this should never happen", payment)
|
||||
return
|
||||
|
||||
# set order as paid
|
||||
await set_market_order_paid(payment.payment_hash)
|
||||
|
||||
# deduct items sold from stock
|
||||
details = await get_market_order_details(order.id)
|
||||
await update_market_product_stock(details)
|
128
lnbits/extensions/market/templates/market/_api_docs.html
Normal file
128
lnbits/extensions/market/templates/market/_api_docs.html
Normal file
|
@ -0,0 +1,128 @@
|
|||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="swap_vertical_circle"
|
||||
label="Setup guide"
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<h5 class="text-subtitle1 q-my-none">
|
||||
LNbits Market (Nostr support coming soon)
|
||||
</h5>
|
||||
|
||||
<ol>
|
||||
<li>Create Shipping Zones you're willing to ship to</li>
|
||||
<li>Create a Stall to list yiur products on</li>
|
||||
<li>Create products to put on the Stall</li>
|
||||
<li>Take orders</li>
|
||||
<li>Includes chat support!</li>
|
||||
</ol>
|
||||
The first LNbits market idea 'Diagon Alley' helped create Nostr, and soon
|
||||
this market extension will have the option to work on Nostr 'Diagon Alley'
|
||||
mode, by the merchant, market, and buyer all having keys, and data being
|
||||
routed through Nostr relays.
|
||||
<br />
|
||||
<small>
|
||||
Created by,
|
||||
<a href="https://github.com/talvasconcelos">Tal Vasconcelos</a>,
|
||||
<a href="https://github.com/benarc">Ben Arc</a></small
|
||||
>
|
||||
<!-- </p> -->
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="swap_vertical_circle"
|
||||
label="API info"
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="Get prodcuts, categorised by wallet"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-light-blue">GET</span>
|
||||
/market/api/v1/stall/products/<relay_id></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 201 CREATED (application/json)
|
||||
</h5>
|
||||
<code>Product JSON list</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.url_root
|
||||
}}api/v1/stall/products/<relay_id></code
|
||||
>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="Get invoice for product"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-light-green">POST</span>
|
||||
/market/api/v1/stall/order/<relay_id></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<code
|
||||
>{"id": <string>, "address": <string>, "shippingzone":
|
||||
<integer>, "email": <string>, "quantity":
|
||||
<integer>}</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 201 CREATED (application/json)
|
||||
</h5>
|
||||
<code
|
||||
>{"checking_id": <string>,"payment_request":
|
||||
<string>}</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X POST {{ request.url_root
|
||||
}}api/v1/stall/order/<relay_id> -d '{"id": <product_id&>,
|
||||
"email": <customer_email>, "address": <customer_address>,
|
||||
"quantity": 2, "shippingzone": 1}' -H "Content-type: application/json"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="Check a product has been shipped"
|
||||
class="q-mb-md"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-light-blue">GET</span>
|
||||
/market/api/v1/stall/checkshipped/<checking_id></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code>{"shipped": <boolean>}</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.url_root
|
||||
}}api/v1/stall/checkshipped/<checking_id> -H "Content-type:
|
||||
application/json"</code
|
||||
>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
</q-expansion-item>
|
58
lnbits/extensions/market/templates/market/_chat_box.html
Normal file
58
lnbits/extensions/market/templates/market/_chat_box.html
Normal file
|
@ -0,0 +1,58 @@
|
|||
<q-card>
|
||||
<q-card-section>
|
||||
<h6 class="text-subtitle1 q-my-none">Messages</h6>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pa-none">
|
||||
<q-separator></q-separator>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<q-select
|
||||
v-model="customerKey"
|
||||
:options="Object.keys(messages).map(k => ({label: `${k.slice(0, 25)}...`, value: k}))"
|
||||
label="Customers"
|
||||
@input="chatRoom(customerKey)"
|
||||
emit-value
|
||||
></q-select>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<div class="chat-container" ref="chatCard">
|
||||
<div class="chat-box">
|
||||
<!-- <p v-if="Object.keys(messages).length === 0">No messages yet</p> -->
|
||||
<div class="chat-messages">
|
||||
<q-chat-message
|
||||
:key="index"
|
||||
v-for="(message, index) in orderMessages"
|
||||
:name="message.pubkey == keys.pubkey ? 'me' : 'customer'"
|
||||
:text="[message.msg]"
|
||||
:sent="message.pubkey == keys.pubkey ? true : false"
|
||||
:bg-color="message.pubkey == keys.pubkey ? 'white' : 'light-green-2'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<q-card-section>
|
||||
<q-form @submit="sendMessage" class="full-width chat-input">
|
||||
<q-input
|
||||
ref="newMessage"
|
||||
v-model="newMessage"
|
||||
placeholder="Message"
|
||||
class="full-width"
|
||||
dense
|
||||
outlined
|
||||
@click="checkWebSocket"
|
||||
>
|
||||
<template>
|
||||
<q-btn
|
||||
round
|
||||
dense
|
||||
flat
|
||||
type="submit"
|
||||
icon="send"
|
||||
color="primary"
|
||||
/>
|
||||
</template>
|
||||
</q-input>
|
||||
</q-form>
|
||||
</q-card-section>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
393
lnbits/extensions/market/templates/market/_dialogs.html
Normal file
393
lnbits/extensions/market/templates/market/_dialogs.html
Normal file
|
@ -0,0 +1,393 @@
|
|||
<!-- PRODUCT DIALOG -->
|
||||
<q-dialog v-model="productDialog.show" position="top">
|
||||
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
|
||||
<q-form @submit="sendProductFormData" class="q-gutter-md">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
v-model="productDialog.data.stall"
|
||||
:options="stalls.map(s => ({label: s.name, value: s.id}))"
|
||||
label="Stall"
|
||||
>
|
||||
</q-select>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="productDialog.data.product"
|
||||
label="Product"
|
||||
></q-input>
|
||||
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="productDialog.data.description"
|
||||
label="Description"
|
||||
></q-input>
|
||||
<!-- <div class="row"> -->
|
||||
<!-- <div class="col-5">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
multiple
|
||||
v-model.trim="productDialog.data.categories"
|
||||
:options="categories"
|
||||
label="Categories"
|
||||
class="q-pr-sm"
|
||||
></q-select>
|
||||
</div> -->
|
||||
<!-- <div class="col-7"> -->
|
||||
<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"
|
||||
placeholder="crafts,robots,etc"
|
||||
hint="Hit Enter to add"
|
||||
></q-select>
|
||||
<!-- </div> -->
|
||||
<!-- </div> -->
|
||||
|
||||
<q-file
|
||||
class="q-pr-md"
|
||||
filled
|
||||
dense
|
||||
capture="environment"
|
||||
accept="image/jpeg, image/png"
|
||||
:max-file-size="3*1024**2"
|
||||
label="Small image (optional)"
|
||||
clearable
|
||||
@input="imageAdded"
|
||||
@clear="imageCleared"
|
||||
>
|
||||
<template v-if="productDialog.data.image" v-slot:before>
|
||||
<img style="height: 1em" :src="productDialog.data.image" />
|
||||
</template>
|
||||
<template v-if="productDialog.data.image" v-slot:append>
|
||||
<q-icon
|
||||
name="cancel"
|
||||
@click.stop.prevent="imageCleared"
|
||||
class="cursor-pointer"
|
||||
/>
|
||||
</template>
|
||||
</q-file>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="productDialog.data.price"
|
||||
type="number"
|
||||
:label="'Price (' + currencies.unit + ') *'"
|
||||
:mask="currencies.unit != 'sat' ? '#.##' : '#'"
|
||||
fill-mask="0"
|
||||
reverse-fill-mask
|
||||
:step="currencies.unit != 'sat' ? '0.01' : '1'"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="productDialog.data.quantity"
|
||||
type="number"
|
||||
label="Quantity"
|
||||
></q-input>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
v-if="productDialog.data.id"
|
||||
unelevated
|
||||
color="primary"
|
||||
type="submit"
|
||||
>Update Product</q-btn
|
||||
>
|
||||
|
||||
<q-btn
|
||||
v-else
|
||||
unelevated
|
||||
color="primary"
|
||||
:disable="productDialog.data.price == null
|
||||
|| productDialog.data.product == null
|
||||
|| productDialog.data.description == null
|
||||
|| productDialog.data.quantity == null"
|
||||
type="submit"
|
||||
>Create Product</q-btn
|
||||
>
|
||||
|
||||
<q-btn
|
||||
v-close-popup
|
||||
flat
|
||||
@click="resetDialog('productDialog')"
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
<!-- ZONE DIALOG -->
|
||||
<q-dialog v-model="zoneDialog.show" position="top">
|
||||
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
|
||||
<q-form @submit="sendZoneFormData" class="q-gutter-md">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
multiple
|
||||
:options="shippingZoneOptions"
|
||||
label="Countries"
|
||||
v-model.trim="zoneDialog.data.countries"
|
||||
></q-select>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
:label="'Amount (' + currencies.unit + ') *'"
|
||||
:mask="currencies.unit != 'sat' ? '#.##' : '#'"
|
||||
fill-mask="0"
|
||||
reverse-fill-mask
|
||||
:step="currencies.unit != 'sat' ? '0.01' : '1'"
|
||||
type="number"
|
||||
v-model.trim="zoneDialog.data.cost"
|
||||
></q-input>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
v-if="zoneDialog.data.id"
|
||||
unelevated
|
||||
color="primary"
|
||||
type="submit"
|
||||
>Update Shipping Zone</q-btn
|
||||
>
|
||||
<q-btn
|
||||
v-else
|
||||
unelevated
|
||||
color="primary"
|
||||
:disable="zoneDialog.data.countries == null
|
||||
|| zoneDialog.data.cost == null"
|
||||
type="submit"
|
||||
>Create Shipping Zone</q-btn
|
||||
>
|
||||
|
||||
<q-btn
|
||||
v-close-popup
|
||||
flat
|
||||
@click="resetDialog('zoneDialog')"
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
<!-- MARKETPLACE/market DIALOG -->
|
||||
<q-dialog v-model="marketDialog.show" position="top">
|
||||
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
|
||||
<q-form @submit="sendMarketplaceFormData" class="q-gutter-md">
|
||||
<q-toggle
|
||||
label="Activate marketplace"
|
||||
color="primary"
|
||||
v-model="marketDialog.data.activate"
|
||||
></q-toggle>
|
||||
<q-select
|
||||
filled
|
||||
multiple
|
||||
emit-value
|
||||
:options="stalls.map(s => ({label: s.name, value: s.id}))"
|
||||
label="Stalls"
|
||||
v-model="marketDialog.data.stalls"
|
||||
map-options
|
||||
></q-select>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="marketDialog.data.name"
|
||||
label="Name"
|
||||
></q-input>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
v-if="marketDialog.data.id"
|
||||
unelevated
|
||||
color="primary"
|
||||
type="submit"
|
||||
>Update Marketplace</q-btn
|
||||
>
|
||||
<q-btn
|
||||
v-else
|
||||
unelevated
|
||||
color="primary"
|
||||
:disable="marketDialog.data.activate == null
|
||||
|| marketDialog.data.stalls == null"
|
||||
type="submit"
|
||||
>Launch Marketplace</q-btn
|
||||
>
|
||||
|
||||
<q-btn
|
||||
v-close-popup
|
||||
flat
|
||||
@click="resetDialog('marketDialog')"
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
<!-- STALL/STORE DIALOG -->
|
||||
<q-dialog v-model="stallDialog.show" position="top">
|
||||
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
|
||||
<q-form @submit="sendStallFormData" class="q-gutter-md">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="stallDialog.data.name"
|
||||
label="Name"
|
||||
></q-input>
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
v-model="stallDialog.data.wallet"
|
||||
:options="g.user.walletOptions"
|
||||
label="Wallet *"
|
||||
>
|
||||
</q-select>
|
||||
<q-input
|
||||
v-if="diagonAlley"
|
||||
v-if="keys"
|
||||
filled
|
||||
dense
|
||||
v-model.trim="stallDialog.data.publickey"
|
||||
label="Public Key"
|
||||
></q-input>
|
||||
<q-input
|
||||
v-if="diagonAlley"
|
||||
v-if="keys"
|
||||
filled
|
||||
dense
|
||||
v-model.trim="stallDialog.data.privatekey"
|
||||
label="Private Key"
|
||||
></q-input>
|
||||
<!-- NOSTR -->
|
||||
<div v-if="diagonAlley" class="row">
|
||||
<div class="col-5">
|
||||
<q-btn unelevated @click="generateKeys" color="primary"
|
||||
>Generate keys</q-btn
|
||||
>
|
||||
</div>
|
||||
<div class="col-5">
|
||||
<q-btn unelevated @click="restoreKeys" color="primary"
|
||||
>Restore keys</q-btn
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<q-select
|
||||
:options="zoneOptions"
|
||||
filled
|
||||
dense
|
||||
multiple
|
||||
v-model.trim="stallDialog.data.shippingzones"
|
||||
label="Shipping Zones"
|
||||
></q-select>
|
||||
<q-select
|
||||
v-if="diagonAlley"
|
||||
:options="relayOptions"
|
||||
filled
|
||||
dense
|
||||
multiple
|
||||
v-model.trim="stallDialog.data.relays"
|
||||
label="Relays"
|
||||
></q-select>
|
||||
<q-input
|
||||
v-if="diagonAlley"
|
||||
filled
|
||||
dense
|
||||
v-model.trim="stallDialog.data.crelays"
|
||||
label="Custom relays (seperate by comma)"
|
||||
></q-input>
|
||||
<q-input
|
||||
v-if="diagonAlley"
|
||||
filled
|
||||
dense
|
||||
v-model.trim="stallDialog.data.nostrMarkets"
|
||||
label="Nostr market public keys (seperate by comma)"
|
||||
></q-input>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
v-if="stallDialog.data.id"
|
||||
unelevated
|
||||
color="primary"
|
||||
type="submit"
|
||||
>Update Stall</q-btn
|
||||
>
|
||||
<q-btn
|
||||
v-else
|
||||
unelevated
|
||||
color="primary"
|
||||
:disable="stallDialog.data.wallet == null
|
||||
|| stallDialog.data.shippingzones == null"
|
||||
type="submit"
|
||||
>Create Stall</q-btn
|
||||
>
|
||||
<q-btn
|
||||
v-close-popup
|
||||
flat
|
||||
@click="resetDialog('stallDialog')"
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
<!-- ONBOARDING DIALOG -->
|
||||
<q-dialog v-model="onboarding.show">
|
||||
<q-card class="q-pa-lg">
|
||||
<h6 class="q-my-md text-primary">How to use Market</h6>
|
||||
<q-stepper v-model="step" color="primary" vertical animated>
|
||||
<q-step
|
||||
:name="1"
|
||||
title="Create a Shipping Zone"
|
||||
icon="settings"
|
||||
:done="step > 1"
|
||||
>
|
||||
Create Shipping Zones you're willing to ship to. You can define
|
||||
different values for different zones.
|
||||
<q-stepper-navigation>
|
||||
<q-btn @click="step = step + 1" color="primary" label="Next" />
|
||||
</q-stepper-navigation>
|
||||
</q-step>
|
||||
<q-step
|
||||
:name="2"
|
||||
title="Create a Stall"
|
||||
icon="create_new_folder"
|
||||
:done="step > 2"
|
||||
>
|
||||
Create a Stall and provide private and public keys to use for
|
||||
communication. If you don't have one, LNbits will create a key pair for
|
||||
you. It will be saved and can be used on other stalls.
|
||||
<q-stepper-navigation>
|
||||
<q-btn @click="step = step + 1" color="primary" label="Next" />
|
||||
</q-stepper-navigation>
|
||||
</q-step>
|
||||
|
||||
<q-step :name="3" title="Create Products" icon="assignment">
|
||||
Create your products, add a small description and an image. Choose to
|
||||
what stall, if you have more than one, it belongs to
|
||||
<q-stepper-navigation>
|
||||
<q-btn @click="onboarding.finish" color="primary" label="Finish" />
|
||||
</q-stepper-navigation>
|
||||
<div>
|
||||
<q-checkbox v-model="onboarding.showAgain" label="Show this again?" />
|
||||
</div>
|
||||
</q-step>
|
||||
</q-stepper>
|
||||
</q-card>
|
||||
</q-dialog>
|
440
lnbits/extensions/market/templates/market/_tables.html
Normal file
440
lnbits/extensions/market/templates/market/_tables.html
Normal file
|
@ -0,0 +1,440 @@
|
|||
<q-card>
|
||||
<!-- ORDERS TABLE -->
|
||||
<q-card-section>
|
||||
<div class="row items-center no-wrap q-mb-md">
|
||||
<div class="col">
|
||||
<h5 class="text-subtitle1 q-my-none">Orders</h5>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<q-btn flat color="grey" @click="exportOrdersCSV">Export to CSV</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:data="orders"
|
||||
row-key="id"
|
||||
:columns="ordersTable.columns"
|
||||
:pagination.sync="ordersTable.pagination"
|
||||
>
|
||||
{% raw %}
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th auto-width></q-th>
|
||||
<q-th auto-width></q-th>
|
||||
<q-th v-for="col in props.cols" :key="col.name" :props="props">
|
||||
{{ col.label }}
|
||||
</q-th>
|
||||
<q-th auto-width></q-th>
|
||||
</q-tr>
|
||||
</template>
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props">
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
size="sm"
|
||||
color="accent"
|
||||
round
|
||||
dense
|
||||
@click="props.expand = !props.expand"
|
||||
:icon="props.expand ? 'remove' : 'add'"
|
||||
/>
|
||||
</q-td>
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
size="sm"
|
||||
color="green"
|
||||
dense
|
||||
icon="chat"
|
||||
@click="chatRoom(props.row.invoiceid)"
|
||||
>
|
||||
<q-badge
|
||||
v-if="props.row.unread"
|
||||
color="red"
|
||||
rounded
|
||||
floating
|
||||
style="padding: 6px; border-radius: 6px"
|
||||
/>
|
||||
</q-btn>
|
||||
</q-td>
|
||||
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
||||
{{ col.value }}
|
||||
</q-td>
|
||||
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="shipOrder(props.row.id)"
|
||||
icon="add_marketping_cart"
|
||||
color="green"
|
||||
>
|
||||
<q-tooltip> Product shipped? </q-tooltip>
|
||||
</q-btn>
|
||||
</q-td>
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="deleteOrder(props.row.id)"
|
||||
icon="cancel"
|
||||
color="pink"
|
||||
></q-btn>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
<q-tr v-show="props.expand" :props="props">
|
||||
<q-td colspan="100%">
|
||||
<template>
|
||||
<div class="q-pa-md">
|
||||
<q-list>
|
||||
<q-item-label header>Order Details</q-item-label>
|
||||
|
||||
<q-item v-for="col in props.row.details" :key="col.id">
|
||||
<q-item-section>
|
||||
<q-item-label>Products</q-item-label>
|
||||
<q-item-label caption
|
||||
>{{ products.length && (_.findWhere(products, {id:
|
||||
col.product_id})).product }}</q-item-label
|
||||
>
|
||||
<q-item-label caption
|
||||
>Quantity: {{ col.quantity }}</q-item-label
|
||||
>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item>
|
||||
<q-item-section>
|
||||
<q-item-label>Shipping to</q-item-label>
|
||||
<q-item-label caption
|
||||
>{{ props.row.address }}</q-item-label
|
||||
>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item>
|
||||
<q-item-section>
|
||||
<q-item-label>User info</q-item-label>
|
||||
<q-item-label caption v-if="props.row.username"
|
||||
>{{ props.row.username }}</q-item-label
|
||||
>
|
||||
<q-item-label caption>{{ props.row.email }}</q-item-label>
|
||||
<q-item-label caption v-if="props.row.pubkey"
|
||||
>{{ props.row.pubkey }}</q-item-label
|
||||
>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item>
|
||||
<q-item-section>
|
||||
<q-item-label>Total</q-item-label>
|
||||
<q-item-label>{{ props.row.total }}</q-item-label>
|
||||
<!-- <q-icon name="star" color="yellow" /> -->
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</div>
|
||||
</template>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
{% endraw %}
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<q-card>
|
||||
<!-- PRODUCTS TABLE -->
|
||||
<q-card-section>
|
||||
<div class="row items-center no-wrap q-mb-md">
|
||||
<div class="col">
|
||||
<h5 class="text-subtitle1 q-my-none">
|
||||
Products
|
||||
<span v-if="stalls.length > 0" class="q-px-sm">
|
||||
<q-btn
|
||||
round
|
||||
color="primary"
|
||||
icon="add"
|
||||
size="sm"
|
||||
@click="productDialog.show = true"
|
||||
/>
|
||||
<q-tooltip> Add a product </q-tooltip>
|
||||
</span>
|
||||
</h5>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<q-btn flat color="grey" @click="exportProductsCSV"
|
||||
>Export to CSV</q-btn
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:data="products"
|
||||
row-key="id"
|
||||
:columns="productsTable.columns"
|
||||
:pagination.sync="productsTable.pagination"
|
||||
>
|
||||
{% raw %}
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th auto-width></q-th>
|
||||
<q-th v-for="col in props.cols" :key="col.name" :props="props">
|
||||
{{ col.label }}
|
||||
</q-th>
|
||||
<q-th auto-width></q-th>
|
||||
</q-tr>
|
||||
</template>
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props">
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
disabled
|
||||
unelevated
|
||||
dense
|
||||
size="xs"
|
||||
icon="add_marketping_cart"
|
||||
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
||||
type="a"
|
||||
:href="props.row.wallet"
|
||||
target="_blank"
|
||||
></q-btn>
|
||||
<q-tooltip> Link to pass to stall relay </q-tooltip>
|
||||
</q-td>
|
||||
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
||||
{{ col.value }}
|
||||
</q-td>
|
||||
<q-td class="text-center" auto-width>
|
||||
<img
|
||||
v-if="props.row.image"
|
||||
:src="props.row.image"
|
||||
style="height: 1.5em"
|
||||
/>
|
||||
</q-td>
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="openProductUpdateDialog(props.row.id)"
|
||||
icon="edit"
|
||||
color="light-blue"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="deleteProduct(props.row.id)"
|
||||
icon="cancel"
|
||||
color="pink"
|
||||
></q-btn>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
{% endraw %}
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<q-card>
|
||||
<!-- STALLS TABLE -->
|
||||
<q-card-section>
|
||||
<div class="row items-center no-wrap q-mb-md">
|
||||
<div class="col">
|
||||
<h5 class="text-subtitle1 q-my-none">Market Stalls</h5>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<q-btn flat color="grey" @click="exportStallsCSV">Export to CSV</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:data="stalls"
|
||||
row-key="id"
|
||||
:columns="stallTable.columns"
|
||||
:pagination.sync="stallTable.pagination"
|
||||
>
|
||||
{% raw %}
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th auto-width></q-th>
|
||||
<q-th v-for="col in props.cols" :key="col.name" :props="props">
|
||||
{{ col.label }}
|
||||
</q-th>
|
||||
<q-th auto-width></q-th>
|
||||
</q-tr>
|
||||
</template>
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props">
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
unelevated
|
||||
dense
|
||||
size="xs"
|
||||
icon="storefront"
|
||||
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
||||
type="a"
|
||||
:href="'/market/stalls/' + props.row.id"
|
||||
target="_blank"
|
||||
></q-btn>
|
||||
<q-tooltip> Stall simple UI marketping cart </q-tooltip>
|
||||
</q-td>
|
||||
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
||||
{{ col.value }}
|
||||
</q-td>
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="openStallUpdateDialog(props.row.id)"
|
||||
icon="edit"
|
||||
color="light-blue"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="deleteStall(props.row.id)"
|
||||
icon="cancel"
|
||||
color="pink"
|
||||
></q-btn>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
{% endraw %}
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<q-card v-if="markets.length">
|
||||
<!-- MARKETPLACES TABLE -->
|
||||
<q-card-section>
|
||||
<div class="row items-center no-wrap q-mb-md">
|
||||
<div class="col">
|
||||
<h5 class="text-subtitle1 q-my-none">Marketplaces</h5>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<q-btn flat color="grey" @click="exportStallsCSV">Export to CSV</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:data="markets"
|
||||
row-key="id"
|
||||
:columns="marketTable.columns"
|
||||
:pagination.sync="marketTable.pagination"
|
||||
>
|
||||
{% raw %}
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th auto-width></q-th>
|
||||
<q-th v-for="col in props.cols" :key="col.name" :props="props">
|
||||
{{ col.label }}
|
||||
</q-th>
|
||||
<q-th auto-width></q-th>
|
||||
</q-tr>
|
||||
</template>
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props">
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
unelevated
|
||||
dense
|
||||
size="xs"
|
||||
icon="storefront"
|
||||
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
||||
type="a"
|
||||
:href="'/market/market/' + props.row.id"
|
||||
target="_blank"
|
||||
></q-btn>
|
||||
<q-tooltip> Link to pass to stall relay </q-tooltip>
|
||||
</q-td>
|
||||
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
||||
{{ col.name == 'stalls' ? stallName(col.value) : col.value }}
|
||||
</q-td>
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="openMarketUpdateDialog(props.row.id)"
|
||||
icon="edit"
|
||||
color="light-blue"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="deleteMarket(props.row.id)"
|
||||
icon="cancel"
|
||||
color="pink"
|
||||
></q-btn>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
{% endraw %}
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<q-card>
|
||||
<!-- ZONES TABLE -->
|
||||
<q-card-section>
|
||||
<div class="row items-center no-wrap q-mb-md">
|
||||
<div class="col">
|
||||
<h5 class="text-subtitle1 q-my-none">Shipping Zones</h5>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<q-btn flat color="grey" @click="exportZonesCSV">Export to CSV</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:data="zones"
|
||||
row-key="id"
|
||||
:columns="zonesTable.columns"
|
||||
:pagination.sync="zonesTable.pagination"
|
||||
>
|
||||
{% raw %}
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th v-for="col in props.cols" :key="col.name" :props="props">
|
||||
{{ col.label }}
|
||||
</q-th>
|
||||
<q-th auto-width></q-th>
|
||||
</q-tr>
|
||||
</template>
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props">
|
||||
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
||||
{{ col.value }}
|
||||
</q-td>
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="openZoneUpdateDialog(props.row.id)"
|
||||
icon="edit"
|
||||
color="light-blue"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="deleteZone(props.row.id)"
|
||||
icon="cancel"
|
||||
color="pink"
|
||||
></q-btn>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
{% endraw %}
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
1419
lnbits/extensions/market/templates/market/index.html
Normal file
1419
lnbits/extensions/market/templates/market/index.html
Normal file
File diff suppressed because it is too large
Load Diff
175
lnbits/extensions/market/templates/market/market.html
Normal file
175
lnbits/extensions/market/templates/market/market.html
Normal file
|
@ -0,0 +1,175 @@
|
|||
{% extends "public.html" %} {% block page %}
|
||||
<div class="row q-mb-md">
|
||||
<div class="col-12 q-gutter-y-md">
|
||||
<q-toolbar class="row">
|
||||
<div class="col">
|
||||
<q-toolbar-title> Market: {{ market.name }} </q-toolbar-title>
|
||||
</div>
|
||||
<div class="col q-mx-md">
|
||||
<q-input
|
||||
class="float-left full-width q-ml-md"
|
||||
standout
|
||||
square
|
||||
dense
|
||||
outlined
|
||||
clearable
|
||||
v-model.trim="searchText"
|
||||
label="Search for products"
|
||||
>
|
||||
<template v-slot:append>
|
||||
<q-icon v-if="!searchText" name="search" />
|
||||
</template>
|
||||
</q-input>
|
||||
</div>
|
||||
</q-toolbar>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div
|
||||
class="col-xs-12 col-sm-6 col-md-4 col-lg-3"
|
||||
v-for="item in filterProducts"
|
||||
:key="item.id"
|
||||
>
|
||||
<q-card class="card--product">
|
||||
{% raw %}
|
||||
<q-img
|
||||
:src="item.image ? item.image : '/market/static/images/placeholder.png'"
|
||||
alt="Product Image"
|
||||
loading="lazy"
|
||||
spinner-color="white"
|
||||
fit="contain"
|
||||
height="300px"
|
||||
></q-img>
|
||||
|
||||
<q-card-section class="q-pb-xs q-pt-md">
|
||||
<div class="row no-wrap items-center">
|
||||
<div class="col text-subtitle2 ellipsis-2-lines">
|
||||
{{ item.product }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- <q-rating v-model="stars" color="orange" :max="5" readonly size="17px"></q-rating> -->
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section class="q-py-sm">
|
||||
<div>
|
||||
<div class="text-caption text-weight-bolder">
|
||||
{{ item.stallName }}
|
||||
</div>
|
||||
<span v-if="item.currency == 'sat'">
|
||||
<span class="text-h6">{{ item.price }} sats</span
|
||||
><span class="q-ml-sm text-grey-6"
|
||||
>BTC {{ (item.price / 1e8).toFixed(8) }}</span
|
||||
>
|
||||
</span>
|
||||
<span v-else>
|
||||
<span class="text-h6"
|
||||
>{{ getAmountFormated(item.price, item.currency) }}</span
|
||||
>
|
||||
<span v-if="exchangeRates" class="q-ml-sm text-grey-6"
|
||||
>({{ getValueInSats(item.price, item.currency) }} sats)</span
|
||||
>
|
||||
</span>
|
||||
<span
|
||||
class="q-ml-md text-caption text-green-8 text-weight-bolder q-mt-md"
|
||||
>{{item.quantity}} left</span
|
||||
>
|
||||
</div>
|
||||
<div v-if="item.categories" class="text-subtitle1">
|
||||
<q-chip v-for="(cat, i) in item.categories.split(',')" :key="i" dense
|
||||
>{{cat}}</q-chip
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="text-caption text-grey ellipsis-2-lines"
|
||||
style="min-height: 40px"
|
||||
>
|
||||
<p v-if="item.description">{{ item.description }}</p>
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-separator></q-separator>
|
||||
|
||||
<q-card-actions>
|
||||
<span>Stall: {{ item.stallName }}</span>
|
||||
<q-btn
|
||||
flat
|
||||
class="text-weight-bold text-capitalize q-ml-auto"
|
||||
dense
|
||||
color="primary"
|
||||
type="a"
|
||||
:href="'/market/stalls/' + item.stall"
|
||||
target="_blank"
|
||||
>
|
||||
Visit Stall
|
||||
</q-btn>
|
||||
</q-card-actions>
|
||||
{% endraw %}
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %} {% block scripts %}
|
||||
<script>
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
stalls: null,
|
||||
products: [],
|
||||
searchText: null,
|
||||
exchangeRates: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
filterProducts() {
|
||||
if (!this.searchText || this.searchText.length < 2) return this.products
|
||||
return this.products.filter(p => {
|
||||
return (
|
||||
p.product.includes(this.searchText) ||
|
||||
p.description.includes(this.searchText) ||
|
||||
p.categories.includes(this.searchText)
|
||||
)
|
||||
})
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async getRates() {
|
||||
let noFiat = this.stalls.map(s => s.currency).every(c => c == 'sat')
|
||||
if (noFiat) return
|
||||
try {
|
||||
let rates = await axios.get('https://api.opennode.co/v1/rates')
|
||||
this.exchangeRates = rates.data.data
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
},
|
||||
getValueInSats(amount, unit = 'USD') {
|
||||
if (!this.exchangeRates) return 0
|
||||
return Math.ceil(
|
||||
(amount / this.exchangeRates[`BTC${unit}`][unit]) * 1e8
|
||||
)
|
||||
},
|
||||
getAmountFormated(amount, unit = 'USD') {
|
||||
return LNbits.utils.formatCurrency(amount, unit)
|
||||
}
|
||||
},
|
||||
async created() {
|
||||
this.stalls = JSON.parse('{{ stalls | tojson }}')
|
||||
let products = JSON.parse('{{ products | tojson }}')
|
||||
|
||||
this.products = products.map(obj => {
|
||||
let stall = this.stalls.find(s => s.id == obj.stall)
|
||||
obj.currency = stall.currency
|
||||
if (obj.currency != 'sat') {
|
||||
obj.price = parseFloat((obj.price / 100).toFixed(2))
|
||||
}
|
||||
obj.stallName = stall.name
|
||||
return obj
|
||||
})
|
||||
await this.getRates()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
564
lnbits/extensions/market/templates/market/order.html
Normal file
564
lnbits/extensions/market/templates/market/order.html
Normal file
|
@ -0,0 +1,564 @@
|
|||
{% extends "public.html" %} {% block page %}
|
||||
<div class="row q-col-gutter-md flex">
|
||||
<div class="col-12 col-md-7 col-lg-6 q-gutter-y-md">
|
||||
<q-card>
|
||||
<div class="chat-container q-pa-md">
|
||||
<div class="chat-box">
|
||||
<!-- <p v-if="Object.keys(messages).length === 0">No messages yet</p> -->
|
||||
<div class="chat-messages">
|
||||
<q-chat-message
|
||||
:key="index"
|
||||
v-for="(message, index) in messages"
|
||||
:name="message.pubkey == user.keys.publickey ? 'me' : 'merchant'"
|
||||
:text="[message.msg]"
|
||||
:sent="message.pubkey == user.keys.publickey ? true : false"
|
||||
:bg-color="message.pubkey == user.keys.publickey ? 'white' : 'light-green-2'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<q-form @submit="sendMessage" class="full-width chat-input">
|
||||
<q-input
|
||||
ref="newMessage"
|
||||
v-model="newMessage"
|
||||
placeholder="Message"
|
||||
class="full-width"
|
||||
dense
|
||||
outlined
|
||||
@click="checkWebSocket"
|
||||
>
|
||||
<template>
|
||||
<q-btn
|
||||
round
|
||||
dense
|
||||
flat
|
||||
type="submit"
|
||||
icon="send"
|
||||
color="primary"
|
||||
/>
|
||||
</template>
|
||||
</q-input>
|
||||
</q-form>
|
||||
</div>
|
||||
</q-card>
|
||||
</div>
|
||||
<div class="col-12 col-md-5 col-lg-6 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
{% raw %}
|
||||
<h6 class="text-subtitle1 q-my-none">{{ stall.name }}</h6>
|
||||
<p @click="copyText(stall.publickey)" style="width: max-content">
|
||||
Public Key: {{ sliceKey(stall.publickey) }}
|
||||
<q-tooltip>Click to copy</q-tooltip>
|
||||
</p>
|
||||
{% endraw %}
|
||||
</q-card-section>
|
||||
<q-card-section v-if="user">
|
||||
<q-form @submit="" class="q-gutter-md">
|
||||
<!-- <q-select
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
v-model="model"
|
||||
:options="mockMerch"
|
||||
label="Merchant"
|
||||
hint="Select a merchant you've opened an order to"
|
||||
></q-select>
|
||||
<br /> -->
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
v-model="selectedOrder"
|
||||
:options="Object.keys(user.orders).map(o => ({label: `${o.slice(0, 25)}...`, value: o}))"
|
||||
label="Order"
|
||||
hint="Select an order from this merchant"
|
||||
@input="val => { changeOrder() }"
|
||||
emit-value
|
||||
></q-select>
|
||||
</q-form>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<q-list>
|
||||
{% raw %}
|
||||
<q-item clickable :key="p.id" v-for="p in products">
|
||||
<q-item-section side>
|
||||
<span>{{p.quantity}} x </span>
|
||||
</q-item-section>
|
||||
<q-item-section avatar>
|
||||
<q-avatar color="primary">
|
||||
<img size="sm" :src="p.image" />
|
||||
</q-avatar>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>{{ p.name }}</q-item-label>
|
||||
</q-item-section>
|
||||
|
||||
<q-item-section side>
|
||||
<span v-if="stall.currency != 'sat'"
|
||||
>{{ getAmountFormated(p.price) }}</span
|
||||
>
|
||||
<span v-else> {{p.price}} sats</span>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
{% endraw %}
|
||||
</q-list>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<q-separator></q-separator>
|
||||
<q-list>
|
||||
<q-expansion-item group="extras" icon="vpn_key" label="Keys"
|
||||
><p>
|
||||
Bellow are the keys needed to contact the merchant. They are
|
||||
stored in the browser!
|
||||
</p>
|
||||
<div v-if="user?.keys" class="row q-col-gutter-md">
|
||||
<div
|
||||
class="col-12 col-sm-6"
|
||||
v-for="type in ['publickey', 'privatekey']"
|
||||
v-bind:key="type"
|
||||
>
|
||||
<div class="text-center q-mb-lg">
|
||||
{% raw %}
|
||||
<q-responsive
|
||||
:ratio="1"
|
||||
class="q-mx-auto"
|
||||
style="max-width: 250px"
|
||||
>
|
||||
<qrcode
|
||||
:value="user.keys[type]"
|
||||
:options="{width: 500}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
<q-tooltip>{{ user.keys[type] }}</q-tooltip>
|
||||
</q-responsive>
|
||||
<p>
|
||||
{{ type == 'publickey' ? 'Public Key' : 'Private Key' }}
|
||||
</p>
|
||||
{% endraw %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<q-separator></q-separator>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn outline color="grey" @click="downloadKeys"
|
||||
>Backup keys
|
||||
<q-tooltip>Download your keys</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
class="q-mx-sm"
|
||||
@click="keysDialog.show = true"
|
||||
:disabled="this.user.keys"
|
||||
>Restore keys
|
||||
<q-tooltip>Restore keys</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
@click="deleteData"
|
||||
v-close-popup
|
||||
flat
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
>Delete data
|
||||
<q-tooltip>Delete all data from browser</q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</q-expansion-item>
|
||||
</q-list>
|
||||
<q-expansion-item icon="qr_code" label="Export page">
|
||||
<p>Export, or send, this page to another device</p>
|
||||
<div class="text-center q-mb-lg">
|
||||
<q-responsive
|
||||
:ratio="1"
|
||||
class="q-my-xl q-mx-auto"
|
||||
style="max-width: 250px"
|
||||
@click="copyText(exportURL)"
|
||||
>
|
||||
<qrcode
|
||||
:value="exportURL"
|
||||
:options="{width: 500}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
<q-tooltip>Click to copy</q-tooltip>
|
||||
</q-responsive>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
@click="copyText(exportURL)"
|
||||
v-close-popup
|
||||
flat
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
>Copy URL
|
||||
<q-tooltip
|
||||
>Export, or send, this page to another device</q-tooltip
|
||||
>
|
||||
</q-btn>
|
||||
</div>
|
||||
</q-expansion-item>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
<!-- RESTORE KEYS DIALOG -->
|
||||
<q-dialog
|
||||
v-if="diagonalley"
|
||||
v-model="keysDialog.show"
|
||||
position="top"
|
||||
@hide="clearRestoreKeyDialog"
|
||||
>
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card"> </q-card>
|
||||
<q-card class="q-pa-lg lnbits__dialog-card">
|
||||
<q-form @submit="restoreKeys" class="q-gutter-md">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="keysDialog.data.publickey"
|
||||
label="Public Key"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="keysDialog.data.privatekey"
|
||||
label="Private Key *optional"
|
||||
></q-input>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
:disable="keysDialog.data.publickey == null"
|
||||
type="submit"
|
||||
label="Submit"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
v-close-popup
|
||||
flat
|
||||
@click="clearRestoreKeyDialog"
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
label="Cancel"
|
||||
></q-btn>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
<!-- ONBOARDING DIALOG -->
|
||||
<q-dialog v-model="lnbitsBookmark.show">
|
||||
<q-card class="q-pa-lg">
|
||||
<h6 class="q-my-md text-primary">Bookmark this page</h6>
|
||||
<p>
|
||||
Don't forget to bookmark this page to be able to check on your order!
|
||||
</p>
|
||||
<p>
|
||||
You can backup your keys, and export the page to another device also.
|
||||
</p>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
v-close-popup
|
||||
flat
|
||||
@click="lnbitsBookmark.finish"
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
>Close</q-btn
|
||||
>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</div>
|
||||
{% endblock %} {% block scripts %}
|
||||
<script src="https://unpkg.com/nostr-tools/lib/nostr.bundle.js"></script>
|
||||
|
||||
<script>
|
||||
const mapChatMsg = msg => {
|
||||
let obj = {}
|
||||
obj.timestamp = {
|
||||
msg: msg,
|
||||
pubkey: pubkey
|
||||
}
|
||||
return obj
|
||||
}
|
||||
|
||||
const mapProductsItems = obj => {
|
||||
obj.price = (obj.price / 100).toFixed(2)
|
||||
|
||||
return obj
|
||||
}
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
const nostr = window.NostrTools
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
lnbitsBookmark: {
|
||||
show: true,
|
||||
finish: () => {
|
||||
this.$q.localStorage.set('lnbits.marketbookmark', false)
|
||||
this.lnbitsBookmark.show = false
|
||||
}
|
||||
},
|
||||
newMessage: '',
|
||||
showMessages: false,
|
||||
messages: {},
|
||||
stall: null,
|
||||
selectedOrder: null,
|
||||
diagonalley: false,
|
||||
products: [],
|
||||
orders: [],
|
||||
user: {
|
||||
keys: {},
|
||||
orders: {}
|
||||
},
|
||||
keysDialog: {
|
||||
show: false,
|
||||
data: {}
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
exportURL() {
|
||||
return (
|
||||
'{{request.url}}' +
|
||||
`&keys=${this.user.keys.publickey},${this.user.keys.privatekey}`
|
||||
)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getAmountFormated(amount) {
|
||||
return LNbits.utils.formatCurrency(amount, this.stall.currency)
|
||||
},
|
||||
clearMessage() {
|
||||
this.newMessage = ''
|
||||
this.$refs.newMessage.focus()
|
||||
},
|
||||
clearRestoreKeyDialog() {
|
||||
this.keysDialog = {
|
||||
show: false,
|
||||
data: {}
|
||||
}
|
||||
},
|
||||
sendMessage() {
|
||||
let message = {
|
||||
msg: this.newMessage,
|
||||
pubkey: this.user.keys.publickey
|
||||
}
|
||||
this.ws.send(JSON.stringify(message))
|
||||
|
||||
this.clearMessage()
|
||||
},
|
||||
sliceKey(key) {
|
||||
if (!key) return ''
|
||||
return `${key.slice(0, 4)}...${key.slice(-4)}`
|
||||
},
|
||||
downloadKeys() {
|
||||
const file = new File(
|
||||
[JSON.stringify(this.user.keys)],
|
||||
'backup_keys.json',
|
||||
{
|
||||
type: 'text/json'
|
||||
}
|
||||
)
|
||||
const link = document.createElement('a')
|
||||
const url = URL.createObjectURL(file)
|
||||
|
||||
link.href = url
|
||||
link.download = file.name
|
||||
link.click()
|
||||
|
||||
window.URL.revokeObjectURL(url)
|
||||
},
|
||||
restoreKeys() {
|
||||
this.user.keys = this.keysDialog.data
|
||||
let data = this.$q.localStorage.getItem(`lnbits.market.data`)
|
||||
this.$q.localStorage.set(`lnbits.market.data`, {
|
||||
...data,
|
||||
keys: this.user.keys
|
||||
})
|
||||
|
||||
this.clearRestoreKeyDialog()
|
||||
},
|
||||
deleteData() {
|
||||
LNbits.utils
|
||||
.confirmDialog('Are you sure you want to delete your stored data?')
|
||||
.onOk(() => {
|
||||
this.$q.localStorage.remove('lnbits.market.data')
|
||||
this.user = null
|
||||
})
|
||||
},
|
||||
generateKeys() {
|
||||
//check if the keys are set
|
||||
if ('publickey' in this.user.keys && 'privatekey' in this.user.keys)
|
||||
return
|
||||
|
||||
const privkey = nostr.generatePrivateKey()
|
||||
const pubkey = nostr.getPublicKey(privkey)
|
||||
|
||||
this.user.keys = {
|
||||
privatekey: privkey,
|
||||
publickey: pubkey
|
||||
}
|
||||
},
|
||||
async getMessages(room_name, all = false) {
|
||||
await LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
`/market/api/v1/chat/messages/${room_name}${
|
||||
all ? '?all_messages=true' : ''
|
||||
}`
|
||||
)
|
||||
.then(response => {
|
||||
if (response.data) {
|
||||
response.data.reverse().map(m => {
|
||||
this.$set(this.messages, m.timestamp * 1000, {
|
||||
msg: m.msg,
|
||||
pubkey: m.pubkey
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
async changeOrder() {
|
||||
this.products = this.user.orders[this.selectedOrder]
|
||||
this.messages = {}
|
||||
await this.getMessages(this.selectedOrder)
|
||||
this.startChat(this.selectedOrder)
|
||||
},
|
||||
checkWebSocket() {
|
||||
if (!this.ws) return
|
||||
if (this.ws.readyState === WebSocket.CLOSED) {
|
||||
console.log('WebSocket CLOSED: Reopening')
|
||||
this.ws = new WebSocket(
|
||||
ws_scheme + location.host + '/market/ws/' + this.selectedOrder
|
||||
)
|
||||
}
|
||||
},
|
||||
startChat(room_name) {
|
||||
if (this.ws) {
|
||||
this.ws.close()
|
||||
}
|
||||
if (location.protocol == 'https:') {
|
||||
ws_scheme = 'wss://'
|
||||
} else {
|
||||
ws_scheme = 'ws://'
|
||||
}
|
||||
ws = new WebSocket(
|
||||
ws_scheme + location.host + '/market/ws/' + room_name
|
||||
)
|
||||
|
||||
ws.onmessage = event => {
|
||||
let event_data = JSON.parse(event.data)
|
||||
|
||||
this.$set(this.messages, Date.now(), event_data)
|
||||
}
|
||||
|
||||
this.ws = ws
|
||||
}
|
||||
},
|
||||
async created() {
|
||||
let showBookmark = this.$q.localStorage.getItem('lnbits.marketbookmark')
|
||||
this.lnbitsBookmark.show = showBookmark === true || showBookmark == null
|
||||
|
||||
let order_details = JSON.parse('{{ order | tojson }}')
|
||||
let products = JSON.parse('{{ products | tojson }}')
|
||||
let order_id = '{{ order_id }}'
|
||||
let hasKeys = Boolean(
|
||||
JSON.parse('{{ publickey | tojson }}') &&
|
||||
JSON.parse('{{ privatekey | tojson }}')
|
||||
)
|
||||
|
||||
if (hasKeys) {
|
||||
this.user.keys = {
|
||||
privatekey: '{{ privatekey }}',
|
||||
publickey: '{{ publickey }}'
|
||||
}
|
||||
}
|
||||
|
||||
this.stall = JSON.parse('{{ stall | tojson }}')
|
||||
this.products = order_details.map(o => {
|
||||
let product = products.find(p => p.id == o.product_id)
|
||||
return {
|
||||
quantity: o.quantity,
|
||||
name: product.product,
|
||||
image: product.image,
|
||||
price: product.price
|
||||
}
|
||||
})
|
||||
console.log(this.stall)
|
||||
if (this.stall.currency != 'sat') {
|
||||
this.products = this.products.map(mapProductsItems)
|
||||
}
|
||||
|
||||
let data = this.$q.localStorage.getItem(`lnbits.market.data`) || false
|
||||
|
||||
if (data) {
|
||||
this.user = data
|
||||
if (!this.user.orders[`${order_id}`]) {
|
||||
this.$set(this.user.orders, order_id, this.products)
|
||||
}
|
||||
} else {
|
||||
// generate keys
|
||||
this.generateKeys()
|
||||
try {
|
||||
await LNbits.api.request(
|
||||
'GET',
|
||||
`/market/api/v1/order/pubkey/${order_id}/${this.user.keys.publickey}`
|
||||
)
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
// populate user data
|
||||
this.user.orders = {
|
||||
[`${order_id}`]: this.products
|
||||
}
|
||||
}
|
||||
|
||||
this.selectedOrder = order_id
|
||||
|
||||
await this.getMessages(order_id)
|
||||
|
||||
this.$q.localStorage.set(`lnbits.market.data`, this.user)
|
||||
this.startChat(order_id)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<style scoped>
|
||||
.q-field__native span {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.chat-container {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-rows: 1fr auto;
|
||||
/*height: calc(100vh - 200px);*/
|
||||
height: 70vh;
|
||||
}
|
||||
|
||||
.chat-box {
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
padding: 1rem;
|
||||
overflow-y: auto;
|
||||
margin-left: auto;
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.chat-other {
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: end;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
14
lnbits/extensions/market/templates/market/product.html
Normal file
14
lnbits/extensions/market/templates/market/product.html
Normal file
|
@ -0,0 +1,14 @@
|
|||
{% extends "public.html" %} {% block page %}
|
||||
<h1>Product page</h1>
|
||||
{% endblock %} {% block scripts %}
|
||||
<script>
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
return {}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
531
lnbits/extensions/market/templates/market/stall.html
Normal file
531
lnbits/extensions/market/templates/market/stall.html
Normal file
|
@ -0,0 +1,531 @@
|
|||
{% extends "public.html" %} {% block page %}
|
||||
<div class="row q-mb-md">
|
||||
<div class="col-12 q-gutter-y-md">
|
||||
<q-toolbar class="row">
|
||||
<div class="col">
|
||||
<q-toolbar-title> Stall: {{ stall.name }} </q-toolbar-title>
|
||||
</div>
|
||||
<div class="col q-mx-md">
|
||||
<q-input
|
||||
class="float-left full-width q-ml-md"
|
||||
standout
|
||||
square
|
||||
dense
|
||||
outlined
|
||||
clearable
|
||||
v-model.trim="searchText"
|
||||
label="Search for products"
|
||||
>
|
||||
<template v-slot:append>
|
||||
<q-icon v-if="!searchText" name="search" />
|
||||
</template>
|
||||
</q-input>
|
||||
</div>
|
||||
<q-btn dense round flat icon="shopping_cart">
|
||||
{% raw %}
|
||||
<q-badge v-if="cart.size" color="red" class="text-bold" floating>
|
||||
{{ cart.size }}
|
||||
</q-badge>
|
||||
{% endraw %}
|
||||
<q-menu v-if="cart.size">
|
||||
<q-list style="min-width: 100px">
|
||||
{% raw %}
|
||||
<q-item :key="p.id" v-for="p in cartMenu">
|
||||
<q-item-section side>
|
||||
<span>{{p.quantity}} x </span>
|
||||
</q-item-section>
|
||||
<q-item-section avatar>
|
||||
<q-avatar color="primary">
|
||||
<img
|
||||
size="sm"
|
||||
:src="products.find(f => f.id == p.id).image"
|
||||
/>
|
||||
</q-avatar>
|
||||
</q-item-section>
|
||||
|
||||
<q-item-section>
|
||||
<q-item-label>{{ p.name }}</q-item-label>
|
||||
</q-item-section>
|
||||
|
||||
<q-item-section side>
|
||||
<span>
|
||||
{{unit != 'sat' ? getAmountFormated(p.price) : p.price +
|
||||
'sats'}}
|
||||
<q-btn
|
||||
class="q-ml-md"
|
||||
round
|
||||
color="red"
|
||||
size="xs"
|
||||
icon="close"
|
||||
@click="removeFromCart(p)"
|
||||
/>
|
||||
</span>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
{% endraw %}
|
||||
<q-separator />
|
||||
</q-list>
|
||||
<div class="row q-pa-md q-gutter-md">
|
||||
<q-btn
|
||||
color="primary"
|
||||
icon-right="checkout"
|
||||
label="Checkout"
|
||||
@click="checkoutDialog.show = true"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
class="q-ml-lg"
|
||||
flat
|
||||
color="primary"
|
||||
label="Reset"
|
||||
@click="resetCart"
|
||||
></q-btn>
|
||||
</div>
|
||||
</q-menu>
|
||||
</q-btn>
|
||||
</q-toolbar>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div
|
||||
class="col-xs-12 col-sm-6 col-md-4 col-lg-3"
|
||||
v-for="item in filterProducts"
|
||||
:key="item.id"
|
||||
>
|
||||
<q-card class="card--product">
|
||||
{% raw %}
|
||||
<q-img
|
||||
:src="item.image ? item.image : '/market/static/images/placeholder.png'"
|
||||
alt="Product Image"
|
||||
loading="lazy"
|
||||
spinner-color="white"
|
||||
fit="contain"
|
||||
height="300px"
|
||||
></q-img>
|
||||
|
||||
<q-card-section class="q-pb-xs q-pt-md">
|
||||
<q-btn
|
||||
round
|
||||
:disabled="item.quantity < 1"
|
||||
color="primary"
|
||||
icon="shopping_cart"
|
||||
size="lg"
|
||||
style="
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
transform: translate(-50%, -50%);
|
||||
"
|
||||
@click="addToCart(item)"
|
||||
><q-tooltip> Add to cart </q-tooltip></q-btn
|
||||
>
|
||||
|
||||
<div class="row no-wrap items-center">
|
||||
<div class="col text-subtitle2 ellipsis-2-lines">
|
||||
{{ item.product }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- <q-rating v-model="stars" color="orange" :max="5" readonly size="17px"></q-rating> -->
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section class="q-py-sm">
|
||||
<div>
|
||||
<span v-if="unit == 'sat'">
|
||||
<span class="text-h6">{{ item.price }} sats</span
|
||||
><span class="q-ml-sm text-grey-6"
|
||||
>BTC {{ (item.price / 1e8).toFixed(8) }}</span
|
||||
>
|
||||
</span>
|
||||
<span v-else>
|
||||
<span class="text-h6">{{ getAmountFormated(item.price) }}</span>
|
||||
<span v-if="exchangeRate" class="q-ml-sm text-grey-6"
|
||||
>({{ getValueInSats(item.price) }} sats)</span
|
||||
>
|
||||
</span>
|
||||
<span
|
||||
class="q-ml-md text-caption text-green-8 text-weight-bolder q-mt-md"
|
||||
>{{item.quantity}} left</span
|
||||
>
|
||||
</div>
|
||||
<div v-if="item.categories" class="text-subtitle1">
|
||||
<q-chip v-for="(cat, i) in item.categories.split(',')" :key="i" dense
|
||||
>{{cat}}</q-chip
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="text-caption text-grey ellipsis-2-lines"
|
||||
style="min-height: 40px"
|
||||
>
|
||||
<p v-if="item.description">{{ item.description }}</p>
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||
<!-- <q-separator></q-separator>
|
||||
|
||||
<q-card-actions>
|
||||
<q-btn
|
||||
flat
|
||||
class="text-weight-bold text-capitalize"
|
||||
dense
|
||||
color="primary"
|
||||
>
|
||||
View details
|
||||
</q-btn>
|
||||
</q-card-actions> -->
|
||||
{% endraw %}
|
||||
</q-card>
|
||||
</div>
|
||||
<!-- CHECKOUT DIALOG -->
|
||||
<q-dialog v-model="checkoutDialog.show" position="top">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-form @submit="placeOrder" class="q-gutter-md">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="checkoutDialog.data.username"
|
||||
label="Name *optional"
|
||||
></q-input>
|
||||
<q-input
|
||||
v-if="diagonalley"
|
||||
filled
|
||||
dense
|
||||
v-model.trim="checkoutDialog.data.pubkey"
|
||||
label="Public key *optional"
|
||||
>
|
||||
<template v-slot:append>
|
||||
<q-icon @click="getPubkey" name="settings_backup_restore" />
|
||||
<q-tooltip>Click to restore saved public key</q-tooltip>
|
||||
</template>
|
||||
</q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="checkoutDialog.data.address"
|
||||
label="Address"
|
||||
></q-input>
|
||||
<!-- <q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="checkoutDialog.data.address_2"
|
||||
label="Address (line 2)"
|
||||
></q-input> -->
|
||||
<q-input
|
||||
v-model="checkoutDialog.data.email"
|
||||
filled
|
||||
dense
|
||||
type="email"
|
||||
label="Email"
|
||||
></q-input>
|
||||
<p>Select the shipping zone:</p>
|
||||
<div class="row q-mt-lg">
|
||||
<q-option-group
|
||||
:options="stall.zones"
|
||||
type="radio"
|
||||
emit-value
|
||||
v-model="checkoutDialog.data.shippingzone"
|
||||
/>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
{% raw %} Total: {{ unit != 'sat' ? getAmountFormated(finalCost) :
|
||||
finalCost + 'sats' }}
|
||||
<span v-if="unit != 'sat'" class="q-ml-sm text-grey-6"
|
||||
>({{ getValueInSats(finalCost) }} sats)</span
|
||||
>
|
||||
{% endraw %}
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
:disable="checkoutDialog.data.address == null
|
||||
|| checkoutDialog.data.email == null
|
||||
|| checkoutDialog.data.shippingzone == null"
|
||||
type="submit"
|
||||
>Checkout</q-btn
|
||||
>
|
||||
<q-btn
|
||||
v-close-popup
|
||||
flat
|
||||
@click="checkoutDialog = {show: false, data: {pubkey: ''}}"
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
<!-- INVOICE DIALOG -->
|
||||
<q-dialog
|
||||
v-model="qrCodeDialog.show"
|
||||
position="top"
|
||||
@hide="closeQrCodeDialog"
|
||||
>
|
||||
<q-card
|
||||
v-if="!qrCodeDialog.data.payment_request"
|
||||
class="q-pa-lg q-pt-xl lnbits__dialog-card"
|
||||
>
|
||||
</q-card>
|
||||
<q-card v-else class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<div class="text-center q-mb-lg">
|
||||
<a :href="'lightning:' + qrCodeDialog.data.payment_request">
|
||||
<q-responsive :ratio="1" class="q-mx-xl">
|
||||
<qrcode
|
||||
:value="qrCodeDialog.data.payment_request"
|
||||
:options="{width: 340}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
</q-responsive>
|
||||
</a>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
@click="copyText(qrCodeDialog.data.payment_request)"
|
||||
>Copy invoice</q-btn
|
||||
>
|
||||
<q-btn
|
||||
@click="closeQrCodeDialog"
|
||||
v-close-popup
|
||||
flat
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
>Close</q-btn
|
||||
>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</div>
|
||||
{% endblock %} {% block scripts %}
|
||||
<script>
|
||||
const mapProductsItems = obj => {
|
||||
obj.price = parseFloat((obj.price / 100).toFixed(2))
|
||||
|
||||
return obj
|
||||
}
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
stall: null,
|
||||
products: [],
|
||||
searchText: null,
|
||||
diagonalley: false,
|
||||
unit: 'sat',
|
||||
exchangeRate: 0,
|
||||
cart: {
|
||||
total: 0,
|
||||
size: 0,
|
||||
products: new Map()
|
||||
},
|
||||
cartMenu: [],
|
||||
checkoutDialog: {
|
||||
show: false,
|
||||
data: {
|
||||
pubkey: ''
|
||||
}
|
||||
},
|
||||
qrCodeDialog: {
|
||||
data: {
|
||||
payment_request: null
|
||||
},
|
||||
show: false
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
filterProducts() {
|
||||
if (!this.searchText || this.searchText.length < 2) return this.products
|
||||
return this.products.filter(p => {
|
||||
return (
|
||||
p.product.includes(this.searchText) ||
|
||||
p.description.includes(this.searchText) ||
|
||||
p.categories.includes(this.searchText)
|
||||
)
|
||||
})
|
||||
},
|
||||
finalCost() {
|
||||
if (!this.checkoutDialog.data.shippingzone) return this.cart.total
|
||||
|
||||
let zoneCost = this.stall.zones.find(
|
||||
z => z.value == this.checkoutDialog.data.shippingzone
|
||||
)
|
||||
return +this.cart.total + zoneCost.cost
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
closeQrCodeDialog() {
|
||||
this.qrCodeDialog.dismissMsg()
|
||||
this.qrCodeDialog.show = false
|
||||
},
|
||||
resetCart() {
|
||||
this.cart = {
|
||||
total: 0,
|
||||
size: 0,
|
||||
products: new Map()
|
||||
}
|
||||
},
|
||||
getAmountFormated(amount) {
|
||||
return LNbits.utils.formatCurrency(amount, this.unit)
|
||||
},
|
||||
async getRates() {
|
||||
if (this.unit == 'sat') return
|
||||
try {
|
||||
let rate = (
|
||||
await LNbits.api.request('POST', '/api/v1/conversion', null, {
|
||||
amount: 1e8,
|
||||
to: this.unit
|
||||
})
|
||||
).data
|
||||
this.exchangeRate = rate[this.unit]
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
},
|
||||
getValueInSats(amount) {
|
||||
if (!this.exchangeRate) return 0
|
||||
return Math.ceil((amount / this.exchangeRate) * 1e8)
|
||||
},
|
||||
addToCart(item) {
|
||||
let prod = this.cart.products
|
||||
if (prod.has(item.id)) {
|
||||
let qty = prod.get(item.id).quantity
|
||||
prod.set(item.id, {
|
||||
...prod.get(item.id),
|
||||
quantity: qty + 1
|
||||
})
|
||||
} else {
|
||||
prod.set(item.id, {
|
||||
name: item.product,
|
||||
quantity: 1,
|
||||
price: item.price
|
||||
})
|
||||
}
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message: `${item.product} added to cart`,
|
||||
icon: 'thumb_up'
|
||||
})
|
||||
this.cart.products = prod
|
||||
this.updateCart(+item.price)
|
||||
},
|
||||
removeFromCart(item) {
|
||||
this.cart.products.delete(item.id)
|
||||
this.updateCart(+item.price, true)
|
||||
},
|
||||
updateCart(price, del = false) {
|
||||
console.log(this.cart, this.cartMenu)
|
||||
if (del) {
|
||||
this.cart.total -= price
|
||||
this.cart.size--
|
||||
} else {
|
||||
this.cart.total += price
|
||||
this.cart.size++
|
||||
}
|
||||
this.cartMenu = Array.from(this.cart.products, item => {
|
||||
return {id: item[0], ...item[1]}
|
||||
})
|
||||
console.log(this.cart, this.cartMenu)
|
||||
},
|
||||
getPubkey() {
|
||||
let data = this.$q.localStorage.getItem(`lnbits.market.data`)
|
||||
if (data && data.keys.publickey) {
|
||||
this.checkoutDialog.data.pubkey = data.keys.publickey
|
||||
} else {
|
||||
this.$q.notify({
|
||||
type: 'warning',
|
||||
message: 'No public key stored!',
|
||||
icon: 'settings_backup_restore'
|
||||
})
|
||||
}
|
||||
},
|
||||
placeOrder() {
|
||||
let dialog = this.checkoutDialog.data
|
||||
let data = {
|
||||
...this.checkoutDialog.data,
|
||||
wallet: this.stall.wallet,
|
||||
total:
|
||||
this.unit != 'sat'
|
||||
? this.getValueInSats(this.finalCost)
|
||||
: this.finalCost, // maybe this is better made in Python to allow API ordering?!
|
||||
products: Array.from(this.cart.products, p => {
|
||||
return {product_id: p[0], quantity: p[1].quantity}
|
||||
})
|
||||
}
|
||||
LNbits.api
|
||||
.request('POST', '/market/api/v1/orders', null, data)
|
||||
.then(res => {
|
||||
this.checkoutDialog = {show: false, data: {}}
|
||||
|
||||
return res.data
|
||||
})
|
||||
.then(data => {
|
||||
this.qrCodeDialog.data = data
|
||||
this.qrCodeDialog.show = true
|
||||
|
||||
this.qrCodeDialog.dismissMsg = this.$q.notify({
|
||||
timeout: 0,
|
||||
message: 'Waiting for payment...'
|
||||
})
|
||||
return data
|
||||
})
|
||||
.then(data => {
|
||||
this.qrCodeDialog.paymentChecker = setInterval(() => {
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
`/market/api/v1/orders/payments/${this.qrCodeDialog.data.payment_hash}`
|
||||
)
|
||||
.then(res => {
|
||||
if (res.data.paid) {
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
multiLine: true,
|
||||
message:
|
||||
"Sats received, thanks! You'l be redirected to the order page...",
|
||||
icon: 'thumb_up',
|
||||
actions: [
|
||||
{
|
||||
label: 'See Order',
|
||||
handler: () => {
|
||||
window.location.href = `/market/order/?merch=${this.stall.id}&invoice_id=${this.qrCodeDialog.data.payment_hash}`
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
clearInterval(this.qrCodeDialog.paymentChecker)
|
||||
this.resetCart()
|
||||
this.closeQrCodeDialog()
|
||||
setTimeout(() => {
|
||||
window.location.href = `/market/order/?merch=${this.stall.id}&invoice_id=${this.qrCodeDialog.data.payment_hash}`
|
||||
}, 5000)
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(error)
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
}, 3000)
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(error)
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
}
|
||||
},
|
||||
async created() {
|
||||
this.stall = JSON.parse('{{ stall | tojson }}')
|
||||
this.products = JSON.parse('{{ products | tojson }}')
|
||||
this.unit = this.stall.currency
|
||||
if (this.unit != 'sat') {
|
||||
this.products = this.products.map(mapProductsItems)
|
||||
}
|
||||
await this.getRates()
|
||||
setInterval(this.getRates, 300000)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
177
lnbits/extensions/market/views.py
Normal file
177
lnbits/extensions/market/views.py
Normal file
|
@ -0,0 +1,177 @@
|
|||
import json
|
||||
from http import HTTPStatus
|
||||
from typing import List
|
||||
|
||||
from fastapi import (
|
||||
BackgroundTasks,
|
||||
Depends,
|
||||
Query,
|
||||
Request,
|
||||
WebSocket,
|
||||
WebSocketDisconnect,
|
||||
)
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from loguru import logger
|
||||
from starlette.exceptions import HTTPException
|
||||
from starlette.responses import HTMLResponse
|
||||
|
||||
from lnbits.core.models import User
|
||||
from lnbits.decorators import check_user_exists # type: ignore
|
||||
from lnbits.extensions.market import market_ext, market_renderer
|
||||
from lnbits.extensions.market.models import CreateChatMessage, SetSettings
|
||||
from lnbits.extensions.market.notifier import Notifier
|
||||
|
||||
from .crud import (
|
||||
create_chat_message,
|
||||
create_market_settings,
|
||||
get_market_market,
|
||||
get_market_market_stalls,
|
||||
get_market_order_details,
|
||||
get_market_order_invoiceid,
|
||||
get_market_products,
|
||||
get_market_settings,
|
||||
get_market_stall,
|
||||
get_market_zone,
|
||||
get_market_zones,
|
||||
update_market_product_stock,
|
||||
)
|
||||
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
@market_ext.get("/", response_class=HTMLResponse)
|
||||
async def index(request: Request, user: User = Depends(check_user_exists)):
|
||||
settings = await get_market_settings(user=user.id)
|
||||
|
||||
if not settings:
|
||||
await create_market_settings(
|
||||
user=user.id, data=SetSettings(currency="sat", fiat_base_multiplier=1)
|
||||
)
|
||||
settings = await get_market_settings(user.id)
|
||||
assert settings
|
||||
return market_renderer().TemplateResponse(
|
||||
"market/index.html",
|
||||
{"request": request, "user": user.dict(), "currency": settings.currency},
|
||||
)
|
||||
|
||||
|
||||
@market_ext.get("/stalls/{stall_id}", response_class=HTMLResponse)
|
||||
async def stall(request: Request, stall_id):
|
||||
stall = await get_market_stall(stall_id)
|
||||
products = await get_market_products(stall_id)
|
||||
|
||||
if not stall:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Stall does not exist."
|
||||
)
|
||||
|
||||
zones = []
|
||||
for id in stall.shippingzones.split(","):
|
||||
zone = await get_market_zone(id)
|
||||
assert zone
|
||||
z = zone.dict()
|
||||
zones.append({"label": z["countries"], "cost": z["cost"], "value": z["id"]})
|
||||
|
||||
_stall = stall.dict()
|
||||
|
||||
_stall["zones"] = zones
|
||||
|
||||
return market_renderer().TemplateResponse(
|
||||
"market/stall.html",
|
||||
{
|
||||
"request": request,
|
||||
"stall": _stall,
|
||||
"products": [product.dict() for product in products],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@market_ext.get("/market/{market_id}", response_class=HTMLResponse)
|
||||
async def market(request: Request, market_id):
|
||||
market = await get_market_market(market_id)
|
||||
|
||||
if not market:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Marketplace doesn't exist."
|
||||
)
|
||||
|
||||
stalls = await get_market_market_stalls(market_id)
|
||||
stalls_ids = [stall.id for stall in stalls]
|
||||
products = [product.dict() for product in await get_market_products(stalls_ids)]
|
||||
|
||||
return market_renderer().TemplateResponse(
|
||||
"market/market.html",
|
||||
{
|
||||
"request": request,
|
||||
"market": market,
|
||||
"stalls": [stall.dict() for stall in stalls],
|
||||
"products": products,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@market_ext.get("/order", response_class=HTMLResponse)
|
||||
async def order_chat(
|
||||
request: Request,
|
||||
merch: str = Query(...),
|
||||
invoice_id: str = Query(...),
|
||||
keys: str = Query(None),
|
||||
):
|
||||
stall = await get_market_stall(merch)
|
||||
assert stall
|
||||
order = await get_market_order_invoiceid(invoice_id)
|
||||
assert order
|
||||
_order = await get_market_order_details(order.id)
|
||||
products = await get_market_products(stall.id)
|
||||
assert products
|
||||
|
||||
return market_renderer().TemplateResponse(
|
||||
"market/order.html",
|
||||
{
|
||||
"request": request,
|
||||
"stall": {
|
||||
"id": stall.id,
|
||||
"name": stall.name,
|
||||
"publickey": stall.publickey,
|
||||
"wallet": stall.wallet,
|
||||
"currency": stall.currency,
|
||||
},
|
||||
"publickey": keys.split(",")[0] if keys else None,
|
||||
"privatekey": keys.split(",")[1] if keys else None,
|
||||
"order_id": order.invoiceid,
|
||||
"order": [details.dict() for details in _order],
|
||||
"products": [product.dict() for product in products],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
##################WEBSOCKET ROUTES########################
|
||||
|
||||
# Initialize Notifier:
|
||||
notifier = Notifier()
|
||||
|
||||
|
||||
@market_ext.websocket("/ws/{room_name}")
|
||||
async def websocket_endpoint(
|
||||
websocket: WebSocket, room_name: str, background_tasks: BackgroundTasks
|
||||
):
|
||||
await notifier.connect(websocket, room_name)
|
||||
try:
|
||||
while True:
|
||||
data = await websocket.receive_text()
|
||||
d = json.loads(data)
|
||||
d["room_name"] = room_name
|
||||
|
||||
room_members = (
|
||||
notifier.get_members(room_name)
|
||||
if notifier.get_members(room_name) is not None
|
||||
else []
|
||||
)
|
||||
|
||||
if websocket not in room_members:
|
||||
print("Sender not in room member: Reconnecting...")
|
||||
await notifier.connect(websocket, room_name)
|
||||
await notifier._notify(data, room_name)
|
||||
|
||||
except WebSocketDisconnect:
|
||||
notifier.remove(websocket, room_name)
|
518
lnbits/extensions/market/views_api.py
Normal file
518
lnbits/extensions/market/views_api.py
Normal file
|
@ -0,0 +1,518 @@
|
|||
from base64 import urlsafe_b64encode
|
||||
from http import HTTPStatus
|
||||
from typing import List, Union
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import Body, Depends, Query, Request
|
||||
from loguru import logger
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
from lnbits.core.crud import get_user
|
||||
from lnbits.core.services import create_invoice
|
||||
from lnbits.core.views.api import api_payment
|
||||
from lnbits.decorators import (
|
||||
WalletTypeInfo,
|
||||
get_key_type,
|
||||
require_admin_key,
|
||||
require_invoice_key,
|
||||
)
|
||||
from lnbits.helpers import urlsafe_short_hash
|
||||
from lnbits.utils.exchange_rates import currencies, get_fiat_rate_satoshis
|
||||
|
||||
from . import db, market_ext
|
||||
from .crud import (
|
||||
create_market_market,
|
||||
create_market_market_stalls,
|
||||
create_market_order,
|
||||
create_market_order_details,
|
||||
create_market_product,
|
||||
create_market_settings,
|
||||
create_market_stall,
|
||||
create_market_zone,
|
||||
delete_market_order,
|
||||
delete_market_product,
|
||||
delete_market_stall,
|
||||
delete_market_zone,
|
||||
get_market_chat_by_merchant,
|
||||
get_market_chat_messages,
|
||||
get_market_latest_chat_messages,
|
||||
get_market_market,
|
||||
get_market_market_stalls,
|
||||
get_market_markets,
|
||||
get_market_order,
|
||||
get_market_order_details,
|
||||
get_market_order_invoiceid,
|
||||
get_market_orders,
|
||||
get_market_product,
|
||||
get_market_products,
|
||||
get_market_settings,
|
||||
get_market_stall,
|
||||
get_market_stalls,
|
||||
get_market_stalls_by_ids,
|
||||
get_market_zone,
|
||||
get_market_zones,
|
||||
set_market_order_pubkey,
|
||||
set_market_settings,
|
||||
update_market_market,
|
||||
update_market_product,
|
||||
update_market_stall,
|
||||
update_market_zone,
|
||||
)
|
||||
from .models import (
|
||||
CreateMarket,
|
||||
CreateMarketStalls,
|
||||
Orders,
|
||||
Products,
|
||||
SetSettings,
|
||||
Stalls,
|
||||
Zones,
|
||||
createOrder,
|
||||
createProduct,
|
||||
createStalls,
|
||||
createZones,
|
||||
)
|
||||
|
||||
# from lnbits.db import open_ext_db
|
||||
|
||||
|
||||
### Products
|
||||
@market_ext.get("/api/v1/products")
|
||||
async def api_market_products(
|
||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
||||
all_stalls: bool = Query(False),
|
||||
):
|
||||
wallet_ids = [wallet.wallet.id]
|
||||
|
||||
if all_stalls:
|
||||
user = await get_user(wallet.wallet.user)
|
||||
wallet_ids = user.wallet_ids if user else []
|
||||
|
||||
stalls = [stall.id for stall in await get_market_stalls(wallet_ids)]
|
||||
|
||||
if not stalls:
|
||||
return
|
||||
|
||||
return [product.dict() for product in await get_market_products(stalls)]
|
||||
|
||||
|
||||
@market_ext.post("/api/v1/products")
|
||||
@market_ext.put("/api/v1/products/{product_id}")
|
||||
async def api_market_product_create(
|
||||
data: createProduct,
|
||||
product_id=None,
|
||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
||||
):
|
||||
# For fiat currencies,
|
||||
# we multiply by data.fiat_base_multiplier (usually 100) to save the value in cents.
|
||||
settings = await get_market_settings(user=wallet.wallet.user)
|
||||
assert settings
|
||||
|
||||
stall = await get_market_stall(stall_id=data.stall)
|
||||
assert stall
|
||||
|
||||
if stall.currency != "sat":
|
||||
data.price *= settings.fiat_base_multiplier
|
||||
|
||||
if product_id:
|
||||
product = await get_market_product(product_id)
|
||||
if not product:
|
||||
return {"message": "Product does not exist."}
|
||||
|
||||
# stall = await get_market_stall(stall_id=product.stall)
|
||||
if stall.wallet != wallet.wallet.id:
|
||||
return {"message": "Not your product."}
|
||||
|
||||
product = await update_market_product(product_id, **data.dict())
|
||||
else:
|
||||
product = await create_market_product(data=data)
|
||||
assert product
|
||||
return product.dict()
|
||||
|
||||
|
||||
@market_ext.delete("/api/v1/products/{product_id}")
|
||||
async def api_market_products_delete(
|
||||
product_id, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
):
|
||||
product = await get_market_product(product_id)
|
||||
|
||||
if not product:
|
||||
return {"message": "Product does not exist."}
|
||||
|
||||
stall = await get_market_stall(product.stall)
|
||||
assert stall
|
||||
|
||||
if stall.wallet != wallet.wallet.id:
|
||||
return {"message": "Not your Market."}
|
||||
|
||||
await delete_market_product(product_id)
|
||||
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
|
||||
|
||||
|
||||
# # # Shippingzones
|
||||
|
||||
|
||||
@market_ext.get("/api/v1/zones")
|
||||
async def api_market_zones(wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||
|
||||
return await get_market_zones(wallet.wallet.user)
|
||||
|
||||
|
||||
@market_ext.post("/api/v1/zones")
|
||||
async def api_market_zone_create(
|
||||
data: createZones, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
):
|
||||
zone = await create_market_zone(user=wallet.wallet.user, data=data)
|
||||
return zone.dict()
|
||||
|
||||
|
||||
@market_ext.post("/api/v1/zones/{zone_id}")
|
||||
async def api_market_zone_update(
|
||||
data: createZones,
|
||||
zone_id: str,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
):
|
||||
zone = await get_market_zone(zone_id)
|
||||
if not zone:
|
||||
return {"message": "Zone does not exist."}
|
||||
if zone.user != wallet.wallet.user:
|
||||
return {"message": "Not your record."}
|
||||
zone = await update_market_zone(zone_id, **data.dict())
|
||||
return zone
|
||||
|
||||
|
||||
@market_ext.delete("/api/v1/zones/{zone_id}")
|
||||
async def api_market_zone_delete(
|
||||
zone_id, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
):
|
||||
zone = await get_market_zone(zone_id)
|
||||
|
||||
if not zone:
|
||||
return {"message": "zone does not exist."}
|
||||
|
||||
if zone.user != wallet.wallet.user:
|
||||
return {"message": "Not your zone."}
|
||||
|
||||
await delete_market_zone(zone_id)
|
||||
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
|
||||
|
||||
|
||||
# # # Stalls
|
||||
|
||||
|
||||
@market_ext.get("/api/v1/stalls")
|
||||
async def api_market_stalls(
|
||||
wallet: WalletTypeInfo = Depends(get_key_type), all_wallets: bool = Query(False)
|
||||
):
|
||||
wallet_ids = [wallet.wallet.id]
|
||||
|
||||
if all_wallets:
|
||||
user = await get_user(wallet.wallet.user)
|
||||
wallet_ids = user.wallet_ids if user else []
|
||||
|
||||
return [stall.dict() for stall in await get_market_stalls(wallet_ids)]
|
||||
|
||||
|
||||
@market_ext.post("/api/v1/stalls")
|
||||
@market_ext.put("/api/v1/stalls/{stall_id}")
|
||||
async def api_market_stall_create(
|
||||
data: createStalls,
|
||||
stall_id: str = None,
|
||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
||||
):
|
||||
|
||||
if stall_id:
|
||||
stall = await get_market_stall(stall_id)
|
||||
if not stall:
|
||||
return {"message": "Withdraw stall does not exist."}
|
||||
|
||||
if stall.wallet != wallet.wallet.id:
|
||||
return {"message": "Not your withdraw stall."}
|
||||
|
||||
stall = await update_market_stall(stall_id, **data.dict())
|
||||
else:
|
||||
stall = await create_market_stall(data=data)
|
||||
assert stall
|
||||
return stall.dict()
|
||||
|
||||
|
||||
@market_ext.delete("/api/v1/stalls/{stall_id}")
|
||||
async def api_market_stall_delete(
|
||||
stall_id: str, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
):
|
||||
stall = await get_market_stall(stall_id)
|
||||
|
||||
if not stall:
|
||||
return {"message": "Stall does not exist."}
|
||||
|
||||
if stall.wallet != wallet.wallet.id:
|
||||
return {"message": "Not your Stall."}
|
||||
|
||||
await delete_market_stall(stall_id)
|
||||
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
|
||||
|
||||
|
||||
###Orders
|
||||
|
||||
|
||||
@market_ext.get("/api/v1/orders")
|
||||
async def api_market_orders(
|
||||
wallet: WalletTypeInfo = Depends(get_key_type), all_wallets: bool = Query(False)
|
||||
):
|
||||
wallet_ids = [wallet.wallet.id]
|
||||
if all_wallets:
|
||||
user = await get_user(wallet.wallet.user)
|
||||
wallet_ids = user.wallet_ids if user else []
|
||||
|
||||
orders = await get_market_orders(wallet_ids)
|
||||
if not orders:
|
||||
return
|
||||
orders_with_details = []
|
||||
for order in orders:
|
||||
_order = order.dict()
|
||||
_order["details"] = await get_market_order_details(_order["id"])
|
||||
orders_with_details.append(_order)
|
||||
try:
|
||||
return orders_with_details # [order for order in orders]
|
||||
# return [order.dict() for order in await get_market_orders(wallet_ids)]
|
||||
except:
|
||||
return {"message": "We could not retrieve the orders."}
|
||||
|
||||
|
||||
@market_ext.get("/api/v1/orders/{order_id}")
|
||||
async def api_market_order_by_id(order_id: str):
|
||||
order = await get_market_order(order_id)
|
||||
assert order
|
||||
_order = order.dict()
|
||||
_order["details"] = await get_market_order_details(order_id)
|
||||
|
||||
return _order
|
||||
|
||||
|
||||
@market_ext.post("/api/v1/orders")
|
||||
async def api_market_order_create(data: createOrder):
|
||||
ref = urlsafe_short_hash()
|
||||
|
||||
payment_hash, payment_request = await create_invoice(
|
||||
wallet_id=data.wallet,
|
||||
amount=data.total,
|
||||
memo=f"New order on Market",
|
||||
extra={
|
||||
"tag": "market",
|
||||
"reference": ref,
|
||||
},
|
||||
)
|
||||
order_id = await create_market_order(invoiceid=payment_hash, data=data)
|
||||
logger.debug(f"ORDER ID {order_id}")
|
||||
logger.debug(f"PRODUCTS {data.products}")
|
||||
await create_market_order_details(order_id=order_id, data=data.products)
|
||||
return {
|
||||
"payment_hash": payment_hash,
|
||||
"payment_request": payment_request,
|
||||
"order_reference": ref,
|
||||
}
|
||||
|
||||
|
||||
@market_ext.get("/api/v1/orders/payments/{payment_hash}")
|
||||
async def api_market_check_payment(payment_hash: str):
|
||||
order = await get_market_order_invoiceid(payment_hash)
|
||||
if not order:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Order does not exist."
|
||||
)
|
||||
try:
|
||||
status = await api_payment(payment_hash)
|
||||
|
||||
except Exception as exc:
|
||||
logger.error(exc)
|
||||
return {"paid": False}
|
||||
return status
|
||||
|
||||
|
||||
@market_ext.delete("/api/v1/orders/{order_id}")
|
||||
async def api_market_order_delete(
|
||||
order_id: str, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
):
|
||||
order = await get_market_order(order_id)
|
||||
|
||||
if not order:
|
||||
return {"message": "Order does not exist."}
|
||||
|
||||
if order.wallet != wallet.wallet.id:
|
||||
return {"message": "Not your Order."}
|
||||
|
||||
await delete_market_order(order_id)
|
||||
|
||||
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
|
||||
|
||||
|
||||
# @market_ext.get("/api/v1/orders/paid/{order_id}")
|
||||
# async def api_market_order_paid(
|
||||
# order_id, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
# ):
|
||||
# await db.execute(
|
||||
# "UPDATE market.orders SET paid = ? WHERE id = ?",
|
||||
# (
|
||||
# True,
|
||||
# order_id,
|
||||
# ),
|
||||
# )
|
||||
# return "", HTTPStatus.OK
|
||||
|
||||
|
||||
@market_ext.get("/api/v1/order/pubkey/{payment_hash}/{pubkey}")
|
||||
async def api_market_order_pubkey(payment_hash: str, pubkey: str):
|
||||
await set_market_order_pubkey(payment_hash, pubkey)
|
||||
return "", HTTPStatus.OK
|
||||
|
||||
|
||||
@market_ext.get("/api/v1/orders/shipped/{order_id}")
|
||||
async def api_market_order_shipped(
|
||||
order_id, shipped: bool = Query(...), wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
):
|
||||
await db.execute(
|
||||
"UPDATE market.orders SET shipped = ? WHERE id = ?",
|
||||
(
|
||||
shipped,
|
||||
order_id,
|
||||
),
|
||||
)
|
||||
order = await db.fetchone("SELECT * FROM market.orders WHERE id = ?", (order_id,))
|
||||
|
||||
return order
|
||||
|
||||
|
||||
###List products based on stall id
|
||||
|
||||
|
||||
# @market_ext.get("/api/v1/stall/products/{stall_id}")
|
||||
# async def api_market_stall_products(
|
||||
# stall_id, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
# ):
|
||||
|
||||
# rows = await db.fetchone("SELECT * FROM market.stalls WHERE id = ?", (stall_id,))
|
||||
# if not rows:
|
||||
# return {"message": "Stall does not exist."}
|
||||
|
||||
# products = db.fetchone("SELECT * FROM market.products WHERE wallet = ?", (rows[1],))
|
||||
# if not products:
|
||||
# return {"message": "No products"}
|
||||
|
||||
# return [products.dict() for products in await get_market_products(rows[1])]
|
||||
|
||||
|
||||
###Check a product has been shipped
|
||||
|
||||
|
||||
# @market_ext.get("/api/v1/stall/checkshipped/{checking_id}")
|
||||
# async def api_market_stall_checkshipped(
|
||||
# checking_id, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
# ):
|
||||
# rows = await db.fetchone(
|
||||
# "SELECT * FROM market.orders WHERE invoiceid = ?", (checking_id,)
|
||||
# )
|
||||
# return {"shipped": rows["shipped"]}
|
||||
|
||||
|
||||
##
|
||||
# MARKETS
|
||||
##
|
||||
|
||||
|
||||
@market_ext.get("/api/v1/markets")
|
||||
async def api_market_markets(wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||
# await get_market_market_stalls(market_id="FzpWnMyHQMcRppiGVua4eY")
|
||||
try:
|
||||
return [
|
||||
market.dict() for market in await get_market_markets(wallet.wallet.user)
|
||||
]
|
||||
except:
|
||||
return {"message": "We could not retrieve the markets."}
|
||||
|
||||
|
||||
@market_ext.get("/api/v1/markets/{market_id}/stalls")
|
||||
async def api_market_market_stalls(market_id: str):
|
||||
stall_ids = await get_market_market_stalls(market_id)
|
||||
return stall_ids
|
||||
|
||||
|
||||
@market_ext.post("/api/v1/markets")
|
||||
@market_ext.put("/api/v1/markets/{market_id}")
|
||||
async def api_market_market_create(
|
||||
data: CreateMarket,
|
||||
market_id: str = None,
|
||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
||||
):
|
||||
if market_id:
|
||||
market = await get_market_market(market_id)
|
||||
if not market:
|
||||
return {"message": "Market does not exist."}
|
||||
|
||||
if market.usr != wallet.wallet.user:
|
||||
return {"message": "Not your market."}
|
||||
|
||||
market = await update_market_market(market_id, data.name)
|
||||
else:
|
||||
market = await create_market_market(data=data)
|
||||
|
||||
assert market
|
||||
await create_market_market_stalls(market_id=market.id, data=data.stalls)
|
||||
|
||||
return market.dict()
|
||||
|
||||
|
||||
## MESSAGES/CHAT
|
||||
|
||||
|
||||
@market_ext.get("/api/v1/chat/messages/merchant")
|
||||
async def api_get_merchant_messages(
|
||||
orders: str = Query(...), wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
):
|
||||
return [msg.dict() for msg in await get_market_chat_by_merchant(orders.split(","))]
|
||||
|
||||
|
||||
@market_ext.get("/api/v1/chat/messages/{room_name}")
|
||||
async def api_get_latest_chat_msg(room_name: str, all_messages: bool = Query(False)):
|
||||
if all_messages:
|
||||
messages = await get_market_chat_messages(room_name)
|
||||
else:
|
||||
messages = await get_market_latest_chat_messages(room_name)
|
||||
|
||||
return messages
|
||||
|
||||
|
||||
@market_ext.get("/api/v1/currencies")
|
||||
async def api_list_currencies_available():
|
||||
return list(currencies.keys())
|
||||
|
||||
|
||||
@market_ext.get("/api/v1/settings")
|
||||
async def api_get_settings(wallet: WalletTypeInfo = Depends(require_admin_key)):
|
||||
user = wallet.wallet.user
|
||||
|
||||
settings = await get_market_settings(user)
|
||||
|
||||
return settings
|
||||
|
||||
|
||||
@market_ext.post("/api/v1/settings")
|
||||
@market_ext.put("/api/v1/settings/{usr}")
|
||||
async def api_set_settings(
|
||||
data: SetSettings,
|
||||
usr: str = None,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
):
|
||||
if usr:
|
||||
if usr != wallet.wallet.user:
|
||||
return {"message": "Not your Market."}
|
||||
|
||||
settings = await get_market_settings(user=usr)
|
||||
assert settings
|
||||
|
||||
if settings.user != wallet.wallet.user:
|
||||
return {"message": "Not your Market."}
|
||||
|
||||
return await set_market_settings(usr, data)
|
||||
|
||||
user = wallet.wallet.user
|
||||
|
||||
return await create_market_settings(user, data)
|
Loading…
Reference in New Issue
Block a user