Merge pull request #908 from lnbits/diagon-alley

WIP: Diagon alley Extension
This commit is contained in:
Arc 2023-01-04 21:25:09 +00:00 committed by GitHub
commit 1c6fc8a178
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 5391 additions and 0 deletions

View 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>

View 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))

View File

@ -0,0 +1,6 @@
{
"name": "Marketplace",
"short_description": "Webshop/market on LNbits",
"tile": "/market/static/images/bitcoin-shop.png",
"contributors": ["benarc", "talvasconcelos"]
}

View 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,
),
)

View 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)"
)

View 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(...)

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

View 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)

View 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/&lt;relay_id&gt;</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/&lt;relay_id&gt;</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/&lt;relay_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<code
>{"id": &lt;string&gt;, "address": &lt;string&gt;, "shippingzone":
&lt;integer&gt;, "email": &lt;string&gt;, "quantity":
&lt;integer&gt;}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 201 CREATED (application/json)
</h5>
<code
>{"checking_id": &lt;string&gt;,"payment_request":
&lt;string&gt;}</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/&lt;relay_id&gt; -d '{"id": &lt;product_id&&gt;,
"email": &lt;customer_email&gt;, "address": &lt;customer_address&gt;,
"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/&lt;checking_id&gt;</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": &lt;boolean&gt;}</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/&lt;checking_id&gt; -H "Content-type:
application/json"</code
>
</q-card-section>
</q-card>
</q-expansion-item>
</q-expansion-item>

View 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>

View 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>

View 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>

File diff suppressed because it is too large Load Diff

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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)

View 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)