diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1e04acc --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,10 @@ +name: CI +on: + push: + branches: + - main + pull_request: + +jobs: + lint: + uses: lnbits/lnbits/.github/workflows/lint.yml@dev diff --git a/.gitignore b/.gitignore index 10a11d5..f3a8853 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ __pycache__ htmlcov test-reports tests/data/*.sqlite3 +node_modules *.swo *.swp diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..931dc7f --- /dev/null +++ b/.prettierignore @@ -0,0 +1,14 @@ +**/.git +**/.svn +**/.hg +**/node_modules + +*.yml + +**/static/market/* +**/static/js/nostr.bundle.js* + + +flake.lock + +.venv diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..725c398 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,12 @@ +{ + "semi": false, + "arrowParens": "avoid", + "insertPragma": false, + "printWidth": 80, + "proseWrap": "preserve", + "singleQuote": true, + "trailingComma": "none", + "useTabs": false, + "bracketSameLine": false, + "bracketSpacing": false +} diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..0fac253 --- /dev/null +++ b/Makefile @@ -0,0 +1,47 @@ +all: format check + +format: prettier black ruff + +check: mypy pyright checkblack checkruff checkprettier + +prettier: + uv run ./node_modules/.bin/prettier --write . +pyright: + uv run ./node_modules/.bin/pyright + +mypy: + uv run mypy . + +black: + uv run black . + +ruff: + uv run ruff check . --fix + +checkruff: + uv run ruff check . + +checkprettier: + uv run ./node_modules/.bin/prettier --check . + +checkblack: + uv run black --check . + +checkeditorconfig: + editorconfig-checker + +test: + PYTHONUNBUFFERED=1 \ + DEBUG=true \ + uv run pytest +install-pre-commit-hook: + @echo "Installing pre-commit hook to git" + @echo "Uninstall the hook with uv run pre-commit uninstall" + uv run pre-commit install + +pre-commit: + uv run pre-commit run --all-files + + +checkbundle: + @echo "skipping checkbundle" diff --git a/README.md b/README.md index 9e3f157..1839351 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Nostr Market ([NIP-15](https://github.com/nostr-protocol/nips/blob/master/15.md)) - [LNbits](https://github.com/lnbits/lnbits) extension -For more about LNBits extension check [this tutorial](https://github.com/lnbits/lnbits/wiki/LNbits-Extensions). +For more about LNBits extension check [this tutorial](https://github.com/lnbits/lnbits/wiki/LNbits-Extensions). **Demo at Nostrica here**. @@ -8,15 +8,15 @@ > The concepts around resilience in Diagon Alley helped influence the creation of the NOSTR protocol, now we get to build Diagon Alley on NOSTR! - ## Prerequisites + This extension uses the LNbits [nostrclient](https://github.com/lnbits/nostrclient) extension, an extension that makes _nostrfying_ other extensions easy.  + - before you continue, please make sure that [nostrclient](https://github.com/lnbits/nostrclient) extension is installed, activated and correctly configured. - [nostrclient](https://github.com/lnbits/nostrclient) is usually installed as admin-only extension, so if you do not have admin access please ask an admin to confirm that [nostrclient](https://github.com/lnbits/nostrclient) is OK. - see the [Troubleshoot](https://github.com/lnbits/nostrclient#troubleshoot) section for more details on how to check the health of `nostrclient` extension - ## Create, or import, a merchant account As a merchant you need to provide a Nostr key pair, or the extension can generate one for you. @@ -97,35 +97,39 @@ Make sure to add your `merchant` public key to the list:  ### Styling + In order to create a customized Marketplace, we use `naddr` as defined in [NIP-19](https://github.com/nostr-protocol/nips/blob/master/19.md#shareable-identifiers-with-extra-metadata). You must create an event (kind: `30019`) that has all the custom properties, including merchants and relays, of your marketplace. Start by going to the marketplace page:  -You'll need to Login, and head over to *Marketplace Info*. Optionally import some merchants and relays, that will be included in the event. Click on *Edit* and fill out your marketplace custom info: +You'll need to Login, and head over to _Marketplace Info_. Optionally import some merchants and relays, that will be included in the event. Click on _Edit_ and fill out your marketplace custom info:  Fill in the optional fields: + - Add a name to the Marketplace - Add a small description - Add a logo image URL - Add a banner image URL (max height: 250px) -- Choose a theme +- Choose a theme -By clicking *Publish*, a `kind: 30019` event will be sent to the defined relays containing all the information about your custom Marketplace. On the left drawer, a button with *Copy Naddr* will show up. +By clicking _Publish_, a `kind: 30019` event will be sent to the defined relays containing all the information about your custom Marketplace. On the left drawer, a button with _Copy Naddr_ will show up.  -You can then share your Marketplace, with the merchants and relays, banner, and style by using that Nostr identifier. The URL for the marketplace will be for example: `https://legend.lnbits.com/nostrmarket/market?naddr=naddr1qqfy6ctjddjhgurvv93k....`, you need to include the URL parameter `naddr=`. When a user visits that URL, the client will get the `30019` event and configure the Marketplace to what you defined. In the example bellow, a couple of merchants, relays, `autumn` theme, name (*Veggies Market*) and a header banner: +You can then share your Marketplace, with the merchants and relays, banner, and style by using that Nostr identifier. The URL for the marketplace will be for example: `https://legend.lnbits.com/nostrmarket/market?naddr=naddr1qqfy6ctjddjhgurvv93k....`, you need to include the URL parameter `naddr=`. When a user visits that URL, the client will get the `30019` event and configure the Marketplace to what you defined. In the example bellow, a couple of merchants, relays, `autumn` theme, name (_Veggies Market_) and a header banner:  The nostr event is a replaceable event, so you can change it to what you like and publish a new one to replace a previous one. For example adding a new merchant, or remove, change theme, add more relays,e tc... - ## Troubleshoot + ### Check communication with Nostr + In order to test that the integration with Nostr is working fine, one can add an `npub` to the chat box and check that DMs are working as expected: https://user-images.githubusercontent.com/2951406/236777983-259f81d8-136f-48b3-bb73-80749819b5f9.mov ### Restart connection to Nostr + If the communication with Nostr is not working then an admin user can `Restart` the Nostr connection. Merchants can afterwards re-publish their products. @@ -133,8 +137,8 @@ Merchants can afterwards re-publish their products. https://user-images.githubusercontent.com/2951406/236778651-7ada9f6d-07a1-491c-ac9c-55530326c32a.mp4 ### Check Nostrclient extension -- see the [Troubleshoot](https://github.com/lnbits/nostrclient#troubleshoot) section for more details on how to check the health of `nostrclient` extension +- see the [Troubleshoot](https://github.com/lnbits/nostrclient#troubleshoot) section for more details on how to check the health of `nostrclient` extension ## Aditional info diff --git a/__init__.py b/__init__.py index 5e1e46d..921c383 100644 --- a/__init__.py +++ b/__init__.py @@ -1,12 +1,12 @@ import asyncio -from asyncio import Task -from typing import List from fastapi import APIRouter - from lnbits.db import Database from lnbits.helpers import template_renderer -from lnbits.tasks import catch_everything_and_restart +from lnbits.tasks import create_permanent_unique_task +from loguru import logger + +from .nostr.nostr_client import NostrClient db = Database("ext_nostrmarket") @@ -24,19 +24,28 @@ def nostrmarket_renderer(): return template_renderer(["nostrmarket/templates"]) -from .nostr.nostr_client import NostrClient - -nostr_client = NostrClient() - -scheduled_tasks: List[Task] = [] +nostr_client: NostrClient = NostrClient() -from .tasks import wait_for_nostr_events, wait_for_paid_invoices +from .tasks import wait_for_nostr_events, wait_for_paid_invoices # noqa from .views import * # noqa from .views_api import * # noqa +scheduled_tasks: list[asyncio.Task] = [] + + +async def nostrmarket_stop(): + for task in scheduled_tasks: + try: + task.cancel() + except Exception as ex: + logger.warning(ex) + + await nostr_client.stop() + def nostrmarket_start(): + async def _subscribe_to_nostr_client(): # wait for 'nostrclient' extension to initialize await asyncio.sleep(10) @@ -47,8 +56,13 @@ def nostrmarket_start(): await asyncio.sleep(15) await wait_for_nostr_events(nostr_client) - loop = asyncio.get_event_loop() - task1 = loop.create_task(catch_everything_and_restart(wait_for_paid_invoices)) - task2 = loop.create_task(catch_everything_and_restart(_subscribe_to_nostr_client)) - task3 = loop.create_task(catch_everything_and_restart(_wait_for_nostr_events)) + task1 = create_permanent_unique_task( + "ext_nostrmarket_paid_invoices", wait_for_paid_invoices + ) + task2 = create_permanent_unique_task( + "ext_nostrmarket_subscribe_to_nostr_client", _subscribe_to_nostr_client + ) + task3 = create_permanent_unique_task( + "ext_nostrmarket_wait_for_events", _wait_for_nostr_events + ) scheduled_tasks.extend([task1, task2, task3]) diff --git a/config.json b/config.json index 01d62e5..fe7456f 100644 --- a/config.json +++ b/config.json @@ -2,6 +2,46 @@ "name": "Nostr Market", "short_description": "Nostr Webshop/market on LNbits", "tile": "/nostrmarket/static/images/bitcoin-shop.png", - "contributors": [], - "min_lnbits_version": "0.12.6" + "min_lnbits_version": "1.0.0", + "contributors": [ + { + "name": "motorina0", + "uri": "https://github.com/motorina0", + "role": "Contributor" + }, + { + "name": "benarc", + "uri": "https://github.com/benarc", + "role": "Developer" + }, + { + "name": "talvasconcelos", + "uri": "https://github.com/talvasconcelos", + "role": "Developer" + } + ], + "images": [ + { + "uri": "https://raw.githubusercontent.com/lnbits/nostrmarket/main/static/images/1.png" + }, + { + "uri": "https://raw.githubusercontent.com/lnbits/nostrmarket/main/static/images/2.png" + }, + { + "uri": "https://raw.githubusercontent.com/lnbits/nostrmarket/main/static/images/3.png" + }, + { + "uri": "https://raw.githubusercontent.com/lnbits/nostrmarket/main/static/images/4.png" + }, + { + "uri": "https://raw.githubusercontent.com/lnbits/nostrmarket/main/static/images/5.png" + }, + { + "uri": "https://raw.githubusercontent.com/lnbits/nostrmarket/main/static/images/6.jpg", + "link": "https://www.youtube.com/embed/t9Z2tEsrNIU?si=rOQvwCUSWhwPPmYW" + } + ], + "description_md": "https://raw.githubusercontent.com/lnbits/nostrmarket/main/description.md", + "terms_and_conditions_md": "https://raw.githubusercontent.com/lnbits/nostrmarket/main/toc.md", + "license": "MIT" } diff --git a/crud.py b/crud.py index 986b7b5..17282d7 100644 --- a/crud.py +++ b/crud.py @@ -1,5 +1,4 @@ import json -from typing import List, Optional from lnbits.helpers import urlsafe_short_hash @@ -13,25 +12,29 @@ from .models import ( Order, PartialDirectMessage, PartialMerchant, - PartialProduct, - PartialStall, - PartialZone, Product, Stall, Zone, ) -######################################## MERCHANT ######################################## +######################################## MERCHANT ###################################### async def create_merchant(user_id: str, m: PartialMerchant) -> Merchant: merchant_id = urlsafe_short_hash() await db.execute( """ - INSERT INTO nostrmarket.merchants (user_id, id, private_key, public_key, meta) - VALUES (?, ?, ?, ?, ?) + INSERT INTO nostrmarket.merchants + (user_id, id, private_key, public_key, meta) + VALUES (:user_id, :id, :private_key, :public_key, :meta) """, - (user_id, merchant_id, m.private_key, m.public_key, json.dumps(dict(m.config))), + { + "user_id": user_id, + "id": merchant_id, + "private_key": m.private_key, + "public_key": m.public_key, + "meta": json.dumps(dict(m.config)), + }, ) merchant = await get_merchant(user_id, merchant_id) assert merchant, "Created merchant cannot be retrieved" @@ -40,61 +43,61 @@ async def create_merchant(user_id: str, m: PartialMerchant) -> Merchant: async def update_merchant( user_id: str, merchant_id: str, config: MerchantConfig -) -> Optional[Merchant]: +) -> Merchant | None: await db.execute( f""" - UPDATE nostrmarket.merchants SET meta = ?, time = {db.timestamp_now} - WHERE id = ? AND user_id = ? + UPDATE nostrmarket.merchants SET meta = :meta, time = {db.timestamp_now} + WHERE id = :id AND user_id = :user_id """, - (json.dumps(config.dict()), merchant_id, user_id), + {"meta": json.dumps(config.dict()), "id": merchant_id, "user_id": user_id}, ) return await get_merchant(user_id, merchant_id) -async def touch_merchant(user_id: str, merchant_id: str) -> Optional[Merchant]: +async def touch_merchant(user_id: str, merchant_id: str) -> Merchant | None: await db.execute( f""" UPDATE nostrmarket.merchants SET time = {db.timestamp_now} - WHERE id = ? AND user_id = ? + WHERE id = :id AND user_id = :user_id """, - (merchant_id, user_id), + {"id": merchant_id, "user_id": user_id}, ) return await get_merchant(user_id, merchant_id) -async def get_merchant(user_id: str, merchant_id: str) -> Optional[Merchant]: - row = await db.fetchone( - """SELECT * FROM nostrmarket.merchants WHERE user_id = ? AND id = ?""", - ( - user_id, - merchant_id, - ), +async def get_merchant(user_id: str, merchant_id: str) -> Merchant | None: + row: dict = await db.fetchone( + """SELECT * FROM nostrmarket.merchants WHERE user_id = :user_id AND id = :id""", + { + "user_id": user_id, + "id": merchant_id, + }, ) return Merchant.from_row(row) if row else None -async def get_merchant_by_pubkey(public_key: str) -> Optional[Merchant]: - row = await db.fetchone( - """SELECT * FROM nostrmarket.merchants WHERE public_key = ? """, - (public_key,), +async def get_merchant_by_pubkey(public_key: str) -> Merchant | None: + row: dict = await db.fetchone( + """SELECT * FROM nostrmarket.merchants WHERE public_key = :public_key""", + {"public_key": public_key}, ) return Merchant.from_row(row) if row else None -async def get_merchants_ids_with_pubkeys() -> List[str]: - rows = await db.fetchall( +async def get_merchants_ids_with_pubkeys() -> list[tuple[str, str]]: + rows: list[dict] = await db.fetchall( """SELECT id, public_key FROM nostrmarket.merchants""", ) - return [(row[0], row[1]) for row in rows] + return [(row["id"], row["public_key"]) for row in rows] -async def get_merchant_for_user(user_id: str) -> Optional[Merchant]: - row = await db.fetchone( - """SELECT * FROM nostrmarket.merchants WHERE user_id = ? """, - (user_id,), +async def get_merchant_for_user(user_id: str) -> Merchant | None: + row: dict = await db.fetchone( + """SELECT * FROM nostrmarket.merchants WHERE user_id = :user_id """, + {"user_id": user_id}, ) return Merchant.from_row(row) if row else None @@ -102,29 +105,31 @@ async def get_merchant_for_user(user_id: str) -> Optional[Merchant]: async def delete_merchant(merchant_id: str) -> None: await db.execute( - "DELETE FROM nostrmarket.merchants WHERE id = ?", - (merchant_id,), + "DELETE FROM nostrmarket.merchants WHERE id = :id", + { + "id": merchant_id, + }, ) ######################################## ZONES ######################################## -async def create_zone(merchant_id: str, data: PartialZone) -> Zone: +async def create_zone(merchant_id: str, data: Zone) -> Zone: zone_id = data.id or urlsafe_short_hash() await db.execute( - f""" + """ INSERT INTO nostrmarket.zones (id, merchant_id, name, currency, cost, regions) - VALUES (?, ?, ?, ?, ?, ?) + VALUES (:id, :merchant_id, :name, :currency, :cost, :regions) """, - ( - zone_id, - merchant_id, - data.name, - data.currency, - data.cost, - json.dumps(data.countries), - ), + { + "id": zone_id, + "merchant_id": merchant_id, + "name": data.name, + "currency": data.currency, + "cost": data.cost, + "regions": json.dumps(data.countries), + }, ) zone = await get_zone(merchant_id, zone_id) @@ -132,28 +137,40 @@ async def create_zone(merchant_id: str, data: PartialZone) -> Zone: return zone -async def update_zone(merchant_id: str, z: Zone) -> Optional[Zone]: +async def update_zone(merchant_id: str, z: Zone) -> Zone | None: await db.execute( - f"UPDATE nostrmarket.zones SET name = ?, cost = ?, regions = ? WHERE id = ? AND merchant_id = ?", - (z.name, z.cost, json.dumps(z.countries), z.id, merchant_id), + """ + UPDATE nostrmarket.zones + SET name = :name, cost = :cost, regions = :regions + WHERE id = :id AND merchant_id = :merchant_id + """, + { + "name": z.name, + "cost": z.cost, + "regions": json.dumps(z.countries), + "id": z.id, + "merchant_id": merchant_id, + }, ) + assert z.id return await get_zone(merchant_id, z.id) -async def get_zone(merchant_id: str, zone_id: str) -> Optional[Zone]: - row = await db.fetchone( - "SELECT * FROM nostrmarket.zones WHERE merchant_id = ? AND id = ?", - ( - merchant_id, - zone_id, - ), +async def get_zone(merchant_id: str, zone_id: str) -> Zone | None: + row: dict = await db.fetchone( + "SELECT * FROM nostrmarket.zones WHERE merchant_id = :merchant_id AND id = :id", + { + "merchant_id": merchant_id, + "id": zone_id, + }, ) return Zone.from_row(row) if row else None -async def get_zones(merchant_id: str) -> List[Zone]: - rows = await db.fetchall( - "SELECT * FROM nostrmarket.zones WHERE merchant_id = ?", (merchant_id,) +async def get_zones(merchant_id: str) -> list[Zone]: + rows: list[dict] = await db.fetchall( + "SELECT * FROM nostrmarket.zones WHERE merchant_id = :merchant_id", + {"merchant_id": merchant_id}, ) return [Zone.from_row(row) for row in rows] @@ -161,47 +178,55 @@ async def get_zones(merchant_id: str) -> List[Zone]: async def delete_zone(merchant_id: str, zone_id: str) -> None: await db.execute( - "DELETE FROM nostrmarket.zones WHERE merchant_id = ? AND id = ?", - ( - merchant_id, - zone_id, - ), + "DELETE FROM nostrmarket.zones WHERE merchant_id = :merchant_id AND id = :id", + { + "merchant_id": merchant_id, + "id": zone_id, + }, ) async def delete_merchant_zones(merchant_id: str) -> None: await db.execute( - "DELETE FROM nostrmarket.zones WHERE merchant_id = ?", (merchant_id,) + "DELETE FROM nostrmarket.zones WHERE merchant_id = ?", + {"merchant_id": merchant_id}, ) ######################################## STALL ######################################## -async def create_stall(merchant_id: str, data: PartialStall) -> Stall: +async def create_stall(merchant_id: str, data: Stall) -> Stall: stall_id = data.id or urlsafe_short_hash() await db.execute( - f""" + """ INSERT INTO nostrmarket.stalls - (merchant_id, id, wallet, name, currency, pending, event_id, event_created_at, zones, meta) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ( + merchant_id, id, wallet, name, currency, + pending, event_id, event_created_at, zones, meta + ) + VALUES + ( + :merchant_id, :id, :wallet, :name, :currency, + :pending, :event_id, :event_created_at, :zones, :meta + ) ON CONFLICT(id) DO NOTHING """, - ( - merchant_id, - stall_id, - data.wallet, - data.name, - data.currency, - data.pending, - data.event_id, - data.event_created_at, - json.dumps( + { + "merchant_id": merchant_id, + "id": stall_id, + "wallet": data.wallet, + "name": data.name, + "currency": data.currency, + "pending": data.pending, + "event_id": data.event_id, + "event_created_at": data.event_created_at, + "zones": json.dumps( [z.dict() for z in data.shipping_zones] ), # todo: cost is float. should be int for sats - json.dumps(data.config.dict()), - ), + "meta": json.dumps(data.config.dict()), + }, ) stall = await get_stall(merchant_id, stall_id) @@ -209,107 +234,130 @@ async def create_stall(merchant_id: str, data: PartialStall) -> Stall: return stall -async def get_stall(merchant_id: str, stall_id: str) -> Optional[Stall]: - row = await db.fetchone( - "SELECT * FROM nostrmarket.stalls WHERE merchant_id = ? AND id = ?", - ( - merchant_id, - stall_id, - ), +async def get_stall(merchant_id: str, stall_id: str) -> Stall | None: + row: dict = await db.fetchone( + """ + SELECT * FROM nostrmarket.stalls + WHERE merchant_id = :merchant_id AND id = :id + """, + { + "merchant_id": merchant_id, + "id": stall_id, + }, ) return Stall.from_row(row) if row else None -async def get_stalls(merchant_id: str, pending: Optional[bool] = False) -> List[Stall]: - rows = await db.fetchall( - "SELECT * FROM nostrmarket.stalls WHERE merchant_id = ? AND pending = ?", - ( - merchant_id, - pending, - ), +async def get_stalls(merchant_id: str, pending: bool | None = False) -> list[Stall]: + rows: list[dict] = await db.fetchall( + """ + SELECT * FROM nostrmarket.stalls + WHERE merchant_id = :merchant_id AND pending = :pending + """, + { + "merchant_id": merchant_id, + "pending": pending, + }, ) return [Stall.from_row(row) for row in rows] async def get_last_stall_update_time() -> int: - row = await db.fetchone( + row: dict = await db.fetchone( """ - SELECT event_created_at FROM nostrmarket.stalls + SELECT event_created_at FROM nostrmarket.stalls ORDER BY event_created_at DESC LIMIT 1 """, - (), + {}, ) - return row[0] or 0 if row else 0 + return row["event_created_at"] or 0 if row else 0 -async def update_stall(merchant_id: str, stall: Stall) -> Optional[Stall]: +async def update_stall(merchant_id: str, stall: Stall) -> Stall | None: await db.execute( - f""" - UPDATE nostrmarket.stalls SET wallet = ?, name = ?, currency = ?, pending = ?, event_id = ?, event_created_at = ?, zones = ?, meta = ? - WHERE merchant_id = ? AND id = ? + """ + UPDATE nostrmarket.stalls + SET wallet = :wallet, name = :name, currency = :currency, + pending = :pending, event_id = :event_id, + event_created_at = :event_created_at, + zones = :zones, meta = :meta + WHERE merchant_id = :merchant_id AND id = :id """, - ( - stall.wallet, - stall.name, - stall.currency, - stall.pending, - stall.event_id, - stall.event_created_at, - json.dumps( + { + "wallet": stall.wallet, + "name": stall.name, + "currency": stall.currency, + "pending": stall.pending, + "event_id": stall.event_id, + "event_created_at": stall.event_created_at, + "zones": json.dumps( [z.dict() for z in stall.shipping_zones] ), # todo: cost is float. should be int for sats - json.dumps(stall.config.dict()), - merchant_id, - stall.id, - ), + "meta": json.dumps(stall.config.dict()), + "merchant_id": merchant_id, + "id": stall.id, + }, ) + assert stall.id return await get_stall(merchant_id, stall.id) async def delete_stall(merchant_id: str, stall_id: str) -> None: await db.execute( - "DELETE FROM nostrmarket.stalls WHERE merchant_id =? AND id = ?", - ( - merchant_id, - stall_id, - ), + """ + DELETE FROM nostrmarket.stalls + WHERE merchant_id = :merchant_id AND id = :id + """, + { + "merchant_id": merchant_id, + "id": stall_id, + }, ) async def delete_merchant_stalls(merchant_id: str) -> None: await db.execute( - "DELETE FROM nostrmarket.stalls WHERE merchant_id = ?", - (merchant_id,), + "DELETE FROM nostrmarket.stalls WHERE merchant_id = :merchant_id", + {"merchant_id": merchant_id}, ) -######################################## PRODUCTS ######################################## +######################################## PRODUCTS ###################################### -async def create_product(merchant_id: str, data: PartialProduct) -> Product: +async def create_product(merchant_id: str, data: Product) -> Product: product_id = data.id or urlsafe_short_hash() await db.execute( - f""" - INSERT INTO nostrmarket.products - (merchant_id, id, stall_id, name, price, quantity, pending, event_id, event_created_at, image_urls, category_list, meta) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """ + INSERT INTO nostrmarket.products + ( + merchant_id, id, stall_id, name, price, quantity, + active, pending, event_id, event_created_at, + image_urls, category_list, meta + ) + VALUES ( + :merchant_id, :id, :stall_id, :name, :price, :quantity, + :active, :pending, :event_id, :event_created_at, + :image_urls, :category_list, :meta + ) ON CONFLICT(id) DO NOTHING """, - ( - merchant_id, - product_id, - data.stall_id, - data.name, - data.price, - data.quantity, - data.pending, - data.event_id, - data.event_created_at, - json.dumps(data.images), - json.dumps(data.categories), - json.dumps(data.config.dict()), - ), + { + "merchant_id": merchant_id, + "id": product_id, + "stall_id": data.stall_id, + "name": data.name, + "price": data.price, + "quantity": data.quantity, + "active": data.active, + "pending": data.pending, + "event_id": data.event_id, + "event_created_at": data.event_created_at, + "image_urls": json.dumps(data.images), + "category_list": json.dumps(data.categories), + "meta": json.dumps(data.config.dict()), + }, ) product = await get_product(merchant_id, product_id) assert product, "Newly created product couldn't be retrieved" @@ -318,25 +366,30 @@ async def create_product(merchant_id: str, data: PartialProduct) -> Product: async def update_product(merchant_id: str, product: Product) -> Product: - + assert product.id await db.execute( - f""" - UPDATE nostrmarket.products set name = ?, price = ?, quantity = ?, pending = ?, event_id =?, event_created_at = ?, image_urls = ?, category_list = ?, meta = ? - WHERE merchant_id = ? AND id = ? + """ + UPDATE nostrmarket.products + SET name = :name, price = :price, quantity = :quantity, + active = :active, pending = :pending, event_id =:event_id, + event_created_at = :event_created_at, image_urls = :image_urls, + category_list = :category_list, meta = :meta + WHERE merchant_id = :merchant_id AND id = :id """, - ( - product.name, - product.price, - product.quantity, - product.pending, - product.event_id, - product.event_created_at, - json.dumps(product.images), - json.dumps(product.categories), - json.dumps(product.config.dict()), - merchant_id, - product.id, - ), + { + "name": product.name, + "price": product.price, + "quantity": product.quantity, + "active": product.active, + "pending": product.pending, + "event_id": product.event_id, + "event_created_at": product.event_created_at, + "image_urls": json.dumps(product.images), + "category_list": json.dumps(product.categories), + "meta": json.dumps(product.config.dict()), + "merchant_id": merchant_id, + "id": product.id, + }, ) updated_product = await get_product(merchant_id, product.id) assert updated_product, "Updated product couldn't be retrieved" @@ -344,94 +397,114 @@ async def update_product(merchant_id: str, product: Product) -> Product: return updated_product -async def update_product_quantity( - product_id: str, new_quantity: int -) -> Optional[Product]: +async def update_product_quantity(product_id: str, new_quantity: int) -> Product | None: await db.execute( - f"UPDATE nostrmarket.products SET quantity = ? WHERE id = ?", - (new_quantity, product_id), + """ + UPDATE nostrmarket.products SET quantity = :quantity + WHERE id = :id + """, + {"quantity": new_quantity, "id": product_id}, ) - row = await db.fetchone( - "SELECT * FROM nostrmarket.products WHERE id = ?", - (product_id,), + row: dict = await db.fetchone( + "SELECT * FROM nostrmarket.products WHERE id = :id", + {"id": product_id}, ) return Product.from_row(row) if row else None -async def get_product(merchant_id: str, product_id: str) -> Optional[Product]: - row = await db.fetchone( - "SELECT * FROM nostrmarket.products WHERE merchant_id =? AND id = ?", - ( - merchant_id, - product_id, - ), +async def get_product(merchant_id: str, product_id: str) -> Product | None: + row: dict = await db.fetchone( + """ + SELECT * FROM nostrmarket.products + WHERE merchant_id = :merchant_id AND id = :id + """, + { + "merchant_id": merchant_id, + "id": product_id, + }, ) + # TODO: remove from_row return Product.from_row(row) if row else None async def get_products( - merchant_id: str, stall_id: str, pending: Optional[bool] = False -) -> List[Product]: - rows = await db.fetchall( - "SELECT * FROM nostrmarket.products WHERE merchant_id = ? AND stall_id = ? AND pending = ?", - (merchant_id, stall_id, pending), + merchant_id: str, stall_id: str, pending: bool | None = False +) -> list[Product]: + rows: list[dict] = await db.fetchall( + """ + SELECT * FROM nostrmarket.products + WHERE merchant_id = :merchant_id + AND stall_id = :stall_id AND pending = :pending + """, + {"merchant_id": merchant_id, "stall_id": stall_id, "pending": pending}, ) return [Product.from_row(row) for row in rows] async def get_products_by_ids( - merchant_id: str, product_ids: List[str] -) -> List[Product]: - q = ",".join(["?"] * len(product_ids)) - rows = await db.fetchall( + merchant_id: str, product_ids: list[str] +) -> list[Product]: + # todo: revisit + + keys = [] + values = {"merchant_id": merchant_id} + for i, v in enumerate(product_ids): + key = f"p_{i}" + values[key] = v + keys.append(f":{key}") + rows: list[dict] = await db.fetchall( f""" - SELECT id, stall_id, name, price, quantity, category_list, meta - FROM nostrmarket.products - WHERE merchant_id = ? AND pending = false AND id IN ({q}) + SELECT id, stall_id, name, price, quantity, active, category_list, meta + FROM nostrmarket.products + WHERE merchant_id = :merchant_id + AND pending = false AND id IN ({", ".join(keys)}) """, - (merchant_id, *product_ids), + values, ) return [Product.from_row(row) for row in rows] -async def get_wallet_for_product(product_id: str) -> Optional[str]: - row = await db.fetchone( +async def get_wallet_for_product(product_id: str) -> str | None: + row: dict = await db.fetchone( """ - SELECT s.wallet FROM nostrmarket.products p + SELECT s.wallet as wallet FROM nostrmarket.products p INNER JOIN nostrmarket.stalls s ON p.stall_id = s.id - WHERE p.id = ? AND p.pending = false AND s.pending = false + WHERE p.id = :product_id AND p.pending = false AND s.pending = false """, - (product_id,), + {"product_id": product_id}, ) - return row[0] if row else None + return row["wallet"] if row else None async def get_last_product_update_time() -> int: - row = await db.fetchone( + row: dict = await db.fetchone( """ - SELECT event_created_at FROM nostrmarket.products + SELECT event_created_at FROM nostrmarket.products ORDER BY event_created_at DESC LIMIT 1 """, - (), + {}, ) - return row[0] or 0 if row else 0 + return row["event_created_at"] or 0 if row else 0 async def delete_product(merchant_id: str, product_id: str) -> None: await db.execute( - "DELETE FROM nostrmarket.products WHERE merchant_id =? AND id = ?", - ( - merchant_id, - product_id, - ), + """ + DELETE FROM nostrmarket.products + WHERE merchant_id = :merchant_id AND id = :id + """, + { + "merchant_id": merchant_id, + "id": product_id, + }, ) async def delete_merchant_products(merchant_id: str) -> None: await db.execute( - "DELETE FROM nostrmarket.products WHERE merchant_id = ?", - (merchant_id,), + "DELETE FROM nostrmarket.products WHERE merchant_id = :merchant_id", + {"merchant_id": merchant_id}, ) @@ -440,42 +513,57 @@ async def delete_merchant_products(merchant_id: str) -> None: async def create_order(merchant_id: str, o: Order) -> Order: await db.execute( - f""" + """ INSERT INTO nostrmarket.orders ( - merchant_id, - id, - event_id, + merchant_id, + id, + event_id, event_created_at, merchant_public_key, - public_key, - address, - contact_data, - extra_data, + public_key, + address, + contact_data, + extra_data, order_items, shipping_id, - stall_id, - invoice_id, + stall_id, + invoice_id, total ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES ( + :merchant_id, + :id, + :event_id, + :event_created_at, + :merchant_public_key, + :public_key, + :address, + :contact_data, + :extra_data, + :order_items, + :shipping_id, + :stall_id, + :invoice_id, + :total + ) ON CONFLICT(event_id) DO NOTHING """, - ( - merchant_id, - o.id, - o.event_id, - o.event_created_at, - o.merchant_public_key, - o.public_key, - o.address, - json.dumps(o.contact.dict() if o.contact else {}), - json.dumps(o.extra.dict()), - json.dumps([i.dict() for i in o.items]), - o.shipping_id, - o.stall_id, - o.invoice_id, - o.total, - ), + { + "merchant_id": merchant_id, + "id": o.id, + "event_id": o.event_id, + "event_created_at": o.event_created_at, + "merchant_public_key": o.merchant_public_key, + "public_key": o.public_key, + "address": o.address, + "contact_data": json.dumps(o.contact.dict() if o.contact else {}), + "extra_data": json.dumps(o.extra.dict()), + "order_items": json.dumps([i.dict() for i in o.items]), + "shipping_id": o.shipping_id, + "stall_id": o.stall_id, + "invoice_id": o.invoice_id, + "total": o.total, + }, ) order = await get_order(merchant_id, o.id) assert order, "Newly created order couldn't be retrieved" @@ -483,107 +571,149 @@ async def create_order(merchant_id: str, o: Order) -> Order: return order -async def get_order(merchant_id: str, order_id: str) -> Optional[Order]: - row = await db.fetchone( - "SELECT * FROM nostrmarket.orders WHERE merchant_id =? AND id = ?", - ( - merchant_id, - order_id, - ), +async def get_order(merchant_id: str, order_id: str) -> Order | None: + row: dict = await db.fetchone( + """ + SELECT * FROM nostrmarket.orders + WHERE merchant_id = :merchant_id AND id = :id + """, + { + "merchant_id": merchant_id, + "id": order_id, + }, ) return Order.from_row(row) if row else None -async def get_order_by_event_id(merchant_id: str, event_id: str) -> Optional[Order]: - row = await db.fetchone( - "SELECT * FROM nostrmarket.orders WHERE merchant_id =? AND event_id =?", - ( - merchant_id, - event_id, - ), +async def get_order_by_event_id(merchant_id: str, event_id: str) -> Order | None: + row: dict = await db.fetchone( + """ + SELECT * FROM nostrmarket.orders + WHERE merchant_id = :merchant_id AND event_id = :event_id + """, + { + "merchant_id": merchant_id, + "event_id": event_id, + }, ) return Order.from_row(row) if row else None -async def get_orders(merchant_id: str, **kwargs) -> List[Order]: +async def get_orders(merchant_id: str, **kwargs) -> list[Order]: q = " AND ".join( - [f"{field[0]} = ?" for field in kwargs.items() if field[1] != None] + [ + f"{field[0]} = :{field[0]}" + for field in kwargs.items() + if field[1] is not None + ] ) - values = () - if q: - q = f"AND {q}" - values = (v for v in kwargs.values() if v != None) - rows = await db.fetchall( - f"SELECT * FROM nostrmarket.orders WHERE merchant_id = ? {q} ORDER BY event_created_at DESC", - (merchant_id, *values), + values = {"merchant_id": merchant_id} + for field in kwargs.items(): + if field[1] is None: + continue + values[field[0]] = field[1] + + rows: list[dict] = await db.fetchall( + f""" + SELECT * FROM nostrmarket.orders + WHERE merchant_id = :merchant_id {q} + ORDER BY event_created_at DESC + """, + values, ) return [Order.from_row(row) for row in rows] async def get_orders_for_stall( merchant_id: str, stall_id: str, **kwargs -) -> List[Order]: +) -> list[Order]: q = " AND ".join( - [f"{field[0]} = ?" for field in kwargs.items() if field[1] != None] + [ + f"{field[0]} = :{field[0]}" + for field in kwargs.items() + if field[1] is not None + ] ) - values = () - if q: - q = f"AND {q}" - values = (v for v in kwargs.values() if v != None) - rows = await db.fetchall( - f"SELECT * FROM nostrmarket.orders WHERE merchant_id = ? AND stall_id = ? {q} ORDER BY time DESC", - (merchant_id, stall_id, *values), + values = {"merchant_id": merchant_id, "stall_id": stall_id} + for field in kwargs.items(): + if field[1] is None: + continue + values[field[0]] = field[1] + + rows: list[dict] = await db.fetchall( + f""" + SELECT * FROM nostrmarket.orders + WHERE merchant_id = :merchant_id AND stall_id = :stall_id {q} + ORDER BY time DESC + """, + values, ) return [Order.from_row(row) for row in rows] -async def update_order(merchant_id: str, order_id: str, **kwargs) -> Optional[Order]: - q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) +async def update_order(merchant_id: str, order_id: str, **kwargs) -> Order | None: + q = ", ".join( + [ + f"{field[0]} = :{field[0]}" + for field in kwargs.items() + if field[1] is not None + ] + ) + values = {"merchant_id": merchant_id, "id": order_id} + for field in kwargs.items(): + if field[1] is None: + continue + values[field[0]] = field[1] await db.execute( f""" - UPDATE nostrmarket.orders SET {q} WHERE merchant_id = ? and id = ? + UPDATE nostrmarket.orders + SET {q} WHERE merchant_id = :merchant_id and id = :id """, - (*kwargs.values(), merchant_id, order_id), + values, ) return await get_order(merchant_id, order_id) -async def update_order_paid_status(order_id: str, paid: bool) -> Optional[Order]: +async def update_order_paid_status(order_id: str, paid: bool) -> Order | None: await db.execute( - f"UPDATE nostrmarket.orders SET paid = ? WHERE id = ?", - (paid, order_id), + "UPDATE nostrmarket.orders SET paid = :paid WHERE id = :id", + {"paid": paid, "id": order_id}, ) - row = await db.fetchone( - "SELECT * FROM nostrmarket.orders WHERE id = ?", - (order_id,), + row: dict = await db.fetchone( + "SELECT * FROM nostrmarket.orders WHERE id = :id", + {"id": order_id}, ) return Order.from_row(row) if row else None async def update_order_shipped_status( merchant_id: str, order_id: str, shipped: bool -) -> Optional[Order]: +) -> Order | None: await db.execute( - f"UPDATE nostrmarket.orders SET shipped = ? WHERE merchant_id = ? AND id = ?", - (shipped, merchant_id, order_id), + """ + UPDATE nostrmarket.orders + SET shipped = :shipped + WHERE merchant_id = :merchant_id AND id = :id + """, + {"shipped": shipped, "merchant_id": merchant_id, "id": order_id}, ) - row = await db.fetchone( - "SELECT * FROM nostrmarket.orders WHERE id = ?", - (order_id,), + row: dict = await db.fetchone( + "SELECT * FROM nostrmarket.orders WHERE id = :id", + {"id": order_id}, ) return Order.from_row(row) if row else None async def delete_merchant_orders(merchant_id: str) -> None: await db.execute( - "DELETE FROM nostrmarket.orders WHERE merchant_id = ?", - (merchant_id,), + "DELETE FROM nostrmarket.orders WHERE merchant_id = :merchant_id", + {"merchant_id": merchant_id}, ) -######################################## MESSAGES ########################################L +######################################## MESSAGES ###################################### async def create_direct_message( @@ -591,21 +721,29 @@ async def create_direct_message( ) -> DirectMessage: dm_id = urlsafe_short_hash() await db.execute( - f""" - INSERT INTO nostrmarket.direct_messages (merchant_id, id, event_id, event_created_at, message, public_key, type, incoming) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """ + INSERT INTO nostrmarket.direct_messages + ( + merchant_id, id, event_id, event_created_at, + message, public_key, type, incoming + ) + VALUES + ( + :merchant_id, :id, :event_id, :event_created_at, + :message, :public_key, :type, :incoming + ) ON CONFLICT(event_id) DO NOTHING """, - ( - merchant_id, - dm_id, - dm.event_id, - dm.event_created_at, - dm.message, - dm.public_key, - dm.type, - dm.incoming, - ), + { + "merchant_id": merchant_id, + "id": dm_id, + "event_id": dm.event_id, + "event_created_at": dm.event_created_at, + "message": dm.message, + "public_key": dm.public_key, + "type": dm.type, + "incoming": dm.incoming, + }, ) if dm.event_id: msg = await get_direct_message_by_event_id(merchant_id, dm.event_id) @@ -615,89 +753,102 @@ async def create_direct_message( return msg -async def get_direct_message(merchant_id: str, dm_id: str) -> Optional[DirectMessage]: - row = await db.fetchone( - "SELECT * FROM nostrmarket.direct_messages WHERE merchant_id = ? AND id = ?", - ( - merchant_id, - dm_id, - ), +async def get_direct_message(merchant_id: str, dm_id: str) -> DirectMessage | None: + row: dict = await db.fetchone( + """ + SELECT * FROM nostrmarket.direct_messages + WHERE merchant_id = :merchant_id AND id = :id + """, + { + "merchant_id": merchant_id, + "id": dm_id, + }, ) return DirectMessage.from_row(row) if row else None async def get_direct_message_by_event_id( merchant_id: str, event_id: str -) -> Optional[DirectMessage]: - row = await db.fetchone( - "SELECT * FROM nostrmarket.direct_messages WHERE merchant_id = ? AND event_id = ?", - ( - merchant_id, - event_id, - ), +) -> DirectMessage | None: + row: dict = await db.fetchone( + """ + SELECT * FROM nostrmarket.direct_messages + WHERE merchant_id = :merchant_id AND event_id = :event_id + """, + { + "merchant_id": merchant_id, + "event_id": event_id, + }, ) return DirectMessage.from_row(row) if row else None -async def get_direct_messages(merchant_id: str, public_key: str) -> List[DirectMessage]: - rows = await db.fetchall( - "SELECT * FROM nostrmarket.direct_messages WHERE merchant_id = ? AND public_key = ? ORDER BY event_created_at", - (merchant_id, public_key), +async def get_direct_messages(merchant_id: str, public_key: str) -> list[DirectMessage]: + rows: list[dict] = await db.fetchall( + """ + SELECT * FROM nostrmarket.direct_messages + WHERE merchant_id = :merchant_id AND public_key = :public_key + ORDER BY event_created_at + """, + {"merchant_id": merchant_id, "public_key": public_key}, ) return [DirectMessage.from_row(row) for row in rows] -async def get_orders_from_direct_messages(merchant_id: str) -> List[DirectMessage]: - rows = await db.fetchall( - "SELECT * FROM nostrmarket.direct_messages WHERE merchant_id = ? AND type >= 0 ORDER BY event_created_at, type", - (merchant_id), +async def get_orders_from_direct_messages(merchant_id: str) -> list[DirectMessage]: + rows: list[dict] = await db.fetchall( + """ + SELECT * FROM nostrmarket.direct_messages + WHERE merchant_id = :merchant_id AND type >= 0 ORDER BY event_created_at, type + """, + {"merchant_id": merchant_id}, ) return [DirectMessage.from_row(row) for row in rows] async def get_last_direct_messages_time(merchant_id: str) -> int: - row = await db.fetchone( + row: dict = await db.fetchone( """ - SELECT time FROM nostrmarket.direct_messages - WHERE merchant_id = ? ORDER BY time DESC LIMIT 1 + SELECT time FROM nostrmarket.direct_messages + WHERE merchant_id = :merchant_id ORDER BY time DESC LIMIT 1 """, - (merchant_id,), + {"merchant_id": merchant_id}, ) - return row[0] if row else 0 + return row["time"] if row else 0 async def get_last_direct_messages_created_at() -> int: - row = await db.fetchone( + row: dict = await db.fetchone( """ - SELECT event_created_at FROM nostrmarket.direct_messages + SELECT event_created_at FROM nostrmarket.direct_messages ORDER BY event_created_at DESC LIMIT 1 """, - (), + {}, ) - return row[0] if row else 0 + return row["event_created_at"] if row else 0 async def delete_merchant_direct_messages(merchant_id: str) -> None: await db.execute( - "DELETE FROM nostrmarket.direct_messages WHERE merchant_id = ?", - (merchant_id,), + "DELETE FROM nostrmarket.direct_messages WHERE merchant_id = :merchant_id", + {"merchant_id": merchant_id}, ) -######################################## CUSTOMERS ######################################## +######################################## CUSTOMERS ##################################### async def create_customer(merchant_id: str, data: Customer) -> Customer: await db.execute( - f""" + """ INSERT INTO nostrmarket.customers (merchant_id, public_key, meta) - VALUES (?, ?, ?) + VALUES (:merchant_id, :public_key, :meta) """, - ( - merchant_id, - data.public_key, - json.dumps(data.profile) if data.profile else "{}", - ), + { + "merchant_id": merchant_id, + "public_key": data.public_key, + "meta": json.dumps(data.profile) if data.profile else "{}", + }, ) customer = await get_customer(merchant_id, data.public_key) @@ -705,31 +856,35 @@ async def create_customer(merchant_id: str, data: Customer) -> Customer: return customer -async def get_customer(merchant_id: str, public_key: str) -> Optional[Customer]: - row = await db.fetchone( - "SELECT * FROM nostrmarket.customers WHERE merchant_id = ? AND public_key = ?", - ( - merchant_id, - public_key, - ), +async def get_customer(merchant_id: str, public_key: str) -> Customer | None: + row: dict = await db.fetchone( + """ + SELECT * FROM nostrmarket.customers + WHERE merchant_id = :merchant_id AND public_key = :public_key + """, + { + "merchant_id": merchant_id, + "public_key": public_key, + }, ) return Customer.from_row(row) if row else None -async def get_customers(merchant_id: str) -> List[Customer]: - rows = await db.fetchall( - "SELECT * FROM nostrmarket.customers WHERE merchant_id = ?", (merchant_id,) +async def get_customers(merchant_id: str) -> list[Customer]: + rows: list[dict] = await db.fetchall( + "SELECT * FROM nostrmarket.customers WHERE merchant_id = :merchant_id", + {"merchant_id": merchant_id}, ) return [Customer.from_row(row) for row in rows] -async def get_all_unique_customers() -> List[Customer]: +async def get_all_unique_customers() -> list[Customer]: q = """ - SELECT public_key, MAX(merchant_id) as merchant_id, MAX(event_created_at) - FROM nostrmarket.customers + SELECT public_key, MAX(merchant_id) as merchant_id, MAX(event_created_at) + FROM nostrmarket.customers GROUP BY public_key """ - rows = await db.fetchall(q) + rows: list[dict] = await db.fetchall(q) return [Customer.from_row(row) for row in rows] @@ -737,27 +892,43 @@ async def update_customer_profile( public_key: str, event_created_at: int, profile: CustomerProfile ): await db.execute( - f"UPDATE nostrmarket.customers SET event_created_at = ?, meta = ? WHERE public_key = ?", - (event_created_at, json.dumps(profile.dict()), public_key), + """ + UPDATE nostrmarket.customers + SET event_created_at = :event_created_at, meta = :meta + WHERE public_key = :public_key + """, + { + "event_created_at": event_created_at, + "meta": json.dumps(profile.dict()), + "public_key": public_key, + }, ) async def increment_customer_unread_messages(merchant_id: str, public_key: str): await db.execute( - f"UPDATE nostrmarket.customers SET unread_messages = unread_messages + 1 WHERE merchant_id = ? AND public_key = ?", - ( - merchant_id, - public_key, - ), + """ + UPDATE nostrmarket.customers + SET unread_messages = unread_messages + 1 + WHERE merchant_id = :merchant_id AND public_key = :public_key + """, + { + "merchant_id": merchant_id, + "public_key": public_key, + }, ) # ??? two merchants async def update_customer_no_unread_messages(merchant_id: str, public_key: str): await db.execute( - f"UPDATE nostrmarket.customers SET unread_messages = 0 WHERE merchant_id =? AND public_key = ?", - ( - merchant_id, - public_key, - ), + """ + UPDATE nostrmarket.customers + SET unread_messages = 0 + WHERE merchant_id = :merchant_id AND public_key = :public_key + """, + { + "merchant_id": merchant_id, + "public_key": public_key, + }, ) diff --git a/description.md b/description.md new file mode 100644 index 0000000..6446ca7 --- /dev/null +++ b/description.md @@ -0,0 +1,12 @@ +> IMPORTANT: Nostr market needs the nostr-client extension installed. + +Buy and sell things over Nostr, using NIP15 https://github.com/nostr-protocol/nips/blob/master/15.md + +Nostr was partly based on the the previous version of this extension "Diagon Alley", so lends itself very well to buying and sellinng over Nostr. + +The Nostr Market extension includes: + +- A merchant client to manage products, sales and communication with customers. +- A customer client to find and order products from merchants, communicate with merchants and track status of ordered products. + +All communication happens over NIP04 encrypted DMs. diff --git a/helpers.py b/helpers.py index f7e0d88..bb5efaf 100644 --- a/helpers.py +++ b/helpers.py @@ -1,7 +1,5 @@ import base64 -import json import secrets -from typing import Any, Optional, Tuple import secp256k1 from bech32 import bech32_decode, convertbits @@ -34,7 +32,7 @@ def decrypt_message(encoded_message: str, encryption_key) -> str: return unpadded_data.decode() -def encrypt_message(message: str, encryption_key, iv: Optional[bytes] = None) -> str: +def encrypt_message(message: str, encryption_key, iv: bytes | None = None) -> str: padder = padding.PKCS7(128).padder() padded_data = padder.update(message.encode()) + padder.finalize() @@ -44,12 +42,14 @@ def encrypt_message(message: str, encryption_key, iv: Optional[bytes] = None) -> encryptor = cipher.encryptor() encrypted_message = encryptor.update(padded_data) + encryptor.finalize() - return f"{base64.b64encode(encrypted_message).decode()}?iv={base64.b64encode(iv).decode()}" + base64_message = base64.b64encode(encrypted_message).decode() + base64_iv = base64.b64encode(iv).decode() + return f"{base64_message}?iv={base64_iv}" -def sign_message_hash(private_key: str, hash: bytes) -> str: +def sign_message_hash(private_key: str, hash_: bytes) -> str: privkey = secp256k1.PrivateKey(bytes.fromhex(private_key)) - sig = privkey.schnorr_sign(hash, None, raw=True) + sig = privkey.schnorr_sign(hash_, None, raw=True) return sig.hex() diff --git a/migrations.py b/migrations.py index aa2ec04..c742565 100644 --- a/migrations.py +++ b/migrations.py @@ -1,5 +1,4 @@ async def m001_initial(db): - """ Initial merchants table. """ @@ -121,7 +120,10 @@ async def m001_initial(db): Create indexes for message fetching """ await db.execute( - "CREATE INDEX idx_messages_timestamp ON nostrmarket.direct_messages (time DESC)" + """ + CREATE INDEX idx_messages_timestamp + ON nostrmarket.direct_messages (time DESC) + """ ) await db.execute( "CREATE INDEX idx_event_id ON nostrmarket.direct_messages (event_id)" @@ -142,23 +144,26 @@ async def m001_initial(db): """ ) + async def m002_update_stall_and_product(db): await db.execute( - "ALTER TABLE nostrmarket.stalls ADD COLUMN pending BOOLEAN NOT NULL DEFAULT false;" - ) - await db.execute( - "ALTER TABLE nostrmarket.stalls ADD COLUMN event_id TEXT;" + """ + ALTER TABLE nostrmarket.stalls + ADD COLUMN pending BOOLEAN NOT NULL DEFAULT false; + """ ) + await db.execute("ALTER TABLE nostrmarket.stalls ADD COLUMN event_id TEXT;") await db.execute( "ALTER TABLE nostrmarket.stalls ADD COLUMN event_created_at INTEGER;" ) await db.execute( - "ALTER TABLE nostrmarket.products ADD COLUMN pending BOOLEAN NOT NULL DEFAULT false;" - ) - await db.execute( - "ALTER TABLE nostrmarket.products ADD COLUMN event_id TEXT;" + """ + ALTER TABLE nostrmarket.products + ADD COLUMN pending BOOLEAN NOT NULL DEFAULT false; + """ ) + await db.execute("ALTER TABLE nostrmarket.products ADD COLUMN event_id TEXT;") await db.execute( "ALTER TABLE nostrmarket.products ADD COLUMN event_created_at INTEGER;" ) @@ -166,10 +171,21 @@ async def m002_update_stall_and_product(db): async def m003_update_direct_message_type(db): await db.execute( - "ALTER TABLE nostrmarket.direct_messages ADD COLUMN type INTEGER NOT NULL DEFAULT -1;" + """ + ALTER TABLE nostrmarket.direct_messages + ADD COLUMN type INTEGER NOT NULL DEFAULT -1; + """ ) + async def m004_add_merchant_timestamp(db): + await db.execute("ALTER TABLE nostrmarket.merchants ADD COLUMN time TIMESTAMP;") + + +async def m005_update_product_activation(db): await db.execute( - f"ALTER TABLE nostrmarket.merchants ADD COLUMN time TIMESTAMP;" - ) \ No newline at end of file + """ + ALTER TABLE nostrmarket.products + ADD COLUMN active BOOLEAN NOT NULL DEFAULT true; + """ + ) diff --git a/models.py b/models.py index effd7f9..f1af073 100644 --- a/models.py +++ b/models.py @@ -2,12 +2,10 @@ import json import time from abc import abstractmethod from enum import Enum -from sqlite3 import Row -from typing import Any, List, Optional, Tuple - -from pydantic import BaseModel +from typing import Any from lnbits.utils.exchange_rates import btc_price, fiat_amount_as_satoshis +from pydantic import BaseModel from .helpers import ( decrypt_message, @@ -30,20 +28,20 @@ class Nostrable: pass -######################################## MERCHANT ######################################## +######################################## MERCHANT ###################################### class MerchantProfile(BaseModel): - name: Optional[str] - about: Optional[str] - picture: Optional[str] + name: str | None = None + about: str | None = None + picture: str | None = None class MerchantConfig(MerchantProfile): - event_id: Optional[str] - sync_from_nostr = False + event_id: str | None = None + sync_from_nostr: bool = False active: bool = False - restore_in_progress: Optional[bool] = False + restore_in_progress: bool | None = False class PartialMerchant(BaseModel): @@ -54,10 +52,10 @@ class PartialMerchant(BaseModel): class Merchant(PartialMerchant, Nostrable): id: str - time: Optional[int] = 0 + time: int | None = 0 - def sign_hash(self, hash: bytes) -> str: - return sign_message_hash(self.private_key, hash) + def sign_hash(self, hash_: bytes) -> str: + return sign_message_hash(self.private_key, hash_) def decrypt_message(self, encrypted_message: str, public_key: str) -> str: encryption_key = get_shared_secret(self.private_key, public_key) @@ -82,8 +80,8 @@ class Merchant(PartialMerchant, Nostrable): return event @classmethod - def from_row(cls, row: Row) -> "Merchant": - merchant = cls(**dict(row)) + def from_row(cls, row: dict) -> "Merchant": + merchant = cls(**row) merchant.config = MerchantConfig(**json.loads(row["meta"])) return merchant @@ -123,20 +121,16 @@ class Merchant(PartialMerchant, Nostrable): ######################################## ZONES ######################################## -class PartialZone(BaseModel): - id: Optional[str] - name: Optional[str] +class Zone(BaseModel): + id: str | None = None + name: str | None = None currency: str cost: float - countries: List[str] = [] - - -class Zone(PartialZone): - id: str + countries: list[str] = [] @classmethod - def from_row(cls, row: Row) -> "Zone": - zone = cls(**dict(row)) + def from_row(cls, row: dict) -> "Zone": + zone = cls(**row) zone.countries = json.loads(row["regions"]) return zone @@ -145,22 +139,22 @@ class Zone(PartialZone): class StallConfig(BaseModel): - image_url: Optional[str] - description: Optional[str] + image_url: str | None = None + description: str | None = None -class PartialStall(BaseModel): - id: Optional[str] +class Stall(BaseModel, Nostrable): + id: str | None = None wallet: str name: str currency: str = "sat" - shipping_zones: List[Zone] = [] + shipping_zones: list[Zone] = [] config: StallConfig = StallConfig() pending: bool = False """Last published nostr event for this Stall""" - event_id: Optional[str] - event_created_at: Optional[int] + event_id: str | None = None + event_created_at: int | None = None def validate_stall(self): for z in self.shipping_zones: @@ -169,10 +163,6 @@ class PartialStall(BaseModel): f"Sipping zone '{z.name}' has different currency than stall." ) - -class Stall(PartialStall, Nostrable): - id: str - def to_nostr_event(self, pubkey: str) -> NostrEvent: content = { "id": self.id, @@ -181,6 +171,7 @@ class Stall(PartialStall, Nostrable): "currency": self.currency, "shipping": [dict(z) for z in self.shipping_zones], } + assert self.id event = NostrEvent( pubkey=pubkey, created_at=round(time.time()), @@ -197,7 +188,7 @@ class Stall(PartialStall, Nostrable): pubkey=pubkey, created_at=round(time.time()), kind=5, - tags=[["e", self.event_id]], + tags=[["e", self.event_id or ""]], content=f"Stall '{self.name}' deleted", ) delete_event.id = delete_event.event_id @@ -205,14 +196,14 @@ class Stall(PartialStall, Nostrable): return delete_event @classmethod - def from_row(cls, row: Row) -> "Stall": - stall = cls(**dict(row)) + def from_row(cls, row: dict) -> "Stall": + stall = cls(**row) stall.config = StallConfig(**json.loads(row["meta"])) stall.shipping_zones = [Zone(**z) for z in json.loads(row["zones"])] return stall -######################################## PRODUCTS ######################################## +######################################## PRODUCTS ###################################### class ProductShippingCost(BaseModel): @@ -221,31 +212,28 @@ class ProductShippingCost(BaseModel): class ProductConfig(BaseModel): - description: Optional[str] - currency: Optional[str] - use_autoreply: Optional[bool] = False - autoreply_message: Optional[str] - shipping: Optional[List[ProductShippingCost]] = [] + description: str | None = None + currency: str | None = None + use_autoreply: bool | None = False + autoreply_message: str | None = None + shipping: list[ProductShippingCost] = [] -class PartialProduct(BaseModel): - id: Optional[str] +class Product(BaseModel, Nostrable): + id: str | None = None stall_id: str name: str - categories: List[str] = [] - images: List[str] = [] + categories: list[str] = [] + images: list[str] = [] price: float quantity: int + active: bool = True pending: bool = False config: ProductConfig = ProductConfig() """Last published nostr event for this Product""" - event_id: Optional[str] - event_created_at: Optional[int] - - -class Product(PartialProduct, Nostrable): - id: str + event_id: str | None = None + event_created_at: int | None = None def to_nostr_event(self, pubkey: str) -> NostrEvent: content = { @@ -257,27 +245,32 @@ class Product(PartialProduct, Nostrable): "currency": self.config.currency, "price": self.price, "quantity": self.quantity, - "shipping": [dict(s) for s in self.config.shipping or []] + "active": self.active, + "shipping": [dict(s) for s in self.config.shipping or []], } categories = [["t", tag] for tag in self.categories] - event = NostrEvent( - pubkey=pubkey, - created_at=round(time.time()), - kind=30018, - tags=[["d", self.id]] + categories, - content=json.dumps(content, separators=(",", ":"), ensure_ascii=False), - ) - event.id = event.event_id + assert self.id + if self.active: + event = NostrEvent( + pubkey=pubkey, + created_at=round(time.time()), + kind=30018, + tags=[["d", self.id], *categories], + content=json.dumps(content, separators=(",", ":"), ensure_ascii=False), + ) + event.id = event.event_id - return event + return event + else: + return self.to_nostr_delete_event(pubkey) def to_nostr_delete_event(self, pubkey: str) -> NostrEvent: delete_event = NostrEvent( pubkey=pubkey, created_at=round(time.time()), kind=5, - tags=[["e", self.event_id]], + tags=[["e", self.event_id or ""]], content=f"Product '{self.name}' deleted", ) delete_event.id = delete_event.event_id @@ -285,8 +278,8 @@ class Product(PartialProduct, Nostrable): return delete_event @classmethod - def from_row(cls, row: Row) -> "Product": - product = cls(**dict(row)) + def from_row(cls, row: dict) -> "Product": + product = cls(**row) product.config = ProductConfig(**json.loads(row["meta"])) product.images = json.loads(row["image_urls"]) if "image_urls" in row else [] product.categories = json.loads(row["category_list"]) @@ -297,6 +290,12 @@ class ProductOverview(BaseModel): id: str name: str price: float + product_shipping_cost: float | None = None + + @classmethod + def from_product(cls, p: Product) -> "ProductOverview": + assert p.id + return ProductOverview(id=p.id, name=p.name, price=p.price) ######################################## ORDERS ######################################## @@ -308,43 +307,49 @@ class OrderItem(BaseModel): class OrderContact(BaseModel): - nostr: Optional[str] - phone: Optional[str] - email: Optional[str] + nostr: str | None = None + phone: str | None = None + email: str | None = None class OrderExtra(BaseModel): - products: List[ProductOverview] + products: list[ProductOverview] currency: str btc_price: str shipping_cost: float = 0 shipping_cost_sat: float = 0 - fail_message: Optional[str] + fail_message: str | None = None @classmethod - async def from_products(cls, products: List[Product]): + async def from_products(cls, products: list[Product]): currency = products[0].config.currency if len(products) else "sat" exchange_rate = ( - (await btc_price(currency)) if currency and currency != "sat" else 1 + await btc_price(currency) if currency and currency != "sat" else 1 + ) + + products_overview = [ProductOverview.from_product(p) for p in products] + return OrderExtra( + products=products_overview, + currency=currency or "sat", + btc_price=str(exchange_rate), ) - return OrderExtra(products=products, currency=currency, btc_price=exchange_rate) class PartialOrder(BaseModel): id: str - event_id: Optional[str] - event_created_at: Optional[int] + event_id: str | None = None + event_created_at: int | None = None public_key: str merchant_public_key: str shipping_id: str - items: List[OrderItem] - contact: Optional[OrderContact] - address: Optional[str] + items: list[OrderItem] + contact: OrderContact | None = None + address: str | None = None def validate_order(self): assert len(self.items) != 0, f"Order has no items. Order: '{self.id}'" - def validate_order_items(self, product_list: List[Product]): + def validate_order_items(self, product_list: list[Product]): assert len(self.items) != 0, f"Order has no items. Order: '{self.id}'" assert ( len(product_list) != 0 @@ -365,8 +370,8 @@ class PartialOrder(BaseModel): ) async def costs_in_sats( - self, products: List[Product], shipping_id: str, stall_shipping_cost: float - ) -> Tuple[float, float]: + self, products: list[Product], shipping_id: str, stall_shipping_cost: float + ) -> tuple[float, float]: product_prices = {} for p in products: product_shipping_cost = next( @@ -378,10 +383,11 @@ class PartialOrder(BaseModel): } product_cost: float = 0 # todo + currency = "sat" for item in self.items: assert item.quantity > 0, "Quantity cannot be negative" - price = product_prices[item.product_id]["price"] - currency = product_prices[item.product_id]["currency"] + price = float(str(product_prices[item.product_id]["price"])) + currency = str(product_prices[item.product_id]["currency"]) if currency != "sat": price = await fiat_amount_as_satoshis(price, currency) product_cost += item.quantity * price @@ -394,35 +400,44 @@ class PartialOrder(BaseModel): return product_cost, stall_shipping_cost def receipt( - self, products: List[Product], shipping_id: str, stall_shipping_cost: float + self, products: list[Product], shipping_id: str, stall_shipping_cost: float ) -> str: if len(products) == 0: return "[No Products]" receipt = "" - product_prices = {} + product_prices: dict[str, ProductOverview] = {} for p in products: product_shipping_cost = next( (s.cost for s in p.config.shipping if s.id == shipping_id), 0 ) - product_prices[p.id] = { - "name": p.name, - "price": p.price, - "product_shipping_cost": product_shipping_cost - } + assert p.id + product_prices[p.id] = ProductOverview( + id=p.id, + name=p.name, + price=p.price, + product_shipping_cost=product_shipping_cost, + ) currency = products[0].config.currency or "sat" products_cost: float = 0 # todo items_receipts = [] for item in self.items: prod = product_prices[item.product_id] - price = prod["price"] + prod["product_shipping_cost"] + price = prod.price + (prod.product_shipping_cost or 0) products_cost += item.quantity * price - items_receipts.append(f"""[{prod["name"]}: {item.quantity} x ({prod["price"]} + {prod["product_shipping_cost"]}) = {item.quantity * price} {currency}] """) + items_receipts.append( + f"""[{prod.name}: {item.quantity} x ({prod.price}""" + f""" + {prod.product_shipping_cost})""" + f""" = {item.quantity * price} {currency}] """ + ) receipt = "; ".join(items_receipts) - receipt += f"[Products cost: {products_cost} {currency}] [Stall shipping cost: {stall_shipping_cost} {currency}]; " + receipt += ( + f"[Products cost: {products_cost} {currency}] " + f"[Stall shipping cost: {stall_shipping_cost} {currency}]; " + ) receipt += f"[Total: {products_cost + stall_shipping_cost} {currency}]" return receipt @@ -434,28 +449,28 @@ class Order(PartialOrder): total: float paid: bool = False shipped: bool = False - time: Optional[int] + time: int | None = None extra: OrderExtra @classmethod - def from_row(cls, row: Row) -> "Order": + def from_row(cls, row: dict) -> "Order": contact = OrderContact(**json.loads(row["contact_data"])) extra = OrderExtra(**json.loads(row["extra_data"])) items = [OrderItem(**z) for z in json.loads(row["order_items"])] - order = cls(**dict(row), contact=contact, items=items, extra=extra) + order = cls(**row, contact=contact, items=items, extra=extra) return order class OrderStatusUpdate(BaseModel): id: str - message: Optional[str] - paid: Optional[bool] - shipped: Optional[bool] + message: str | None = None + paid: bool | None = False + shipped: bool | None = None class OrderReissue(BaseModel): id: str - shipping_id: Optional[str] = None + shipping_id: str | None = None class PaymentOption(BaseModel): @@ -465,11 +480,11 @@ class PaymentOption(BaseModel): class PaymentRequest(BaseModel): id: str - message: Optional[str] - payment_options: List[PaymentOption] + message: str | None = None + payment_options: list[PaymentOption] -######################################## MESSAGE ######################################## +######################################## MESSAGE ####################################### class DirectMessageType(Enum): @@ -482,16 +497,16 @@ class DirectMessageType(Enum): class PartialDirectMessage(BaseModel): - event_id: Optional[str] - event_created_at: Optional[int] + event_id: str | None = None + event_created_at: int | None = None message: str public_key: str type: int = DirectMessageType.PLAIN_TEXT.value incoming: bool = False - time: Optional[int] + time: int | None = None @classmethod - def parse_message(cls, msg) -> Tuple[DirectMessageType, Optional[Any]]: + def parse_message(cls, msg) -> tuple[DirectMessageType, Any | None]: try: msg_json = json.loads(msg) if "type" in msg_json: @@ -506,29 +521,28 @@ class DirectMessage(PartialDirectMessage): id: str @classmethod - def from_row(cls, row: Row) -> "DirectMessage": - dm = cls(**dict(row)) - return dm + def from_row(cls, row: dict) -> "DirectMessage": + return cls(**row) -######################################## CUSTOMERS ######################################## +######################################## CUSTOMERS ##################################### class CustomerProfile(BaseModel): - name: Optional[str] - about: Optional[str] + name: str | None = None + about: str | None = None class Customer(BaseModel): merchant_id: str public_key: str - event_created_at: Optional[int] - profile: Optional[CustomerProfile] + event_created_at: int | None = None + profile: CustomerProfile | None = None unread_messages: int = 0 @classmethod - def from_row(cls, row: Row) -> "Customer": - customer = cls(**dict(row)) + def from_row(cls, row: dict) -> "Customer": + customer = cls(**row) customer.profile = ( CustomerProfile(**json.loads(row["meta"])) if "meta" in row else None ) diff --git a/nostr/event.py b/nostr/event.py index c92a4fa..a3216cf 100644 --- a/nostr/event.py +++ b/nostr/event.py @@ -13,7 +13,7 @@ class NostrEvent(BaseModel): kind: int tags: List[List[str]] = [] content: str = "" - sig: Optional[str] + sig: Optional[str] = None def serialize(self) -> List: return [0, self.pubkey, self.created_at, self.kind, self.tags, self.content] @@ -41,7 +41,7 @@ class NostrEvent(BaseModel): f"Invalid public key: '{self.pubkey}' for event '{self.id}'" ) - valid_signature = pub_key.schnorr_verify( + valid_signature = self.sig and pub_key.schnorr_verify( bytes.fromhex(event_id), bytes.fromhex(self.sig), None, raw=True ) if not valid_signature: diff --git a/nostr/nostr_client.py b/nostr/nostr_client.py index 7353e44..a611980 100644 --- a/nostr/nostr_client.py +++ b/nostr/nostr_client.py @@ -2,12 +2,12 @@ import asyncio import json from asyncio import Queue from threading import Thread -from typing import Callable, List +from typing import Callable, List, Optional from loguru import logger from websocket import WebSocketApp -from lnbits.app import settings +from lnbits.settings import settings from lnbits.helpers import encrypt_internal_message, urlsafe_short_hash from .event import NostrEvent @@ -17,7 +17,7 @@ class NostrClient: def __init__(self): self.recieve_event_queue: Queue = Queue() self.send_req_queue: Queue = Queue() - self.ws: WebSocketApp = None + self.ws: Optional[WebSocketApp] = None self.subscription_id = "nostrmarket-" + urlsafe_short_hash()[:32] self.running = False @@ -30,8 +30,7 @@ class NostrClient: async def connect_to_nostrclient_ws(self) -> WebSocketApp: logger.debug(f"Connecting to websockets for 'nostrclient' extension...") - - relay_endpoint = encrypt_internal_message("relay") + relay_endpoint = encrypt_internal_message("relay", urlsafe=True) on_open, on_message, on_error, on_close = self._ws_handlers() ws = WebSocketApp( f"ws://localhost:{settings.port}/nostrclient/api/v1/{relay_endpoint}", @@ -57,19 +56,18 @@ class NostrClient: await asyncio.sleep(5) req = await self.send_req_queue.get() + assert self.ws self.ws.send(json.dumps(req)) except Exception as ex: logger.warning(ex) await asyncio.sleep(60) - async def get_event(self): value = await self.recieve_event_queue.get() if isinstance(value, ValueError): raise value return value - async def publish_nostr_event(self, e: NostrEvent): await self.send_req_queue.put(["EVENT", e.dict()]) @@ -119,7 +117,7 @@ class NostrClient: asyncio.create_task(unsubscribe_with_delay(subscription_id, duration)) - async def user_profile_temp_subscribe(self, public_key: str, duration=5) -> List: + async def user_profile_temp_subscribe(self, public_key: str, duration=5): try: profile_filter = [{"kinds": [0], "authors": [public_key]}] subscription_id = "profile-" + urlsafe_short_hash()[:32] diff --git a/package.json b/package.json new file mode 100644 index 0000000..4917983 --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "nostrmarket", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "", + "license": "ISC", + "dependencies": { + "prettier": "^3.2.5", + "pyright": "^1.1.358" + } +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b0a9d98 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,99 @@ +[project] +name = "nostrmarket" +version = "0.0.0" +requires-python = ">=3.10,<3.13" +description = "LNbits, free and open-source Lightning wallet and accounts system." +authors = [{ name = "Alan Bits", email = "alan@lnbits.com" }] +urls = { Homepage = "https://lnbits.com", Repository = "https://github.com/lnbits/nostrmarket" } +dependencies = [ "lnbits>1" ] + +[tool.poetry] +package-mode = false + +[dependency-groups] +dev = [ + "black", + "pytest-asyncio", + "pytest", + "mypy==1.17.1", + "pre-commit", + "ruff", + "pytest-md", + "types-cffi", +] + +[tool.mypy] +exclude = "(nostr/*)" +plugins = ["pydantic.mypy"] + +[[tool.mypy.overrides]] +module = [ + "nostr.*", + "secp256k1.*", +] +ignore_missing_imports = "True" + + +[tool.pydantic-mypy] +init_forbid_extra = true +init_typed = true +warn_required_dynamic_aliases = true +warn_untyped_fields = true + +[tool.pytest.ini_options] +log_cli = false +testpaths = [ + "tests" +] + +[tool.black] +line-length = 88 + +[tool.ruff] +# Same as Black. + 10% rule of black +line-length = 88 +exclude = [ + "nostr", +] + +[tool.ruff.lint] +# Enable: +# F - pyflakes +# E - pycodestyle errors +# W - pycodestyle warnings +# I - isort +# A - flake8-builtins +# C - mccabe +# N - naming +# UP - pyupgrade +# RUF - ruff +# B - bugbear +select = ["F", "E", "W", "I", "A", "C", "N", "UP", "RUF", "B"] +ignore = ["C901"] + +# Allow autofix for all enabled rules (when `--fix`) is provided. +fixable = ["ALL"] +unfixable = [] + +# Allow unused variables when underscore-prefixed. +dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" + +# needed for pydantic +[tool.ruff.lint.pep8-naming] +classmethod-decorators = [ + "root_validator", +] + +# Ignore unused imports in __init__.py files. +# [tool.ruff.lint.extend-per-file-ignores] +# "__init__.py" = ["F401", "F403"] + +# [tool.ruff.lint.mccabe] +# max-complexity = 10 + +[tool.ruff.lint.flake8-bugbear] +# Allow default arguments like, e.g., `data: List[str] = fastapi.Query(None)`. +extend-immutable-calls = [ + "fastapi.Depends", + "fastapi.Query", +] diff --git a/services.py b/services.py index 55f68d9..4039dbb 100644 --- a/services.py +++ b/services.py @@ -1,12 +1,11 @@ import asyncio import json -from typing import List, Optional, Tuple +from bolt11 import decode +from lnbits.core.crud import get_wallet +from lnbits.core.services import create_invoice, websocket_updater from loguru import logger -from lnbits.bolt11 import decode -from lnbits.core.services import websocket_updater, create_invoice, get_wallet - from . import nostr_client from .crud import ( CustomerProfile, @@ -60,7 +59,7 @@ from .nostr.event import NostrEvent async def create_new_order( merchant_public_key: str, data: PartialOrder -) -> Optional[PaymentRequest]: +) -> PaymentRequest | None: merchant = await get_merchant_by_pubkey(merchant_public_key) assert merchant, "Cannot find merchant for order!" @@ -75,7 +74,9 @@ async def create_new_order( await create_order(merchant.id, order) return PaymentRequest( - id=data.id, payment_options=[PaymentOption(type="ln", link=invoice)], message=receipt + id=data.id, + payment_options=[PaymentOption(type="ln", link=invoice)], + message=receipt, ) @@ -89,12 +90,11 @@ async def build_order_with_payment( shipping_zone = await get_zone(merchant_id, data.shipping_id) assert shipping_zone, f"Shipping zone not found for order '{data.id}'" + assert shipping_zone.id product_cost_sat, shipping_cost_sat = await data.costs_in_sats( products, shipping_zone.id, shipping_zone.cost ) - receipt = data.receipt( - products, shipping_zone.id, shipping_zone.cost - ) + receipt = data.receipt(products, shipping_zone.id, shipping_zone.cost) wallet_id = await get_wallet_for_product(data.items[0].product_id) assert wallet_id, "Missing wallet for order `{data.id}`" @@ -106,7 +106,7 @@ async def build_order_with_payment( if not success: raise ValueError(message) - payment_hash, invoice = await create_invoice( + payment = await create_invoice( wallet_id=wallet_id, amount=round(product_cost_sat + shipping_cost_sat), memo=f"Order '{data.id}' for pubkey '{data.public_key}'", @@ -124,19 +124,21 @@ async def build_order_with_payment( order = Order( **data.dict(), stall_id=products[0].stall_id, - invoice_id=payment_hash, + invoice_id=payment.payment_hash, total=product_cost_sat + shipping_cost_sat, extra=extra, ) - return order, invoice, receipt + return order, payment.bolt11, receipt async def update_merchant_to_nostr( merchant: Merchant, delete_merchant=False ) -> Merchant: stalls = await get_stalls(merchant.id) + event: NostrEvent | None = None for stall in stalls: + assert stall.id products = await get_products(merchant.id, stall.id) for product in products: event = await sign_and_send_to_nostr(merchant, product, delete_merchant) @@ -150,6 +152,7 @@ async def update_merchant_to_nostr( if delete_merchant: # merchant profile updates not supported yet event = await sign_and_send_to_nostr(merchant, merchant, delete_merchant) + assert event merchant.config.event_id = event.id return merchant @@ -218,7 +221,7 @@ async def notify_client_of_order_status( async def update_products_for_order( merchant: Merchant, order: Order -) -> Tuple[bool, str]: +) -> tuple[bool, str]: product_ids = [i.product_id for i in order.items] success, products, message = await compute_products_new_quantity( merchant.id, product_ids, order.items @@ -227,25 +230,29 @@ async def update_products_for_order( return success, message for p in products: + assert p.id product = await update_product_quantity(p.id, p.quantity) + assert product event = await sign_and_send_to_nostr(merchant, product) product.event_id = event.id await update_product(merchant.id, product) return True, "ok" -async def autoreply_for_products_in_order( - merchant: Merchant, order: Order -) -> Tuple[bool, str]: + +async def autoreply_for_products_in_order(merchant: Merchant, order: Order): product_ids = [i.product_id for i in order.items] products = await get_products_by_ids(merchant.id, product_ids) products_with_autoreply = [p for p in products if p.config.use_autoreply] for p in products_with_autoreply: - dm_content = p.config.autoreply_message + dm_content = p.config.autoreply_message or "" await send_dm( - merchant, order.public_key, DirectMessageType.PLAIN_TEXT.value, dm_content + merchant, + order.public_key, + DirectMessageType.PLAIN_TEXT.value, + dm_content, ) await asyncio.sleep(1) # do not send all autoreplies at once @@ -253,7 +260,7 @@ async def autoreply_for_products_in_order( async def send_dm( merchant: Merchant, other_pubkey: str, - type: str, + type_: int, dm_content: str, ): dm_event = merchant.build_dm_event(dm_content, other_pubkey) @@ -263,7 +270,7 @@ async def send_dm( event_created_at=dm_event.created_at, message=dm_content, public_key=other_pubkey, - type=type, + type=type_, ) dm_reply = await create_direct_message(merchant.id, dm) @@ -282,9 +289,9 @@ async def send_dm( async def compute_products_new_quantity( - merchant_id: str, product_ids: List[str], items: List[OrderItem] -) -> Tuple[bool, List[Product], str]: - products: List[Product] = await get_products_by_ids(merchant_id, product_ids) + merchant_id: str, product_ids: list[str], items: list[OrderItem] +) -> tuple[bool, list[Product], str]: + products: list[Product] = await get_products_by_ids(merchant_id, product_ids) for p in products: required_quantity = next( @@ -296,7 +303,8 @@ async def compute_products_new_quantity( return ( False, [], - f"Quantity not sufficient for product: '{p.name}' ({p.id}). Required '{required_quantity}' but only have '{p.quantity}'.", + f"Quantity not sufficient for product: '{p.name}' ({p.id})." + f" Required '{required_quantity}' but only have '{p.quantity}'.", ) p.quantity -= required_quantity @@ -306,9 +314,9 @@ async def compute_products_new_quantity( async def process_nostr_message(msg: str): try: - type, *rest = json.loads(msg) + type_, *rest = json.loads(msg) - if type.upper() == "EVENT": + if type_.upper() == "EVENT": _, event = rest event = NostrEvent(**event) if event.kind == 0: @@ -328,11 +336,11 @@ async def process_nostr_message(msg: str): async def create_or_update_order_from_dm( merchant_id: str, merchant_pubkey: str, dm: DirectMessage ): - type, json_data = PartialDirectMessage.parse_message(dm.message) - if "id" not in json_data: + type_, json_data = PartialDirectMessage.parse_message(dm.message) + if not json_data or "id" not in json_data: return - if type == DirectMessageType.CUSTOMER_ORDER: + if type_ == DirectMessageType.CUSTOMER_ORDER: order = await extract_customer_order_from_dm( merchant_id, merchant_pubkey, dm, json_data ) @@ -348,7 +356,7 @@ async def create_or_update_order_from_dm( ) return - if type == DirectMessageType.PAYMENT_REQUEST: + if type_ == DirectMessageType.PAYMENT_REQUEST: payment_request = PaymentRequest(**json_data) pr = next( (o.link for o in payment_request.payment_options if o.type == "ln"), None @@ -356,14 +364,15 @@ async def create_or_update_order_from_dm( if not pr: return invoice = decode(pr) + total = invoice.amount_msat / 1000 if invoice.amount_msat else 0 await update_order( merchant_id, payment_request.id, - **{"total": invoice.amount_msat / 1000, "invoice_id": invoice.payment_hash}, + **{"total": total, "invoice_id": invoice.payment_hash}, ) return - if type == DirectMessageType.ORDER_PAID_OR_SHIPPED: + if type_ == DirectMessageType.ORDER_PAID_OR_SHIPPED: order_update = OrderStatusUpdate(**json_data) if order_update.paid: await update_order_paid_status(order_update.id, True) @@ -380,16 +389,18 @@ async def extract_customer_order_from_dm( ) extra = await OrderExtra.from_products(products) order = Order( - id=json_data.get("id"), + id=str(json_data.get("id")), event_id=dm.event_id, event_created_at=dm.event_created_at, public_key=dm.public_key, merchant_public_key=merchant_pubkey, shipping_id=json_data.get("shipping_id", "None"), items=order_items, - contact=OrderContact(**json_data.get("contact")) - if json_data.get("contact") - else None, + contact=( + OrderContact(**json_data.get("contact", {})) + if json_data.get("contact") + else None + ), address=json_data.get("address"), stall_id=products[0].stall_id if len(products) else "None", invoice_id="None", @@ -406,12 +417,9 @@ async def _handle_nip04_message(event: NostrEvent): if not merchant: p_tags = event.tag_values("p") - merchant_public_key = p_tags[0] if len(p_tags) else None - merchant = ( - await get_merchant_by_pubkey(merchant_public_key) - if merchant_public_key - else None - ) + if len(p_tags) and p_tags[0]: + merchant_public_key = p_tags[0] + merchant = await get_merchant_by_pubkey(merchant_public_key) assert merchant, f"Merchant not found for public key '{merchant_public_key}'" @@ -461,21 +469,21 @@ async def _handle_outgoing_dms( event: NostrEvent, merchant: Merchant, clear_text_msg: str ): sent_to = event.tag_values("p") - type, _ = PartialDirectMessage.parse_message(clear_text_msg) + type_, _ = PartialDirectMessage.parse_message(clear_text_msg) if len(sent_to) != 0: dm = PartialDirectMessage( event_id=event.id, event_created_at=event.created_at, message=clear_text_msg, public_key=sent_to[0], - type=type.value, + type=type_.value, ) await create_direct_message(merchant.id, dm) async def _handle_incoming_structured_dm( merchant: Merchant, dm: DirectMessage, json_data: dict -) -> Tuple[DirectMessageType, str]: +) -> tuple[DirectMessageType, str | None]: try: if dm.type == DirectMessageType.CUSTOMER_ORDER.value and merchant.config.active: json_resp = await _handle_new_order( @@ -487,7 +495,7 @@ async def _handle_incoming_structured_dm( except Exception as ex: logger.warning(ex) - return None, None + return DirectMessageType.PLAIN_TEXT, None async def _persist_dm( @@ -570,9 +578,13 @@ async def _handle_new_order( except Exception as e: logger.debug(e) payment_req = await create_new_failed_order( - merchant_id, merchant_public_key, dm, json_data, "Order received, but cannot be processed. Please contact merchant." + merchant_id, + merchant_public_key, + dm, + json_data, + "Order received, but cannot be processed. Please contact merchant.", ) - + assert payment_req response = { "type": DirectMessageType.PAYMENT_REQUEST.value, **payment_req.dict(), @@ -594,12 +606,14 @@ async def create_new_failed_order( await create_order(merchant_id, order) return PaymentRequest(id=order.id, message=fail_message, payment_options=[]) + async def resubscribe_to_all_merchants(): await nostr_client.unsubscribe_merchants() # give some time for the message to propagate - asyncio.sleep(1) + await asyncio.sleep(1) await subscribe_to_all_merchants() + async def subscribe_to_all_merchants(): ids = await get_merchants_ids_with_pubkeys() public_keys = [pk for _, pk in ids] @@ -608,7 +622,9 @@ async def subscribe_to_all_merchants(): last_stall_time = await get_last_stall_update_time() last_prod_time = await get_last_product_update_time() - await nostr_client.subscribe_merchants(public_keys, last_dm_time, last_stall_time, last_prod_time, 0) + await nostr_client.subscribe_merchants( + public_keys, last_dm_time, last_stall_time, last_prod_time, 0 + ) async def _handle_new_customer(event: NostrEvent, merchant: Merchant): diff --git a/static/components/direct-messages.js b/static/components/direct-messages.js new file mode 100644 index 0000000..3bb1aa0 --- /dev/null +++ b/static/components/direct-messages.js @@ -0,0 +1,173 @@ +window.app.component('direct-messages', { + name: 'direct-messages', + props: ['active-chat-customer', 'merchant-id', 'adminkey', 'inkey'], + template: '#direct-messages', + delimiters: ['${', '}'], + watch: { + activeChatCustomer: async function (n) { + this.activePublicKey = n + }, + activePublicKey: async function (n) { + await this.getDirectMessages(n) + } + }, + computed: { + messagesAsJson: function () { + return this.messages.map(m => { + const dateFrom = moment(m.event_created_at * 1000).fromNow() + try { + const message = JSON.parse(m.message) + return { + isJson: message.type >= 0, + dateFrom, + ...m, + message + } + } catch (error) { + return { + isJson: false, + dateFrom, + ...m, + message: m.message + } + } + }) + } + }, + data: function () { + return { + customers: [], + unreadMessages: 0, + activePublicKey: null, + messages: [], + newMessage: '', + showAddPublicKey: false, + newPublicKey: null, + showRawMessage: false, + rawMessage: null + } + }, + methods: { + sendMessage: async function () {}, + buildCustomerLabel: function (c) { + if (!c) return '' + let label = c.profile.name || 'unknown' + if (c.profile.about) { + label += ` - ${c.profile.about.substring(0, 30)}` + if (c.profile.about.length > 30) label += '...' + } + if (c.unread_messages) { + label = `[${c.unread_messages} new] ${label}` + } + label += ` (${c.public_key.slice(0, 8)}...${c.public_key.slice(-8)})` + return label + }, + getDirectMessages: async function (pubkey) { + if (!pubkey) { + this.messages = [] + return + } + try { + const {data} = await LNbits.api.request( + 'GET', + '/nostrmarket/api/v1/message/' + pubkey, + this.inkey + ) + this.messages = data + + this.focusOnChatBox(this.messages.length - 1) + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + getCustomers: async function () { + try { + const {data} = await LNbits.api.request( + 'GET', + '/nostrmarket/api/v1/customer', + this.inkey + ) + this.customers = data + this.unreadMessages = data.filter(c => c.unread_messages).length + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + + sendDirectMesage: async function () { + try { + const {data} = await LNbits.api.request( + 'POST', + '/nostrmarket/api/v1/message', + this.adminkey, + { + message: this.newMessage, + public_key: this.activePublicKey + } + ) + this.messages = this.messages.concat([data]) + this.newMessage = '' + this.focusOnChatBox(this.messages.length - 1) + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + addPublicKey: async function () { + try { + const {data} = await LNbits.api.request( + 'POST', + '/nostrmarket/api/v1/customer', + this.adminkey, + { + public_key: this.newPublicKey, + merchant_id: this.merchantId, + unread_messages: 0 + } + ) + this.newPublicKey = null + this.activePublicKey = data.public_key + await this.selectActiveCustomer() + } catch (error) { + LNbits.utils.notifyApiError(error) + } finally { + this.showAddPublicKey = false + } + }, + handleNewMessage: async function (data) { + if (data.customerPubkey === this.activePublicKey) { + this.messages.push(data.dm) + this.focusOnChatBox(this.messages.length - 1) + // focus back on input box + } + this.getCustomersDebounced() + }, + showOrderDetails: function (orderId, eventId) { + this.$emit('order-selected', {orderId, eventId}) + }, + showClientOrders: function () { + this.$emit('customer-selected', this.activePublicKey) + }, + selectActiveCustomer: async function () { + await this.getDirectMessages(this.activePublicKey) + await this.getCustomers() + }, + showMessageRawData: function (index) { + this.rawMessage = this.messages[index]?.message + this.showRawMessage = true + }, + focusOnChatBox: function (index) { + setTimeout(() => { + const lastChatBox = document.getElementsByClassName( + `chat-mesage-index-${index}` + ) + if (lastChatBox && lastChatBox[0]) { + lastChatBox[0].scrollIntoView() + } + }, 100) + } + }, + created: async function () { + await this.getCustomers() + this.getCustomersDebounced = _.debounce(this.getCustomers, 2000, false) + } +}) diff --git a/static/components/direct-messages/direct-messages.html b/static/components/direct-messages/direct-messages.html deleted file mode 100644 index b6b90d6..0000000 --- a/static/components/direct-messages/direct-messages.html +++ /dev/null @@ -1,104 +0,0 @@ - - - - - - Messages - - - new - - - Client - Orders - - - - - - - - - - - - - - - - Add a public key to chat with - - - - - - - - - - - - - - New order: - - - Reply sent for order: - - - Paid - Shipped - - - - - - - - ... - - - - - - - - - - - - - - - - - - - - - - - - Add - Cancel - - - - - - - - - - Close - - - - - - \ No newline at end of file diff --git a/static/components/direct-messages/direct-messages.js b/static/components/direct-messages/direct-messages.js deleted file mode 100644 index 607342a..0000000 --- a/static/components/direct-messages/direct-messages.js +++ /dev/null @@ -1,173 +0,0 @@ -async function directMessages(path) { - const template = await loadTemplateAsync(path) - Vue.component('direct-messages', { - name: 'direct-messages', - props: ['active-chat-customer', 'merchant-id', 'adminkey', 'inkey'], - template, - - watch: { - activeChatCustomer: async function (n) { - this.activePublicKey = n - }, - activePublicKey: async function (n) { - await this.getDirectMessages(n) - } - }, - computed: { - messagesAsJson: function () { - return this.messages.map(m => { - const dateFrom = moment(m.event_created_at * 1000).fromNow() - try { - const message = JSON.parse(m.message) - return { - isJson: message.type >= 0, - dateFrom, - ...m, - message - } - } catch (error) { - return { - isJson: false, - dateFrom, - ...m, - message: m.message - } - } - }) - } - }, - data: function () { - return { - customers: [], - unreadMessages: 0, - activePublicKey: null, - messages: [], - newMessage: '', - showAddPublicKey: false, - newPublicKey: null, - showRawMessage: false, - rawMessage: null, - } - }, - methods: { - sendMessage: async function () { }, - buildCustomerLabel: function (c) { - let label = `${c.profile.name || 'unknown'} ${c.profile.about || ''}` - if (c.unread_messages) { - label += `[new: ${c.unread_messages}]` - } - label += ` (${c.public_key.slice(0, 16)}...${c.public_key.slice( - c.public_key.length - 16 - )}` - return label - }, - getDirectMessages: async function (pubkey) { - if (!pubkey) { - this.messages = [] - return - } - try { - const { data } = await LNbits.api.request( - 'GET', - '/nostrmarket/api/v1/message/' + pubkey, - this.inkey - ) - this.messages = data - - this.focusOnChatBox(this.messages.length - 1) - } catch (error) { - LNbits.utils.notifyApiError(error) - } - }, - getCustomers: async function () { - try { - const { data } = await LNbits.api.request( - 'GET', - '/nostrmarket/api/v1/customer', - this.inkey - ) - this.customers = data - this.unreadMessages = data.filter(c => c.unread_messages).length - } catch (error) { - LNbits.utils.notifyApiError(error) - } - }, - - sendDirectMesage: async function () { - try { - const { data } = await LNbits.api.request( - 'POST', - '/nostrmarket/api/v1/message', - this.adminkey, - { - message: this.newMessage, - public_key: this.activePublicKey - } - ) - this.messages = this.messages.concat([data]) - this.newMessage = '' - this.focusOnChatBox(this.messages.length - 1) - } catch (error) { - LNbits.utils.notifyApiError(error) - } - }, - addPublicKey: async function () { - try { - const { data } = await LNbits.api.request( - 'POST', - '/nostrmarket/api/v1/customer', - this.adminkey, - { - public_key: this.newPublicKey, - merchant_id: this.merchantId, - unread_messages: 0 - } - ) - this.newPublicKey = null - this.activePublicKey = data.public_key - await this.selectActiveCustomer() - } catch (error) { - LNbits.utils.notifyApiError(error) - } finally { - this.showAddPublicKey = false - } - }, - handleNewMessage: async function (data) { - if (data.customerPubkey === this.activePublicKey) { - this.messages.push(data.dm) - this.focusOnChatBox(this.messages.length - 1) - // focus back on input box - } - this.getCustomersDebounced() - }, - showOrderDetails: function (orderId, eventId) { - this.$emit('order-selected', { orderId, eventId }) - }, - showClientOrders: function () { - this.$emit('customer-selected', this.activePublicKey) - }, - selectActiveCustomer: async function () { - await this.getDirectMessages(this.activePublicKey) - await this.getCustomers() - }, - showMessageRawData: function (index) { - this.rawMessage = this.messages[index]?.message - this.showRawMessage = true - }, - focusOnChatBox: function (index) { - setTimeout(() => { - const lastChatBox = document.getElementsByClassName( - `chat-mesage-index-${index}` - ) - if (lastChatBox && lastChatBox[0]) { - lastChatBox[0].scrollIntoView() - } - }, 100) - } - }, - created: async function () { - await this.getCustomers() - this.getCustomersDebounced = _.debounce(this.getCustomers, 2000, false) - } - }) -} diff --git a/static/components/key-pair.js b/static/components/key-pair.js new file mode 100644 index 0000000..5bf9d23 --- /dev/null +++ b/static/components/key-pair.js @@ -0,0 +1,22 @@ +window.app.component('key-pair', { + name: 'key-pair', + template: '#key-pair', + delimiters: ['${', '}'], + props: ['public-key', 'private-key'], + data: function () { + return { + showPrivateKey: false + } + }, + methods: { + copyText: function (text, message, position) { + var notify = this.$q.notify + Quasar.copyToClipboard(text).then(function () { + notify({ + message: message || 'Copied to clipboard!', + position: position || 'bottom' + }) + }) + } + } +}) diff --git a/static/components/key-pair/key-pair.html b/static/components/key-pair/key-pair.html deleted file mode 100644 index a0657fa..0000000 --- a/static/components/key-pair/key-pair.html +++ /dev/null @@ -1,44 +0,0 @@ - - - - Public Key - - - Show Private Key - - - - - - - - - - - Click to copy - - - - - - - - - Click to copy - - - - - diff --git a/static/components/key-pair/key-pair.js b/static/components/key-pair/key-pair.js deleted file mode 100644 index bee16b4..0000000 --- a/static/components/key-pair/key-pair.js +++ /dev/null @@ -1,25 +0,0 @@ -async function keyPair(path) { - const template = await loadTemplateAsync(path) - Vue.component('key-pair', { - name: 'key-pair', - template, - - props: ['public-key', 'private-key'], - data: function () { - return { - showPrivateKey: false - } - }, - methods: { - copyText: function (text, message, position) { - var notify = this.$q.notify - Quasar.utils.copyToClipboard(text).then(function () { - notify({ - message: message || 'Copied to clipboard!', - position: position || 'bottom' - }) - }) - } - } - }) -} diff --git a/static/components/merchant-details.js b/static/components/merchant-details.js new file mode 100644 index 0000000..4581b4e --- /dev/null +++ b/static/components/merchant-details.js @@ -0,0 +1,102 @@ +window.app.component('merchant-details', { + name: 'merchant-details', + template: '#merchant-details', + props: ['merchant-id', 'adminkey', 'inkey', 'showKeys'], + delimiters: ['${', '}'], + data: function () { + return {} + }, + methods: { + toggleShowKeys: async function () { + this.$emit('toggle-show-keys') + }, + + republishMerchantData: async function () { + try { + await LNbits.api.request( + 'PUT', + `/nostrmarket/api/v1/merchant/${this.merchantId}/nostr`, + this.adminkey + ) + this.$q.notify({ + type: 'positive', + message: 'Merchant data republished to Nostr', + timeout: 5000 + }) + } catch (error) { + console.warn(error) + LNbits.utils.notifyApiError(error) + } + }, + requeryMerchantData: async function () { + try { + await LNbits.api.request( + 'GET', + `/nostrmarket/api/v1/merchant/${this.merchantId}/nostr`, + this.adminkey + ) + this.$q.notify({ + type: 'positive', + message: 'Merchant data refreshed from Nostr', + timeout: 5000 + }) + } catch (error) { + console.warn(error) + LNbits.utils.notifyApiError(error) + } + }, + deleteMerchantTables: function () { + LNbits.utils + .confirmDialog( + ` + Stalls, products and orders will be deleted also! + Are you sure you want to delete this merchant? + ` + ) + .onOk(async () => { + try { + await LNbits.api.request( + 'DELETE', + '/nostrmarket/api/v1/merchant/' + this.merchantId, + this.adminkey + ) + this.$emit('merchant-deleted', this.merchantId) + this.$q.notify({ + type: 'positive', + message: 'Merchant Deleted', + timeout: 5000 + }) + } catch (error) { + console.warn(error) + LNbits.utils.notifyApiError(error) + } + }) + }, + deleteMerchantFromNostr: function () { + LNbits.utils + .confirmDialog( + ` + Do you want to remove the merchant from Nostr? + ` + ) + .onOk(async () => { + try { + await LNbits.api.request( + 'DELETE', + `/nostrmarket/api/v1/merchant/${this.merchantId}/nostr`, + this.adminkey + ) + this.$q.notify({ + type: 'positive', + message: 'Merchant Deleted from Nostr', + timeout: 5000 + }) + } catch (error) { + console.warn(error) + LNbits.utils.notifyApiError(error) + } + }) + } + }, + created: async function () {} +}) diff --git a/static/components/merchant-details/merchant-details.js b/static/components/merchant-details/merchant-details.js deleted file mode 100644 index 31dcfac..0000000 --- a/static/components/merchant-details/merchant-details.js +++ /dev/null @@ -1,108 +0,0 @@ -async function merchantDetails(path) { - const template = await loadTemplateAsync(path) - Vue.component('merchant-details', { - name: 'merchant-details', - props: ['merchant-id', 'adminkey', 'inkey'], - template, - - data: function () { - return { - showKeys: false - } - }, - methods: { - toggleMerchantKeys: async function () { - this.showKeys = !this.showKeys - this.$emit('show-keys', this.showKeys) - }, - - republishMerchantData: async function () { - try { - await LNbits.api.request( - 'PUT', - `/nostrmarket/api/v1/merchant/${this.merchantId}/nostr`, - this.adminkey - ) - this.$q.notify({ - type: 'positive', - message: 'Merchant data republished to Nostr', - timeout: 5000 - }) - } catch (error) { - console.warn(error) - LNbits.utils.notifyApiError(error) - } - }, - requeryMerchantData: async function () { - try { - await LNbits.api.request( - 'GET', - `/nostrmarket/api/v1/merchant/${this.merchantId}/nostr`, - this.adminkey - ) - this.$q.notify({ - type: 'positive', - message: 'Merchant data refreshed from Nostr', - timeout: 5000 - }) - } catch (error) { - console.warn(error) - LNbits.utils.notifyApiError(error) - } - }, - deleteMerchantTables: function () { - LNbits.utils - .confirmDialog( - ` - Stalls, products and orders will be deleted also! - Are you sure you want to delete this merchant? - ` - ) - .onOk(async () => { - try { - await LNbits.api.request( - 'DELETE', - '/nostrmarket/api/v1/merchant/' + this.merchantId, - this.adminkey - ) - this.$emit('merchant-deleted', this.merchantId) - this.$q.notify({ - type: 'positive', - message: 'Merchant Deleted', - timeout: 5000 - }) - } catch (error) { - console.warn(error) - LNbits.utils.notifyApiError(error) - } - }) - }, - deleteMerchantFromNostr: function () { - LNbits.utils - .confirmDialog( - ` - Do you want to remove the merchant from Nostr? - ` - ) - .onOk(async () => { - try { - await LNbits.api.request( - 'DELETE', - `/nostrmarket/api/v1/merchant/${this.merchantId}/nostr`, - this.adminkey - ) - this.$q.notify({ - type: 'positive', - message: 'Merchant Deleted from Nostr', - timeout: 5000 - }) - } catch (error) { - console.warn(error) - LNbits.utils.notifyApiError(error) - } - }) - } - }, - created: async function () {} - }) -} diff --git a/static/components/order-list.js b/static/components/order-list.js new file mode 100644 index 0000000..f936fba --- /dev/null +++ b/static/components/order-list.js @@ -0,0 +1,406 @@ +window.app.component('order-list', { + name: 'order-list', + props: ['stall-id', 'customer-pubkey-filter', 'adminkey', 'inkey'], + template: '#order-list', + delimiters: ['${', '}'], + watch: { + customerPubkeyFilter: async function (n) { + this.search.publicKey = n + this.search.isPaid = {label: 'All', id: null} + this.search.isShipped = {label: 'All', id: null} + await this.getOrders() + } + }, + + data: function () { + return { + orders: [], + stalls: [], + selectedOrder: null, + shippingMessage: '', + showShipDialog: false, + filter: '', + search: { + publicKey: null, + isPaid: { + label: 'All', + id: null + }, + isShipped: { + label: 'All', + id: null + }, + restoring: false + }, + customers: [], + ternaryOptions: [ + { + label: 'All', + id: null + }, + { + label: 'Yes', + id: 'true' + }, + { + label: 'No', + id: 'false' + } + ], + zoneOptions: [], + ordersTable: { + columns: [ + { + name: '', + align: 'left', + label: '', + field: '' + }, + { + name: 'id', + align: 'left', + label: 'Order ID', + field: 'id' + }, + { + name: 'total', + align: 'left', + label: 'Total Sats', + field: 'total' + }, + { + name: 'fiat', + align: 'left', + label: 'Total Fiat', + field: 'fiat' + }, + { + name: 'paid', + align: 'left', + label: 'Paid', + field: 'paid' + }, + { + name: 'shipped', + align: 'left', + label: 'Shipped', + field: 'shipped' + }, + { + name: 'public_key', + align: 'left', + label: 'Customer', + field: 'pubkey' + }, + { + name: 'event_created_at', + align: 'left', + label: 'Created At', + field: 'event_created_at' + } + ], + pagination: { + rowsPerPage: 10 + } + } + } + }, + computed: { + customerOptions: function () { + const options = this.customers.map(c => ({ + label: this.buildCustomerLabel(c), + value: c.public_key + })) + options.unshift({label: 'All', value: null, id: null}) + return options + } + }, + methods: { + toShortId: function (value) { + return value.substring(0, 5) + '...' + value.substring(value.length - 5) + }, + formatDate: function (value) { + return Quasar.date.formatDate(new Date(value * 1000), 'YYYY-MM-DD HH:mm') + }, + satBtc(val, showUnit = true) { + return satOrBtc(val, showUnit, true) + }, + formatFiat(value, currency) { + return Math.trunc(value) + ' ' + currency + }, + shortLabel(value = '') { + if (value.length <= 44) return value + return value.substring(0, 20) + '...' + }, + productName: function (order, productId) { + product = order.extra.products.find(p => p.id === productId) + if (product) { + return product.name + } + return '' + }, + productPrice: function (order, productId) { + product = order.extra.products.find(p => p.id === productId) + if (product) { + return `${product.price} ${order.extra.currency}` + } + return '' + }, + orderTotal: function (order) { + const productCost = order.items.reduce((t, item) => { + product = order.extra.products.find(p => p.id === item.product_id) + return t + item.quantity * product.price + }, 0) + return productCost + order.extra.shipping_cost + }, + getOrders: async function () { + try { + const ordersPath = this.stallId + ? `stall/order/${this.stallId}` + : 'order' + + const query = [] + if (this.search.publicKey) { + query.push(`pubkey=${this.search.publicKey}`) + } + if (this.search.isPaid.id) { + query.push(`paid=${this.search.isPaid.id}`) + } + if (this.search.isShipped.id) { + query.push(`shipped=${this.search.isShipped.id}`) + } + const {data} = await LNbits.api.request( + 'GET', + `/nostrmarket/api/v1/${ordersPath}?${query.join('&')}`, + this.inkey + ) + this.orders = data.map(s => ({...s, expanded: false})) + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + getOrder: async function (orderId) { + try { + const {data} = await LNbits.api.request( + 'GET', + `/nostrmarket/api/v1/order/${orderId}`, + this.inkey + ) + return {...data, expanded: false, isNew: true} + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + restoreOrder: async function (eventId) { + console.log('### restoreOrder', eventId) + try { + this.search.restoring = true + const {data} = await LNbits.api.request( + 'PUT', + `/nostrmarket/api/v1/order/restore/${eventId}`, + this.adminkey + ) + await this.getOrders() + this.$q.notify({ + type: 'positive', + message: 'Order restored!' + }) + return data + } catch (error) { + LNbits.utils.notifyApiError(error) + } finally { + this.search.restoring = false + } + }, + restoreOrders: async function () { + try { + this.search.restoring = true + await LNbits.api.request( + 'PUT', + `/nostrmarket/api/v1/orders/restore`, + this.adminkey + ) + await this.getOrders() + this.$q.notify({ + type: 'positive', + message: 'Orders restored!' + }) + } catch (error) { + LNbits.utils.notifyApiError(error) + } finally { + this.search.restoring = false + } + }, + reissueOrderInvoice: async function (order) { + try { + const {data} = await LNbits.api.request( + 'PUT', + `/nostrmarket/api/v1/order/reissue`, + this.adminkey, + { + id: order.id, + shipping_id: order.shipping_id + } + ) + this.$q.notify({ + type: 'positive', + message: 'Order invoice reissued!' + }) + data.expanded = order.expanded + + const i = this.orders.map(o => o.id).indexOf(order.id) + if (i !== -1) { + this.orders[i] = {...this.orders[i], ...data} + } + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + updateOrderShipped: async function () { + this.selectedOrder.shipped = !this.selectedOrder.shipped + try { + await LNbits.api.request( + 'PATCH', + `/nostrmarket/api/v1/order/${this.selectedOrder.id}`, + this.adminkey, + { + id: this.selectedOrder.id, + message: this.shippingMessage, + shipped: this.selectedOrder.shipped + } + ) + this.$q.notify({ + type: 'positive', + message: 'Order updated!' + }) + } catch (error) { + LNbits.utils.notifyApiError(error) + } + this.showShipDialog = false + }, + addOrder: async function (data) { + if ( + !this.search.publicKey || + this.search.publicKey === data.customerPubkey + ) { + const orderData = JSON.parse(data.dm.message) + const i = this.orders.map(o => o.id).indexOf(orderData.id) + if (i === -1) { + const order = await this.getOrder(orderData.id) + this.orders.unshift(order) + } + } + }, + orderSelected: async function (orderId, eventId) { + const order = await this.getOrder(orderId) + if (!order) { + LNbits.utils + .confirmDialog( + 'Order could not be found. Do you want to restore it from this direct message?' + ) + .onOk(async () => { + const restoredOrder = await this.restoreOrder(eventId) + console.log('### restoredOrder', restoredOrder) + if (restoredOrder) { + restoredOrder.expanded = true + restoredOrder.isNew = false + this.orders = [restoredOrder] + } + }) + return + } + order.expanded = true + order.isNew = false + this.orders = [order] + }, + getZones: async function () { + try { + const {data} = await LNbits.api.request( + 'GET', + '/nostrmarket/api/v1/zone', + this.inkey + ) + return data.map(z => ({ + id: z.id, + value: z.id, + label: z.name + ? `${z.name} (${z.countries.join(', ')})` + : z.countries.join(', ') + })) + } catch (error) { + LNbits.utils.notifyApiError(error) + } + return [] + }, + getStalls: async function (pending = false) { + try { + const {data} = await LNbits.api.request( + 'GET', + `/nostrmarket/api/v1/stall?pending=${pending}`, + this.inkey + ) + return data.map(s => ({...s, expanded: false})) + } catch (error) { + LNbits.utils.notifyApiError(error) + } + return [] + }, + getStallZones: function (stallId) { + const stall = this.stalls.find(s => s.id === stallId) + if (!stall) return [] + + return this.zoneOptions.filter(z => + stall.shipping_zones.find(s => s.id === z.id) + ) + }, + showShipOrderDialog: function (order) { + this.selectedOrder = order + this.shippingMessage = order.shipped + ? 'The order has been shipped!' + : 'The order has NOT yet been shipped!' + + // do not change the status yet + this.selectedOrder.shipped = !order.shipped + this.showShipDialog = true + }, + customerSelected: function (customerPubkey) { + this.$emit('customer-selected', customerPubkey) + }, + getCustomers: async function () { + try { + const {data} = await LNbits.api.request( + 'GET', + '/nostrmarket/api/v1/customer', + this.inkey + ) + this.customers = data + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + buildCustomerLabel: function (c) { + let label = `${c.profile.name || 'unknown'} ${c.profile.about || ''}` + if (c.unread_messages) { + label += `[new: ${c.unread_messages}]` + } + label += ` (${c.public_key.slice(0, 16)}...${c.public_key.slice( + c.public_key.length - 16 + )}` + return label + }, + orderPaid: function (orderId) { + const order = this.orders.find(o => o.id === orderId) + if (order) { + order.paid = true + } + } + }, + created: async function () { + if (this.stallId) { + await this.getOrders() + } + await this.getCustomers() + this.zoneOptions = await this.getZones() + this.stalls = await this.getStalls() + } +}) diff --git a/static/components/order-list/order-list.html b/static/components/order-list/order-list.html deleted file mode 100644 index 44feb44..0000000 --- a/static/components/order-list/order-list.html +++ /dev/null @@ -1,212 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - Restore Orders - Restore previous orders from Nostr - - - - - - - - - - - - - - - - {{toShortId(props.row.id)}} - new - - {{satBtc(props.row.total)}} - - - - {{orderTotal(props.row)}} {{props.row.extra.currency}} - - - - - - - - - - - - - {{toShortId(props.row.public_key)}} - - - - {{formatDate(props.row.event_created_at)}} - - - - - - Products: - - - Quantity - - Name - Price - - - - - - - - - - {{item.quantity}} - x - - - {{shortLabel(productName(props.row, item.product_id))}} - - - - {{productPrice(props.row, item.product_id)}} - - - - - - - Shipping Cost - - {{props.row.extra.shipping_cost}} {{props.row.extra.currency}} - - - - - - - - Exchange Rate (1 BTC): - - - - - - - Error: - - - - - - - Order ID: - - - - - - - - Customer Public Key: - - - - - - - - Address: - - - - - - - - Phone: - - - - - - - Email: - - - - - - - Shipping Zone: - - - - - - - Invoice ID: - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Cancel - - - - - diff --git a/static/components/order-list/order-list.js b/static/components/order-list/order-list.js deleted file mode 100644 index 2dc721a..0000000 --- a/static/components/order-list/order-list.js +++ /dev/null @@ -1,409 +0,0 @@ -async function orderList(path) { - const template = await loadTemplateAsync(path) - Vue.component('order-list', { - name: 'order-list', - props: ['stall-id', 'customer-pubkey-filter', 'adminkey', 'inkey'], - template, - - watch: { - customerPubkeyFilter: async function (n) { - this.search.publicKey = n - this.search.isPaid = { label: 'All', id: null } - this.search.isShipped = { label: 'All', id: null } - await this.getOrders() - } - }, - - data: function () { - return { - orders: [], - stalls: [], - selectedOrder: null, - shippingMessage: '', - showShipDialog: false, - filter: '', - search: { - publicKey: null, - isPaid: { - label: 'All', - id: null - }, - isShipped: { - label: 'All', - id: null - }, - restoring: false - }, - customers: [], - ternaryOptions: [ - { - label: 'All', - id: null - }, - { - label: 'Yes', - id: 'true' - }, - { - label: 'No', - id: 'false' - } - ], - zoneOptions: [], - ordersTable: { - columns: [ - { - name: '', - align: 'left', - label: '', - field: '' - }, - { - name: 'id', - align: 'left', - label: 'Order ID', - field: 'id' - }, - { - name: 'total', - align: 'left', - label: 'Total Sats', - field: 'total' - }, - { - name: 'fiat', - align: 'left', - label: 'Total Fiat', - field: 'fiat' - }, - { - name: 'paid', - align: 'left', - label: 'Paid', - field: 'paid' - }, - { - name: 'shipped', - align: 'left', - label: 'Shipped', - field: 'shipped' - }, - { - name: 'public_key', - align: 'left', - label: 'Customer', - field: 'pubkey' - }, - { - name: 'event_created_at', - align: 'left', - label: 'Created At', - field: 'event_created_at' - } - ], - pagination: { - rowsPerPage: 10 - } - } - } - }, - computed: { - customerOptions: function () { - const options = this.customers.map(c => ({ label: this.buildCustomerLabel(c), value: c.public_key })) - options.unshift({ label: 'All', value: null, id: null }) - return options - } - }, - methods: { - toShortId: function (value) { - return value.substring(0, 5) + '...' + value.substring(value.length - 5) - }, - formatDate: function (value) { - return Quasar.utils.date.formatDate( - new Date(value * 1000), - 'YYYY-MM-DD HH:mm' - ) - }, - satBtc(val, showUnit = true) { - return satOrBtc(val, showUnit, true) - }, - formatFiat(value, currency) { - return Math.trunc(value) + ' ' + currency - }, - shortLabel(value = ''){ - if (value.length <= 44) return value - return value.substring(0, 20) + '...' - }, - productName: function (order, productId) { - product = order.extra.products.find(p => p.id === productId) - if (product) { - return product.name - } - return '' - }, - productPrice: function (order, productId) { - product = order.extra.products.find(p => p.id === productId) - if (product) { - return `${product.price} ${order.extra.currency}` - } - return '' - }, - orderTotal: function (order) { - const productCost = order.items.reduce((t, item) => { - product = order.extra.products.find(p => p.id === item.product_id) - return t + item.quantity * product.price - }, 0) - return productCost + order.extra.shipping_cost - }, - getOrders: async function () { - try { - const ordersPath = this.stallId - ? `stall/order/${this.stallId}` - : 'order' - - const query = [] - if (this.search.publicKey) { - query.push(`pubkey=${this.search.publicKey}`) - } - if (this.search.isPaid.id) { - query.push(`paid=${this.search.isPaid.id}`) - } - if (this.search.isShipped.id) { - query.push(`shipped=${this.search.isShipped.id}`) - } - const { data } = await LNbits.api.request( - 'GET', - `/nostrmarket/api/v1/${ordersPath}?${query.join('&')}`, - this.inkey - ) - this.orders = data.map(s => ({ ...s, expanded: false })) - } catch (error) { - LNbits.utils.notifyApiError(error) - } - }, - getOrder: async function (orderId) { - try { - const { data } = await LNbits.api.request( - 'GET', - `/nostrmarket/api/v1/order/${orderId}`, - this.inkey - ) - return { ...data, expanded: false, isNew: true } - } catch (error) { - LNbits.utils.notifyApiError(error) - } - }, - restoreOrder: async function (eventId) { - console.log('### restoreOrder', eventId) - try { - this.search.restoring = true - const {data} = await LNbits.api.request( - 'PUT', - `/nostrmarket/api/v1/order/restore/${eventId}`, - this.adminkey - ) - await this.getOrders() - this.$q.notify({ - type: 'positive', - message: 'Order restored!' - }) - return data - } catch (error) { - LNbits.utils.notifyApiError(error) - } finally { - this.search.restoring = false - } - }, - restoreOrders: async function () { - try { - this.search.restoring = true - await LNbits.api.request( - 'PUT', - `/nostrmarket/api/v1/orders/restore`, - this.adminkey - ) - await this.getOrders() - this.$q.notify({ - type: 'positive', - message: 'Orders restored!' - }) - } catch (error) { - LNbits.utils.notifyApiError(error) - } finally { - this.search.restoring = false - } - }, - reissueOrderInvoice: async function (order) { - try { - const { data } = await LNbits.api.request( - 'PUT', - `/nostrmarket/api/v1/order/reissue`, - this.adminkey, - { - id: order.id, - shipping_id: order.shipping_id - } - ) - this.$q.notify({ - type: 'positive', - message: 'Order invoice reissued!' - }) - data.expanded = order.expanded - - const i = this.orders.map(o => o.id).indexOf(order.id) - if (i !== -1) { - this.orders[i] = { ...this.orders[i], ...data } - } - } catch (error) { - LNbits.utils.notifyApiError(error) - } - }, - updateOrderShipped: async function () { - this.selectedOrder.shipped = !this.selectedOrder.shipped - try { - await LNbits.api.request( - 'PATCH', - `/nostrmarket/api/v1/order/${this.selectedOrder.id}`, - this.adminkey, - { - id: this.selectedOrder.id, - message: this.shippingMessage, - shipped: this.selectedOrder.shipped - } - ) - this.$q.notify({ - type: 'positive', - message: 'Order updated!' - }) - } catch (error) { - LNbits.utils.notifyApiError(error) - } - this.showShipDialog = false - }, - addOrder: async function (data) { - if ( - !this.search.publicKey || - this.search.publicKey === data.customerPubkey - ) { - const orderData = JSON.parse(data.dm.message) - const i = this.orders.map(o => o.id).indexOf(orderData.id) - if (i === -1) { - const order = await this.getOrder(orderData.id) - this.orders.unshift(order) - } - - } - }, - orderSelected: async function (orderId, eventId) { - const order = await this.getOrder(orderId) - if (!order) { - LNbits.utils - .confirmDialog( - "Order could not be found. Do you want to restore it from this direct message?" - ) - .onOk(async () => { - const restoredOrder = await this.restoreOrder(eventId) - console.log('### restoredOrder', restoredOrder) - if (restoredOrder) { - restoredOrder.expanded = true - restoredOrder.isNew = false - this.orders = [restoredOrder] - } - - }) - return - } - order.expanded = true - order.isNew = false - this.orders = [order] - }, - getZones: async function () { - try { - const { data } = await LNbits.api.request( - 'GET', - '/nostrmarket/api/v1/zone', - this.inkey - ) - return data.map(z => ({ - id: z.id, - value: z.id, - label: z.name - ? `${z.name} (${z.countries.join(', ')})` - : z.countries.join(', ') - })) - } catch (error) { - LNbits.utils.notifyApiError(error) - } - return [] - }, - getStalls: async function (pending = false) { - try { - const { data } = await LNbits.api.request( - 'GET', - `/nostrmarket/api/v1/stall?pending=${pending}`, - this.inkey - ) - return data.map(s => ({ ...s, expanded: false })) - } catch (error) { - LNbits.utils.notifyApiError(error) - } - return [] - }, - getStallZones: function (stallId) { - const stall = this.stalls.find(s => s.id === stallId) - if (!stall) return [] - - return this.zoneOptions.filter(z => stall.shipping_zones.find(s => s.id === z.id)) - }, - showShipOrderDialog: function (order) { - this.selectedOrder = order - this.shippingMessage = order.shipped - ? 'The order has been shipped!' - : 'The order has NOT yet been shipped!' - - // do not change the status yet - this.selectedOrder.shipped = !order.shipped - this.showShipDialog = true - }, - customerSelected: function (customerPubkey) { - this.$emit('customer-selected', customerPubkey) - }, - getCustomers: async function () { - try { - const { data } = await LNbits.api.request( - 'GET', - '/nostrmarket/api/v1/customer', - this.inkey - ) - this.customers = data - } catch (error) { - LNbits.utils.notifyApiError(error) - } - }, - buildCustomerLabel: function (c) { - let label = `${c.profile.name || 'unknown'} ${c.profile.about || ''}` - if (c.unread_messages) { - label += `[new: ${c.unread_messages}]` - } - label += ` (${c.public_key.slice(0, 16)}...${c.public_key.slice( - c.public_key.length - 16 - )}` - return label - }, - orderPaid: function (orderId) { - const order = this.orders.find(o => o.id === orderId) - if (order) { - order.paid = true - } - } - }, - created: async function () { - if (this.stallId) { - await this.getOrders() - } - await this.getCustomers() - this.zoneOptions = await this.getZones() - this.stalls = await this.getStalls() - } - }) -} diff --git a/static/components/shipping-zones.js b/static/components/shipping-zones.js new file mode 100644 index 0000000..742021a --- /dev/null +++ b/static/components/shipping-zones.js @@ -0,0 +1,183 @@ +window.app.component('shipping-zones', { + name: 'shipping-zones', + props: ['adminkey', 'inkey'], + template: '#shipping-zones', + delimiters: ['${', '}'], + data: function () { + return { + zones: [], + zoneDialog: { + showDialog: false, + data: { + id: null, + name: '', + countries: [], + cost: 0, + currency: 'sat' + } + }, + currencies: [], + shippingZoneOptions: [ + 'Free (digital)', + 'Flat rate', + 'Worldwide', + 'Europe', + 'Australia', + 'Austria', + 'Belgium', + 'Brazil', + 'Canada', + 'Denmark', + 'Finland', + 'France', + 'Germany', + 'Greece', + 'Hong Kong', + 'Hungary', + 'Ireland', + 'Indonesia', + 'Israel', + 'Italy', + 'Japan', + 'Kazakhstan', + 'Korea', + 'Luxembourg', + 'Malaysia', + 'Mexico', + 'Netherlands', + 'New Zealand', + 'Norway', + 'Poland', + 'Portugal', + 'Romania', + 'Russia', + 'Saudi Arabia', + 'Singapore', + 'Spain', + 'Sweden', + 'Switzerland', + 'Thailand', + 'Turkey', + 'Ukraine', + 'United Kingdom**', + 'United States***', + 'Vietnam', + 'China' + ] + } + }, + methods: { + openZoneDialog: function (data) { + data = data || { + id: null, + name: '', + countries: [], + cost: 0, + currency: 'sat' + } + this.zoneDialog.data = data + + this.zoneDialog.showDialog = true + }, + createZone: async function () { + try { + const {data} = await LNbits.api.request( + 'POST', + '/nostrmarket/api/v1/zone', + this.adminkey, + {} + ) + this.zones = data + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + getZones: async function () { + try { + const {data} = await LNbits.api.request( + 'GET', + '/nostrmarket/api/v1/zone', + this.inkey + ) + this.zones = data + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + sendZoneFormData: async function () { + this.zoneDialog.showDialog = false + if (this.zoneDialog.data.id) { + await this.updateShippingZone(this.zoneDialog.data) + } else { + await this.createShippingZone(this.zoneDialog.data) + } + await this.getZones() + }, + createShippingZone: async function (newZone) { + try { + await LNbits.api.request( + 'POST', + '/nostrmarket/api/v1/zone', + this.adminkey, + newZone + ) + this.$q.notify({ + type: 'positive', + message: 'Zone created!' + }) + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + updateShippingZone: async function (updatedZone) { + try { + await LNbits.api.request( + 'PATCH', + `/nostrmarket/api/v1/zone/${updatedZone.id}`, + this.adminkey, + updatedZone + ) + this.$q.notify({ + type: 'positive', + message: 'Zone updated!' + }) + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + deleteShippingZone: async function () { + try { + await LNbits.api.request( + 'DELETE', + `/nostrmarket/api/v1/zone/${this.zoneDialog.data.id}`, + this.adminkey + ) + this.$q.notify({ + type: 'positive', + message: 'Zone deleted!' + }) + await this.getZones() + this.zoneDialog.showDialog = false + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + async getCurrencies() { + try { + const {data} = await LNbits.api.request( + 'GET', + '/nostrmarket/api/v1/currencies', + this.inkey + ) + + this.currencies = ['sat', ...data] + } catch (error) { + LNbits.utils.notifyApiError(error) + } + } + }, + created: async function () { + await this.getZones() + await this.getCurrencies() + } +}) diff --git a/static/components/shipping-zones/shipping-zones.js b/static/components/shipping-zones/shipping-zones.js deleted file mode 100644 index 173d713..0000000 --- a/static/components/shipping-zones/shipping-zones.js +++ /dev/null @@ -1,186 +0,0 @@ -async function shippingZones(path) { - const template = await loadTemplateAsync(path) - Vue.component('shipping-zones', { - name: 'shipping-zones', - props: ['adminkey', 'inkey'], - template, - - data: function () { - return { - zones: [], - zoneDialog: { - showDialog: false, - data: { - id: null, - name: '', - countries: [], - cost: 0, - currency: 'sat' - } - }, - currencies: [], - shippingZoneOptions: [ - 'Free (digital)', - 'Flat rate', - 'Worldwide', - 'Europe', - 'Australia', - 'Austria', - 'Belgium', - 'Brazil', - 'Canada', - 'Denmark', - 'Finland', - 'France', - 'Germany', - 'Greece', - 'Hong Kong', - 'Hungary', - 'Ireland', - 'Indonesia', - 'Israel', - 'Italy', - 'Japan', - 'Kazakhstan', - 'Korea', - 'Luxembourg', - 'Malaysia', - 'Mexico', - 'Netherlands', - 'New Zealand', - 'Norway', - 'Poland', - 'Portugal', - 'Romania', - 'Russia', - 'Saudi Arabia', - 'Singapore', - 'Spain', - 'Sweden', - 'Switzerland', - 'Thailand', - 'Turkey', - 'Ukraine', - 'United Kingdom**', - 'United States***', - 'Vietnam', - 'China' - ] - } - }, - methods: { - openZoneDialog: function (data) { - data = data || { - id: null, - name: '', - countries: [], - cost: 0, - currency: 'sat' - } - this.zoneDialog.data = data - - this.zoneDialog.showDialog = true - }, - createZone: async function () { - try { - const {data} = await LNbits.api.request( - 'POST', - '/nostrmarket/api/v1/zone', - this.adminkey, - {} - ) - this.zones = data - } catch (error) { - LNbits.utils.notifyApiError(error) - } - }, - getZones: async function () { - try { - const {data} = await LNbits.api.request( - 'GET', - '/nostrmarket/api/v1/zone', - this.inkey - ) - this.zones = data - } catch (error) { - LNbits.utils.notifyApiError(error) - } - }, - sendZoneFormData: async function () { - this.zoneDialog.showDialog = false - if (this.zoneDialog.data.id) { - await this.updateShippingZone(this.zoneDialog.data) - } else { - await this.createShippingZone(this.zoneDialog.data) - } - await this.getZones() - }, - createShippingZone: async function (newZone) { - try { - await LNbits.api.request( - 'POST', - '/nostrmarket/api/v1/zone', - this.adminkey, - newZone - ) - this.$q.notify({ - type: 'positive', - message: 'Zone created!' - }) - } catch (error) { - LNbits.utils.notifyApiError(error) - } - }, - updateShippingZone: async function (updatedZone) { - try { - await LNbits.api.request( - 'PATCH', - `/nostrmarket/api/v1/zone/${updatedZone.id}`, - this.adminkey, - updatedZone - ) - this.$q.notify({ - type: 'positive', - message: 'Zone updated!' - }) - } catch (error) { - LNbits.utils.notifyApiError(error) - } - }, - deleteShippingZone: async function () { - try { - await LNbits.api.request( - 'DELETE', - `/nostrmarket/api/v1/zone/${this.zoneDialog.data.id}`, - this.adminkey - ) - this.$q.notify({ - type: 'positive', - message: 'Zone deleted!' - }) - await this.getZones() - this.zoneDialog.showDialog = false - } catch (error) { - LNbits.utils.notifyApiError(error) - } - }, - async getCurrencies() { - try { - const {data} = await LNbits.api.request( - 'GET', - '/nostrmarket/api/v1/currencies', - this.inkey - ) - - this.currencies = ['sat', ...data] - } catch (error) { - LNbits.utils.notifyApiError(error) - } - } - }, - created: async function () { - await this.getZones() - await this.getCurrencies() - } - }) -} diff --git a/static/components/stall-details.js b/static/components/stall-details.js new file mode 100644 index 0000000..be0633b --- /dev/null +++ b/static/components/stall-details.js @@ -0,0 +1,338 @@ +window.app.component('stall-details', { + name: 'stall-details', + template: '#stall-details', + delimiters: ['${', '}'], + props: [ + 'stall-id', + 'adminkey', + 'inkey', + 'wallet-options', + 'zone-options', + 'currencies' + ], + data: function () { + return { + tab: 'products', + stall: null, + products: [], + pendingProducts: [], + productDialog: { + showDialog: false, + showRestore: false, + url: true, + data: null + }, + productsFilter: '', + productsTable: { + columns: [ + { + name: 'delete', + align: 'left', + label: '', + field: '' + }, + { + name: 'edit', + align: 'left', + label: '', + field: '' + }, + { + name: 'activate', + align: 'left', + label: '', + field: '' + }, + + { + name: 'id', + align: 'left', + label: 'ID', + field: 'id' + }, + { + name: 'name', + align: 'left', + label: 'Name', + field: 'name' + }, + { + name: 'price', + align: 'left', + label: 'Price', + field: 'price' + }, + { + name: 'quantity', + align: 'left', + label: 'Quantity', + field: 'quantity' + } + ], + pagination: { + rowsPerPage: 10 + } + } + } + }, + computed: { + filteredZoneOptions: function () { + if (!this.stall) return [] + return this.zoneOptions.filter(z => z.currency === this.stall.currency) + } + }, + methods: { + mapStall: function (stall) { + stall.shipping_zones.forEach( + z => + (z.label = z.name + ? `${z.name} (${z.countries.join(', ')})` + : z.countries.join(', ')) + ) + return stall + }, + newEmtpyProductData: function () { + return { + id: null, + name: '', + categories: [], + images: [], + image: null, + price: 0, + + quantity: 0, + config: { + description: '', + use_autoreply: false, + autoreply_message: '', + shipping: (this.stall.shipping_zones || []).map(z => ({ + id: z.id, + name: z.name, + cost: 0 + })) + } + } + }, + getStall: async function () { + try { + const {data} = await LNbits.api.request( + 'GET', + '/nostrmarket/api/v1/stall/' + this.stallId, + this.inkey + ) + this.stall = this.mapStall(data) + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + updateStall: async function () { + try { + const {data} = await LNbits.api.request( + 'PUT', + '/nostrmarket/api/v1/stall/' + this.stallId, + this.adminkey, + this.stall + ) + this.stall = this.mapStall(data) + this.$emit('stall-updated', this.stall) + this.$q.notify({ + type: 'positive', + message: 'Stall Updated', + timeout: 5000 + }) + } catch (error) { + console.warn(error) + LNbits.utils.notifyApiError(error) + } + }, + deleteStall: function () { + LNbits.utils + .confirmDialog( + ` + Products and orders will be deleted also! + Are you sure you want to delete this stall? + ` + ) + .onOk(async () => { + try { + await LNbits.api.request( + 'DELETE', + '/nostrmarket/api/v1/stall/' + this.stallId, + this.adminkey + ) + this.$emit('stall-deleted', this.stallId) + this.$q.notify({ + type: 'positive', + message: 'Stall Deleted', + timeout: 5000 + }) + } catch (error) { + console.warn(error) + LNbits.utils.notifyApiError(error) + } + }) + }, + addProductImage: function () { + if (!isValidImageUrl(this.productDialog.data.image)) { + this.$q.notify({ + type: 'warning', + message: 'Not a valid image URL', + timeout: 5000 + }) + return + } + this.productDialog.data.images.push(this.productDialog.data.image) + this.productDialog.data.image = null + }, + removeProductImage: function (imageUrl) { + const index = this.productDialog.data.images.indexOf(imageUrl) + if (index !== -1) { + this.productDialog.data.images.splice(index, 1) + } + }, + getProducts: async function (pending = false) { + try { + const {data} = await LNbits.api.request( + 'GET', + `/nostrmarket/api/v1/stall/product/${this.stall.id}?pending=${pending}`, + this.inkey + ) + return data + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + sendProductFormData: function () { + const data = { + stall_id: this.stall.id, + id: this.productDialog.data.id, + name: this.productDialog.data.name, + + images: this.productDialog.data.images, + price: this.productDialog.data.price, + quantity: this.productDialog.data.quantity, + categories: this.productDialog.data.categories, + config: this.productDialog.data.config + } + this.productDialog.showDialog = false + if (this.productDialog.data.id) { + data.pending = false + this.updateProduct(data) + } else { + this.createProduct(data) + } + }, + updateProduct: async function (product) { + try { + const {data} = await LNbits.api.request( + 'PATCH', + '/nostrmarket/api/v1/product/' + product.id, + this.adminkey, + product + ) + const index = this.products.findIndex(r => r.id === product.id) + if (index !== -1) { + this.products.splice(index, 1, data) + } else { + this.products.unshift(data) + } + this.$q.notify({ + type: 'positive', + message: 'Product Updated', + timeout: 5000 + }) + } catch (error) { + console.warn(error) + LNbits.utils.notifyApiError(error) + } + }, + createProduct: async function (payload) { + try { + const {data} = await LNbits.api.request( + 'POST', + '/nostrmarket/api/v1/product', + this.adminkey, + payload + ) + this.products.unshift(data) + this.$q.notify({ + type: 'positive', + message: 'Product Created', + timeout: 5000 + }) + } catch (error) { + console.warn(error) + LNbits.utils.notifyApiError(error) + } + }, + editProduct: async function (product) { + const emptyShipping = this.newEmtpyProductData().config.shipping + this.productDialog.data = {...product} + this.productDialog.data.config.shipping = emptyShipping.map( + shippingZone => { + const existingShippingCost = (product.config.shipping || []).find( + ps => ps.id === shippingZone.id + ) + shippingZone.cost = existingShippingCost?.cost || 0 + return shippingZone + } + ) + + this.productDialog.showDialog = true + }, + deleteProduct: async function (productId) { + LNbits.utils + .confirmDialog('Are you sure you want to delete this product?') + .onOk(async () => { + try { + await LNbits.api.request( + 'DELETE', + '/nostrmarket/api/v1/product/' + productId, + this.adminkey + ) + this.products = _.reject(this.products, function (obj) { + return obj.id === productId + }) + this.$q.notify({ + type: 'positive', + message: 'Product deleted', + timeout: 5000 + }) + } catch (error) { + console.warn(error) + LNbits.utils.notifyApiError(error) + } + }) + }, + showNewProductDialog: async function (data) { + this.productDialog.data = data || this.newEmtpyProductData() + this.productDialog.showDialog = true + }, + openSelectPendingProductDialog: async function () { + this.productDialog.showRestore = true + this.pendingProducts = await this.getProducts(true) + }, + openRestoreProductDialog: async function (pendingProduct) { + pendingProduct.pending = true + await this.showNewProductDialog(pendingProduct) + }, + restoreAllPendingProducts: async function () { + for (const p of this.pendingProducts) { + p.pending = false + await this.updateProduct(p) + } + }, + customerSelectedForOrder: function (customerPubkey) { + this.$emit('customer-selected-for-order', customerPubkey) + }, + shortLabel(value = '') { + if (value.length <= 44) return value + return value.substring(0, 40) + '...' + } + }, + created: async function () { + await this.getStall() + this.products = await this.getProducts() + this.productDialog.data = this.newEmtpyProductData() + } +}) diff --git a/static/components/stall-details/stall-details.html b/static/components/stall-details/stall-details.html deleted file mode 100644 index 1a16461..0000000 --- a/static/components/stall-details/stall-details.html +++ /dev/null @@ -1,245 +0,0 @@ - - - - - - - - - - - ID: - - - - - - - Name: - - - - - - - Description: - - - - - - - Wallet: - - - - - - - - Currency: - - - - - - - Shipping Zones: - - - - - - - - - Update Stall - - - Delete Stall - - - - - - - - - - - - New Product - Create a new product - - - - - Restore Product - Restore existing product from Nostr - - - - - - - - - - - - - - - - - - - - - - {{props.row.id}} - {{shortLabel(props.row.name)}} - {{props.row.price}} - - {{props.row.quantity}} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Create Product - - Cancel - - - - - - - - - - - - - - - - - Restore - - - - - - - - There are no products to be restored. - - - Restore All - Close - - - - \ No newline at end of file diff --git a/static/components/stall-details/stall-details.js b/static/components/stall-details/stall-details.js deleted file mode 100644 index 32fcad5..0000000 --- a/static/components/stall-details/stall-details.js +++ /dev/null @@ -1,328 +0,0 @@ -async function stallDetails(path) { - const template = await loadTemplateAsync(path) - - Vue.component('stall-details', { - name: 'stall-details', - template, - - props: [ - 'stall-id', - 'adminkey', - 'inkey', - 'wallet-options', - 'zone-options', - 'currencies' - ], - data: function () { - return { - tab: 'products', - stall: null, - products: [], - pendingProducts: [], - productDialog: { - showDialog: false, - showRestore: false, - url: true, - data: null - }, - productsFilter: '', - productsTable: { - columns: [ - { - name: 'delete', - align: 'left', - label: '', - field: '' - }, - { - name: 'edit', - align: 'left', - label: '', - field: '' - }, - - { - name: 'id', - align: 'left', - label: 'ID', - field: 'id' - }, - { - name: 'name', - align: 'left', - label: 'Name', - field: 'name' - }, - { - name: 'price', - align: 'left', - label: 'Price', - field: 'price' - }, - { - name: 'quantity', - align: 'left', - label: 'Quantity', - field: 'quantity' - } - ], - pagination: { - rowsPerPage: 10 - } - } - } - }, - computed: { - filteredZoneOptions: function () { - if (!this.stall) return [] - return this.zoneOptions.filter(z => z.currency === this.stall.currency) - } - }, - methods: { - mapStall: function (stall) { - stall.shipping_zones.forEach( - z => - (z.label = z.name - ? `${z.name} (${z.countries.join(', ')})` - : z.countries.join(', ')) - ) - return stall - }, - newEmtpyProductData: function() { - return { - id: null, - name: '', - categories: [], - images: [], - image: null, - price: 0, - - quantity: 0, - config: { - description: '', - use_autoreply: false, - autoreply_message: '', - shipping: (this.stall.shipping_zones || []).map(z => ({id: z.id, name: z.name, cost: 0})) - } - } - }, - getStall: async function () { - try { - const { data } = await LNbits.api.request( - 'GET', - '/nostrmarket/api/v1/stall/' + this.stallId, - this.inkey - ) - this.stall = this.mapStall(data) - } catch (error) { - LNbits.utils.notifyApiError(error) - } - }, - updateStall: async function () { - try { - const { data } = await LNbits.api.request( - 'PUT', - '/nostrmarket/api/v1/stall/' + this.stallId, - this.adminkey, - this.stall - ) - this.stall = this.mapStall(data) - this.$emit('stall-updated', this.stall) - this.$q.notify({ - type: 'positive', - message: 'Stall Updated', - timeout: 5000 - }) - } catch (error) { - console.warn(error) - LNbits.utils.notifyApiError(error) - } - }, - deleteStall: function () { - LNbits.utils - .confirmDialog( - ` - Products and orders will be deleted also! - Are you sure you want to delete this stall? - ` - ) - .onOk(async () => { - try { - await LNbits.api.request( - 'DELETE', - '/nostrmarket/api/v1/stall/' + this.stallId, - this.adminkey - ) - this.$emit('stall-deleted', this.stallId) - this.$q.notify({ - type: 'positive', - message: 'Stall Deleted', - timeout: 5000 - }) - } catch (error) { - console.warn(error) - LNbits.utils.notifyApiError(error) - } - }) - }, - addProductImage: function () { - if (!isValidImageUrl(this.productDialog.data.image)) { - this.$q.notify({ - type: 'warning', - message: 'Not a valid image URL', - timeout: 5000 - }) - return - } - this.productDialog.data.images.push(this.productDialog.data.image) - this.productDialog.data.image = null - }, - removeProductImage: function (imageUrl) { - const index = this.productDialog.data.images.indexOf(imageUrl) - if (index !== -1) { - this.productDialog.data.images.splice(index, 1) - } - }, - getProducts: async function (pending = false) { - try { - const { data } = await LNbits.api.request( - 'GET', - `/nostrmarket/api/v1/stall/product/${this.stall.id}?pending=${pending}`, - this.inkey - ) - return data - } catch (error) { - LNbits.utils.notifyApiError(error) - } - }, - sendProductFormData: function () { - const data = { - stall_id: this.stall.id, - id: this.productDialog.data.id, - name: this.productDialog.data.name, - - images: this.productDialog.data.images, - price: this.productDialog.data.price, - quantity: this.productDialog.data.quantity, - categories: this.productDialog.data.categories, - config: this.productDialog.data.config - } - this.productDialog.showDialog = false - if (this.productDialog.data.id) { - data.pending = false - this.updateProduct(data) - } else { - this.createProduct(data) - } - }, - updateProduct: async function (product) { - try { - const { data } = await LNbits.api.request( - 'PATCH', - '/nostrmarket/api/v1/product/' + product.id, - this.adminkey, - product - ) - const index = this.products.findIndex(r => r.id === product.id) - if (index !== -1) { - this.products.splice(index, 1, data) - } else { - this.products.unshift(data) - } - this.$q.notify({ - type: 'positive', - message: 'Product Updated', - timeout: 5000 - }) - } catch (error) { - console.warn(error) - LNbits.utils.notifyApiError(error) - } - }, - createProduct: async function (payload) { - try { - const { data } = await LNbits.api.request( - 'POST', - '/nostrmarket/api/v1/product', - this.adminkey, - payload - ) - this.products.unshift(data) - this.$q.notify({ - type: 'positive', - message: 'Product Created', - timeout: 5000 - }) - } catch (error) { - console.warn(error) - LNbits.utils.notifyApiError(error) - } - }, - editProduct: async function (product) { - const emptyShipping = this.newEmtpyProductData().config.shipping - this.productDialog.data = { ...product } - this.productDialog.data.config.shipping = emptyShipping.map(shippingZone => { - const existingShippingCost = (product.config.shipping || []).find(ps => ps.id === shippingZone.id) - shippingZone.cost = existingShippingCost?.cost || 0 - return shippingZone - }) - - this.productDialog.showDialog = true - }, - deleteProduct: async function (productId) { - LNbits.utils - .confirmDialog('Are you sure you want to delete this product?') - .onOk(async () => { - try { - await LNbits.api.request( - 'DELETE', - '/nostrmarket/api/v1/product/' + productId, - this.adminkey - ) - this.products = _.reject(this.products, function (obj) { - return obj.id === productId - }) - this.$q.notify({ - type: 'positive', - message: 'Product deleted', - timeout: 5000 - }) - } catch (error) { - console.warn(error) - LNbits.utils.notifyApiError(error) - } - }) - }, - showNewProductDialog: async function (data) { - this.productDialog.data = data || this.newEmtpyProductData() - this.productDialog.showDialog = true - }, - openSelectPendingProductDialog: async function () { - this.productDialog.showRestore = true - this.pendingProducts = await this.getProducts(true) - }, - openRestoreProductDialog: async function (pendingProduct) { - pendingProduct.pending = true - await this.showNewProductDialog(pendingProduct) - }, - restoreAllPendingProducts: async function () { - for (const p of this.pendingProducts){ - p.pending = false - await this.updateProduct(p) - } - }, - customerSelectedForOrder: function (customerPubkey) { - this.$emit('customer-selected-for-order', customerPubkey) - }, - shortLabel(value = ''){ - if (value.length <= 44) return value - return value.substring(0, 40) + '...' - } - }, - created: async function () { - await this.getStall() - this.products = await this.getProducts() - this.productDialog.data = this.newEmtpyProductData() - } - }) -} diff --git a/static/components/stall-list.js b/static/components/stall-list.js new file mode 100644 index 0000000..1ef4d70 --- /dev/null +++ b/static/components/stall-list.js @@ -0,0 +1,262 @@ +window.app.component('stall-list', { + name: 'stall-list', + template: '#stall-list', + delimiters: ['${', '}'], + props: [`adminkey`, 'inkey', 'wallet-options'], + data: function () { + return { + filter: '', + stalls: [], + pendingStalls: [], + currencies: [], + stallDialog: { + show: false, + showRestore: false, + data: { + name: '', + description: '', + wallet: null, + currency: 'sat', + shippingZones: [] + } + }, + zoneOptions: [], + stallsTable: { + columns: [ + { + name: '', + align: 'left', + label: '', + field: '' + }, + { + name: 'id', + align: 'left', + label: 'Name', + field: 'id' + }, + { + name: 'currency', + align: 'left', + label: 'Currency', + field: 'currency' + }, + { + name: 'description', + align: 'left', + label: 'Description', + field: 'description' + }, + { + name: 'shippingZones', + align: 'left', + label: 'Shipping Zones', + field: 'shippingZones' + } + ], + pagination: { + rowsPerPage: 10 + } + } + } + }, + computed: { + filteredZoneOptions: function () { + return this.zoneOptions.filter( + z => z.currency === this.stallDialog.data.currency + ) + } + }, + methods: { + sendStallFormData: async function () { + const stallData = { + name: this.stallDialog.data.name, + wallet: this.stallDialog.data.wallet, + currency: this.stallDialog.data.currency, + shipping_zones: this.stallDialog.data.shippingZones, + config: { + description: this.stallDialog.data.description + } + } + if (this.stallDialog.data.id) { + stallData.id = this.stallDialog.data.id + await this.restoreStall(stallData) + } else { + await this.createStall(stallData) + } + }, + createStall: async function (stall) { + try { + const {data} = await LNbits.api.request( + 'POST', + '/nostrmarket/api/v1/stall', + this.adminkey, + stall + ) + this.stallDialog.show = false + data.expanded = false + this.stalls.unshift(data) + this.$q.notify({ + type: 'positive', + message: 'Stall created!' + }) + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + restoreStall: async function (stallData) { + try { + stallData.pending = false + const {data} = await LNbits.api.request( + 'PUT', + `/nostrmarket/api/v1/stall/${stallData.id}`, + this.adminkey, + stallData + ) + this.stallDialog.show = false + data.expanded = false + this.stalls.unshift(data) + this.$q.notify({ + type: 'positive', + message: 'Stall restored!' + }) + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + deleteStall: async function (pendingStall) { + LNbits.utils + .confirmDialog( + ` + Are you sure you want to delete this pending stall '${pendingStall.name}'? + ` + ) + .onOk(async () => { + try { + await LNbits.api.request( + 'DELETE', + '/nostrmarket/api/v1/stall/' + pendingStall.id, + this.adminkey + ) + this.$q.notify({ + type: 'positive', + message: 'Pending Stall Deleted', + timeout: 5000 + }) + } catch (error) { + console.warn(error) + LNbits.utils.notifyApiError(error) + } + }) + }, + getCurrencies: async function () { + try { + const {data} = await LNbits.api.request( + 'GET', + '/nostrmarket/api/v1/currencies', + this.inkey + ) + + return ['sat', ...data] + } catch (error) { + LNbits.utils.notifyApiError(error) + } + return [] + }, + getStalls: async function (pending = false) { + try { + const {data} = await LNbits.api.request( + 'GET', + `/nostrmarket/api/v1/stall?pending=${pending}`, + this.inkey + ) + return data.map(s => ({...s, expanded: false})) + } catch (error) { + LNbits.utils.notifyApiError(error) + } + return [] + }, + getZones: async function () { + try { + const {data} = await LNbits.api.request( + 'GET', + '/nostrmarket/api/v1/zone', + this.inkey + ) + return data.map(z => ({ + ...z, + label: z.name + ? `${z.name} (${z.countries.join(', ')})` + : z.countries.join(', ') + })) + } catch (error) { + LNbits.utils.notifyApiError(error) + } + return [] + }, + handleStallDeleted: function (stallId) { + this.stalls = _.reject(this.stalls, function (obj) { + return obj.id === stallId + }) + }, + handleStallUpdated: function (stall) { + const index = this.stalls.findIndex(r => r.id === stall.id) + if (index !== -1) { + stall.expanded = true + this.stalls.splice(index, 1, stall) + } + }, + openCreateStallDialog: async function (stallData) { + this.currencies = await this.getCurrencies() + this.zoneOptions = await this.getZones() + if (!this.zoneOptions || !this.zoneOptions.length) { + this.$q.notify({ + type: 'warning', + message: 'Please create a Shipping Zone first!' + }) + return + } + this.stallDialog.data = stallData || { + name: '', + description: '', + wallet: null, + currency: 'sat', + shippingZones: [] + } + this.stallDialog.show = true + }, + openSelectPendingStallDialog: async function () { + this.stallDialog.showRestore = true + this.pendingStalls = await this.getStalls(true) + }, + openRestoreStallDialog: async function (pendingStall) { + const shippingZonesIds = this.zoneOptions.map(z => z.id) + await this.openCreateStallDialog({ + id: pendingStall.id, + name: pendingStall.name, + description: pendingStall.config?.description, + currency: pendingStall.currency, + shippingZones: (pendingStall.shipping_zones || []) + .filter(z => shippingZonesIds.indexOf(z.id) !== -1) + .map(z => ({ + ...z, + label: z.name + ? `${z.name} (${z.countries.join(', ')})` + : z.countries.join(', ') + })) + }) + }, + customerSelectedForOrder: function (customerPubkey) { + this.$emit('customer-selected-for-order', customerPubkey) + }, + shortLabel(value = '') { + if (value.length <= 64) return value + return value.substring(0, 60) + '...' + } + }, + created: async function () { + this.stalls = await this.getStalls() + this.currencies = await this.getCurrencies() + this.zoneOptions = await this.getZones() + } +}) diff --git a/static/components/stall-list/stall-list.html b/static/components/stall-list/stall-list.html deleted file mode 100644 index 8981355..0000000 --- a/static/components/stall-list/stall-list.html +++ /dev/null @@ -1,117 +0,0 @@ - - - - - - - - New Stall - Create a new stall - - - - - Restore Stall - Restore existing stall from Nostr - - - - - - - - - - - - - - - - - - - {{shortLabel(props.row.name)}} - {{props.row.currency}} - - {{shortLabel(props.row.config.description)}} - - - - {{shortLabel(props.row.shipping_zones.filter(z => !!z.name).map(z => z.name).join(', '))}} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Cancel - - - - - - - - - - - - - - - Restore - - - - - - - - There are no stalls to be restored. - - - Close - - - - - \ No newline at end of file diff --git a/static/components/stall-list/stall-list.js b/static/components/stall-list/stall-list.js deleted file mode 100644 index e384895..0000000 --- a/static/components/stall-list/stall-list.js +++ /dev/null @@ -1,266 +0,0 @@ -async function stallList(path) { - const template = await loadTemplateAsync(path) - Vue.component('stall-list', { - name: 'stall-list', - template, - - props: [`adminkey`, 'inkey', 'wallet-options'], - data: function () { - return { - filter: '', - stalls: [], - pendingStalls: [], - currencies: [], - stallDialog: { - show: false, - showRestore: false, - data: { - name: '', - description: '', - wallet: null, - currency: 'sat', - shippingZones: [] - } - }, - zoneOptions: [], - stallsTable: { - columns: [ - { - name: '', - align: 'left', - label: '', - field: '' - }, - { - name: 'id', - align: 'left', - label: 'Name', - field: 'id' - }, - { - name: 'currency', - align: 'left', - label: 'Currency', - field: 'currency' - }, - { - name: 'description', - align: 'left', - label: 'Description', - field: 'description' - }, - { - name: 'shippingZones', - align: 'left', - label: 'Shipping Zones', - field: 'shippingZones' - } - ], - pagination: { - rowsPerPage: 10 - } - } - } - }, - computed: { - filteredZoneOptions: function () { - return this.zoneOptions.filter( - z => z.currency === this.stallDialog.data.currency - ) - } - }, - methods: { - sendStallFormData: async function () { - const stallData = { - name: this.stallDialog.data.name, - wallet: this.stallDialog.data.wallet, - currency: this.stallDialog.data.currency, - shipping_zones: this.stallDialog.data.shippingZones, - config: { - description: this.stallDialog.data.description - } - } - if (this.stallDialog.data.id) { - stallData.id = this.stallDialog.data.id - await this.restoreStall(stallData) - } else { - await this.createStall(stallData) - } - - }, - createStall: async function (stall) { - try { - const { data } = await LNbits.api.request( - 'POST', - '/nostrmarket/api/v1/stall', - this.adminkey, - stall - ) - this.stallDialog.show = false - data.expanded = false - this.stalls.unshift(data) - this.$q.notify({ - type: 'positive', - message: 'Stall created!' - }) - } catch (error) { - LNbits.utils.notifyApiError(error) - } - }, - restoreStall: async function (stallData) { - try { - stallData.pending = false - const { data } = await LNbits.api.request( - 'PUT', - `/nostrmarket/api/v1/stall/${stallData.id}`, - this.adminkey, - stallData - ) - this.stallDialog.show = false - data.expanded = false - this.stalls.unshift(data) - this.$q.notify({ - type: 'positive', - message: 'Stall restored!' - }) - } catch (error) { - LNbits.utils.notifyApiError(error) - } - }, - deleteStall: async function (pendingStall) { - LNbits.utils - .confirmDialog( - ` - Are you sure you want to delete this pending stall '${pendingStall.name}'? - ` - ) - .onOk(async () => { - try { - await LNbits.api.request( - 'DELETE', - '/nostrmarket/api/v1/stall/' + pendingStall.id, - this.adminkey - ) - this.$q.notify({ - type: 'positive', - message: 'Pending Stall Deleted', - timeout: 5000 - }) - } catch (error) { - console.warn(error) - LNbits.utils.notifyApiError(error) - } - }) - }, - getCurrencies: async function () { - try { - const { data } = await LNbits.api.request( - 'GET', - '/nostrmarket/api/v1/currencies', - this.inkey - ) - - return ['sat', ...data] - } catch (error) { - LNbits.utils.notifyApiError(error) - } - return [] - }, - getStalls: async function (pending = false) { - try { - const { data } = await LNbits.api.request( - 'GET', - `/nostrmarket/api/v1/stall?pending=${pending}`, - this.inkey - ) - return data.map(s => ({ ...s, expanded: false })) - } catch (error) { - LNbits.utils.notifyApiError(error) - } - return [] - }, - getZones: async function () { - try { - const { data } = await LNbits.api.request( - 'GET', - '/nostrmarket/api/v1/zone', - this.inkey - ) - return data.map(z => ({ - ...z, - label: z.name - ? `${z.name} (${z.countries.join(', ')})` - : z.countries.join(', ') - })) - } catch (error) { - LNbits.utils.notifyApiError(error) - } - return [] - }, - handleStallDeleted: function (stallId) { - this.stalls = _.reject(this.stalls, function (obj) { - return obj.id === stallId - }) - }, - handleStallUpdated: function (stall) { - const index = this.stalls.findIndex(r => r.id === stall.id) - if (index !== -1) { - stall.expanded = true - this.stalls.splice(index, 1, stall) - } - }, - openCreateStallDialog: async function (stallData) { - this.currencies = await this.getCurrencies() - this.zoneOptions = await this.getZones() - if (!this.zoneOptions || !this.zoneOptions.length) { - this.$q.notify({ - type: 'warning', - message: 'Please create a Shipping Zone first!' - }) - return - } - this.stallDialog.data = stallData || { - name: '', - description: '', - wallet: null, - currency: 'sat', - shippingZones: [] - } - this.stallDialog.show = true - }, - openSelectPendingStallDialog: async function () { - this.stallDialog.showRestore = true - this.pendingStalls = await this.getStalls(true) - }, - openRestoreStallDialog: async function (pendingStall) { - const shippingZonesIds = this.zoneOptions.map(z => z.id) - await this.openCreateStallDialog({ - id: pendingStall.id, - name: pendingStall.name, - description: pendingStall.config?.description, - currency: pendingStall.currency, - shippingZones: (pendingStall.shipping_zones || []) - .filter(z => shippingZonesIds.indexOf(z.id) !== -1) - .map(z => ({ - ...z, - label: z.name - ? `${z.name} (${z.countries.join(', ')})` - : z.countries.join(', ') - })) - }) - }, - customerSelectedForOrder: function (customerPubkey) { - this.$emit('customer-selected-for-order', customerPubkey) - }, - shortLabel(value = ''){ - if (value.length <= 64) return value - return value.substring(0, 60) + '...' - } - }, - created: async function () { - this.stalls = await this.getStalls() - this.currencies = await this.getCurrencies() - this.zoneOptions = await this.getZones() - } - }) -} diff --git a/static/images/1.png b/static/images/1.png new file mode 100644 index 0000000..b52baa8 Binary files /dev/null and b/static/images/1.png differ diff --git a/static/images/2.png b/static/images/2.png new file mode 100644 index 0000000..2c67a46 Binary files /dev/null and b/static/images/2.png differ diff --git a/static/images/3.png b/static/images/3.png new file mode 100644 index 0000000..833b0f6 Binary files /dev/null and b/static/images/3.png differ diff --git a/static/images/4.png b/static/images/4.png new file mode 100644 index 0000000..854e734 Binary files /dev/null and b/static/images/4.png differ diff --git a/static/images/5.png b/static/images/5.png new file mode 100644 index 0000000..71b04dd Binary files /dev/null and b/static/images/5.png differ diff --git a/static/images/6.jpg b/static/images/6.jpg new file mode 100644 index 0000000..5c3f576 Binary files /dev/null and b/static/images/6.jpg differ diff --git a/static/js/index.js b/static/js/index.js index 72f1607..3d89779 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -1,241 +1,228 @@ -const merchant = async () => { - Vue.component(VueQrcode.name, VueQrcode) +const nostr = window.NostrTools - await keyPair('static/components/key-pair/key-pair.html') - await shippingZones('static/components/shipping-zones/shipping-zones.html') - await stallDetails('static/components/stall-details/stall-details.html') - await stallList('static/components/stall-list/stall-list.html') - await orderList('static/components/order-list/order-list.html') - await directMessages('static/components/direct-messages/direct-messages.html') - await merchantDetails( - 'static/components/merchant-details/merchant-details.html' - ) - - const nostr = window.NostrTools - - new Vue({ - el: '#vue', - mixins: [windowMixin], - data: function () { - return { - merchant: {}, - shippingZones: [], - activeChatCustomer: '', - orderPubkey: null, - showKeys: false, - importKeyDialog: { - show: false, - data: { - privateKey: null - } - }, - wsConnection: null - } - }, - methods: { - generateKeys: async function () { - const privateKey = nostr.generatePrivateKey() - await this.createMerchant(privateKey) - }, - importKeys: async function () { - this.importKeyDialog.show = false - let privateKey = this.importKeyDialog.data.privateKey - if (!privateKey) { - return - } - try { - if (privateKey.toLowerCase().startsWith('nsec')) { - privateKey = nostr.nip19.decode(privateKey).data - } - } catch (error) { - this.$q.notify({ - type: 'negative', - message: `${error}` - }) - } - await this.createMerchant(privateKey) - }, - showImportKeysDialog: async function () { - this.importKeyDialog.show = true - }, - toggleMerchantKeys: function (value) { - this.showKeys = value - }, - toggleMerchantState: async function () { - const merchant = await this.getMerchant() - if (!merchant) { - this.$q.notify({ - timeout: 5000, - type: 'warning', - message: "Cannot fetch merchant!" - }) - return - } - const message = merchant.config.active ? - 'New orders will not be processed. Are you sure you want to deactivate?' : - merchant.config.restore_in_progress ? - 'Merchant restore from nostr in progress. Please wait!! ' + - 'Activating now can lead to duplicate order processing. Click "OK" if you want to activate anyway?' : - 'Are you sure you want activate this merchant?' - - LNbits.utils - .confirmDialog(message) - .onOk(async () => { - await this.toggleMerchant() - }) - }, - toggleMerchant: async function () { - try { - const { data } = await LNbits.api.request( - 'PUT', - `/nostrmarket/api/v1/merchant/${this.merchant.id}/toggle`, - this.g.user.wallets[0].adminkey, - ) - const state = data.config.active ? 'activated' : 'disabled' - this.merchant = data - this.$q.notify({ - type: 'positive', - message: `'Merchant ${state}`, - timeout: 5000 - }) - } catch (error) { - console.warn(error) - LNbits.utils.notifyApiError(error) +window.app = Vue.createApp({ + el: '#vue', + mixins: [window.windowMixin], + data: function () { + return { + merchant: {}, + shippingZones: [], + activeChatCustomer: '', + orderPubkey: null, + showKeys: false, + importKeyDialog: { + show: false, + data: { + privateKey: null } }, - handleMerchantDeleted: function () { - this.merchant = null - this.shippingZones = [] - this.activeChatCustomer = '' - this.showKeys = false - }, - createMerchant: async function (privateKey) { - try { - const pubkey = nostr.getPublicKey(privateKey) - const payload = { - private_key: privateKey, - public_key: pubkey, - config: {} - } - const { data } = await LNbits.api.request( - 'POST', - '/nostrmarket/api/v1/merchant', - this.g.user.wallets[0].adminkey, - payload - ) - this.merchant = data - this.$q.notify({ - type: 'positive', - message: 'Merchant Created!' - }) - this.waitForNotifications() - } catch (error) { - LNbits.utils.notifyApiError(error) - } - }, - getMerchant: async function () { - try { - const { data } = await LNbits.api.request( - 'GET', - '/nostrmarket/api/v1/merchant', - this.g.user.wallets[0].inkey - ) - this.merchant = data - return data - } catch (error) { - LNbits.utils.notifyApiError(error) - } - }, - customerSelectedForOrder: function (customerPubkey) { - this.activeChatCustomer = customerPubkey - }, - filterOrdersForCustomer: function (customerPubkey) { - this.orderPubkey = customerPubkey - }, - showOrderDetails: async function (orderData) { - await this.$refs.orderListRef.orderSelected(orderData.orderId, orderData.eventId) - }, - waitForNotifications: async function () { - if (!this.merchant) return - try { - const scheme = location.protocol === 'http:' ? 'ws' : 'wss' - const port = location.port ? `:${location.port}` : '' - const wsUrl = `${scheme}://${document.domain}${port}/api/v1/ws/${this.merchant.id}` - console.log('Reconnecting to websocket: ', wsUrl) - this.wsConnection = new WebSocket(wsUrl) - this.wsConnection.onmessage = async e => { - const data = JSON.parse(e.data) - if (data.type === 'dm:0') { - this.$q.notify({ - timeout: 5000, - type: 'positive', - message: 'New Order' - }) - - await this.$refs.directMessagesRef.handleNewMessage(data) - return - } - if (data.type === 'dm:1') { - await this.$refs.directMessagesRef.handleNewMessage(data) - await this.$refs.orderListRef.addOrder(data) - return - } - if (data.type === 'dm:2') { - const orderStatus = JSON.parse(data.dm.message) - this.$q.notify({ - timeout: 5000, - type: 'positive', - message: orderStatus.message - }) - if (orderStatus.paid) { - await this.$refs.orderListRef.orderPaid(orderStatus.id) - } - await this.$refs.directMessagesRef.handleNewMessage(data) - return - } - if (data.type === 'dm:-1') { - await this.$refs.directMessagesRef.handleNewMessage(data) - } - // order paid - // order shipped - } - - } catch (error) { - this.$q.notify({ - timeout: 5000, - type: 'warning', - message: 'Failed to watch for updates', - caption: `${error}` - }) - } - }, - restartNostrConnection: async function () { - LNbits.utils - .confirmDialog( - 'Are you sure you want to reconnect to the nostrcient extension?' - ) - .onOk(async () => { - try { - await LNbits.api.request( - 'PUT', - '/nostrmarket/api/v1/restart', - this.g.user.wallets[0].adminkey - ) - } catch (error) { - LNbits.utils.notifyApiError(error) - } - }) - } - }, - created: async function () { - await this.getMerchant() - setInterval(async () => { - if (!this.wsConnection || this.wsConnection.readyState !== WebSocket.OPEN) { - await this.waitForNotifications() - } - }, 1000) + wsConnection: null } - }) -} + }, + methods: { + generateKeys: async function () { + const privateKey = nostr.generatePrivateKey() + await this.createMerchant(privateKey) + }, + importKeys: async function () { + this.importKeyDialog.show = false + let privateKey = this.importKeyDialog.data.privateKey + if (!privateKey) { + return + } + try { + if (privateKey.toLowerCase().startsWith('nsec')) { + privateKey = nostr.nip19.decode(privateKey).data + } + } catch (error) { + this.$q.notify({ + type: 'negative', + message: `${error}` + }) + } + await this.createMerchant(privateKey) + }, + showImportKeysDialog: async function () { + this.importKeyDialog.show = true + }, + toggleShowKeys: function () { + this.showKeys = !this.showKeys + }, + toggleMerchantState: async function () { + const merchant = await this.getMerchant() + if (!merchant) { + this.$q.notify({ + timeout: 5000, + type: 'warning', + message: 'Cannot fetch merchant!' + }) + return + } + const message = merchant.config.active + ? 'New orders will not be processed. Are you sure you want to deactivate?' + : merchant.config.restore_in_progress + ? 'Merchant restore from nostr in progress. Please wait!! ' + + 'Activating now can lead to duplicate order processing. Click "OK" if you want to activate anyway?' + : 'Are you sure you want activate this merchant?' -merchant() + LNbits.utils.confirmDialog(message).onOk(async () => { + await this.toggleMerchant() + }) + }, + toggleMerchant: async function () { + try { + const {data} = await LNbits.api.request( + 'PUT', + `/nostrmarket/api/v1/merchant/${this.merchant.id}/toggle`, + this.g.user.wallets[0].adminkey + ) + const state = data.config.active ? 'activated' : 'disabled' + this.merchant = data + this.$q.notify({ + type: 'positive', + message: `'Merchant ${state}`, + timeout: 5000 + }) + } catch (error) { + console.warn(error) + LNbits.utils.notifyApiError(error) + } + }, + handleMerchantDeleted: function () { + this.merchant = null + this.shippingZones = [] + this.activeChatCustomer = '' + this.showKeys = false + }, + createMerchant: async function (privateKey) { + try { + const pubkey = nostr.getPublicKey(privateKey) + const payload = { + private_key: privateKey, + public_key: pubkey, + config: {} + } + const {data} = await LNbits.api.request( + 'POST', + '/nostrmarket/api/v1/merchant', + this.g.user.wallets[0].adminkey, + payload + ) + this.merchant = data + this.$q.notify({ + type: 'positive', + message: 'Merchant Created!' + }) + this.waitForNotifications() + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + getMerchant: async function () { + try { + const {data} = await LNbits.api.request( + 'GET', + '/nostrmarket/api/v1/merchant', + this.g.user.wallets[0].inkey + ) + this.merchant = data + return data + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + customerSelectedForOrder: function (customerPubkey) { + this.activeChatCustomer = customerPubkey + }, + filterOrdersForCustomer: function (customerPubkey) { + this.orderPubkey = customerPubkey + }, + showOrderDetails: async function (orderData) { + await this.$refs.orderListRef.orderSelected( + orderData.orderId, + orderData.eventId + ) + }, + waitForNotifications: async function () { + if (!this.merchant) return + try { + const scheme = location.protocol === 'http:' ? 'ws' : 'wss' + const port = location.port ? `:${location.port}` : '' + const wsUrl = `${scheme}://${document.domain}${port}/api/v1/ws/${this.merchant.id}` + console.log('Reconnecting to websocket: ', wsUrl) + this.wsConnection = new WebSocket(wsUrl) + this.wsConnection.onmessage = async e => { + const data = JSON.parse(e.data) + if (data.type === 'dm:0') { + this.$q.notify({ + timeout: 5000, + type: 'positive', + message: 'New Order' + }) + + await this.$refs.directMessagesRef.handleNewMessage(data) + return + } + if (data.type === 'dm:1') { + await this.$refs.directMessagesRef.handleNewMessage(data) + await this.$refs.orderListRef.addOrder(data) + return + } + if (data.type === 'dm:2') { + const orderStatus = JSON.parse(data.dm.message) + this.$q.notify({ + timeout: 5000, + type: 'positive', + message: orderStatus.message + }) + if (orderStatus.paid) { + await this.$refs.orderListRef.orderPaid(orderStatus.id) + } + await this.$refs.directMessagesRef.handleNewMessage(data) + return + } + if (data.type === 'dm:-1') { + await this.$refs.directMessagesRef.handleNewMessage(data) + } + // order paid + // order shipped + } + } catch (error) { + this.$q.notify({ + timeout: 5000, + type: 'warning', + message: 'Failed to watch for updates', + caption: `${error}` + }) + } + }, + restartNostrConnection: async function () { + LNbits.utils + .confirmDialog( + 'Are you sure you want to reconnect to the nostrcient extension?' + ) + .onOk(async () => { + try { + await LNbits.api.request( + 'PUT', + '/nostrmarket/api/v1/restart', + this.g.user.wallets[0].adminkey + ) + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }) + } + }, + created: async function () { + await this.getMerchant() + setInterval(async () => { + if ( + !this.wsConnection || + this.wsConnection.readyState !== WebSocket.OPEN + ) { + await this.waitForNotifications() + } + }, 1000) + } +}) diff --git a/static/js/utils.js b/static/js/utils.js index a28c455..b244fce 100644 --- a/static/js/utils.js +++ b/static/js/utils.js @@ -23,7 +23,7 @@ function imgSizeFit(img, maxWidth = 1024, maxHeight = 768) { maxWidth / img.naturalWidth, maxHeight / img.naturalHeight ) - return { width: img.naturalWidth * ratio, height: img.naturalHeight * ratio } + return {width: img.naturalWidth * ratio, height: img.naturalHeight * ratio} } async function hash(string) { @@ -125,7 +125,7 @@ function isValidImageUrl(string) { function isValidKey(key, prefix = 'n') { try { if (key && key.startsWith(prefix)) { - let { _, data } = NostrTools.nip19.decode(key) + let {_, data} = NostrTools.nip19.decode(key) key = data } return isValidKeyHex(key) @@ -143,4 +143,4 @@ function formatCurrency(value, currency) { style: 'currency', currency: currency }).format(value) -} \ No newline at end of file +} diff --git a/static/market/index.html b/static/market/index.html index 4b4e656..381d551 100644 --- a/static/market/index.html +++ b/static/market/index.html @@ -11,11 +11,11 @@ - - - - - + + + + + diff --git a/tasks.py b/tasks.py index 4f50ebe..013a281 100644 --- a/tasks.py +++ b/tasks.py @@ -1,10 +1,9 @@ -from asyncio import Queue import asyncio - -from loguru import logger +from asyncio import Queue from lnbits.core.models import Payment from lnbits.tasks import register_invoice_listener +from loguru import logger from .nostr.nostr_client import NostrClient from .services import ( diff --git a/templates/nostrmarket/components/direct-messages.html b/templates/nostrmarket/components/direct-messages.html new file mode 100644 index 0000000..9f68511 --- /dev/null +++ b/templates/nostrmarket/components/direct-messages.html @@ -0,0 +1,187 @@ + + + + + + Messages + + + new + + + Client Orders + + + + + + + + + + + + + + + + + + + + + + + + + + + Add a public key to chat with + + + + + + + + + + + + New order: + + + Reply sent for order: + + + Paid + Shipped + + + + + + + + + ... + + + + + + + + + + + + + + + + + + + + + + + + Add + Cancel + + + + + + + + + + Close + + + + + + diff --git a/templates/nostrmarket/components/key-pair.html b/templates/nostrmarket/components/key-pair.html new file mode 100644 index 0000000..911e057 --- /dev/null +++ b/templates/nostrmarket/components/key-pair.html @@ -0,0 +1,93 @@ + + + + + + Keys + + + + + + + + + + Public Key + + + + + + + ... + + + + + + + + + + + + + Private Key (Keep Secret!) + + + + + + + + ... + + + + + + + diff --git a/static/components/merchant-details/merchant-details.html b/templates/nostrmarket/components/merchant-details.html similarity index 96% rename from static/components/merchant-details/merchant-details.html rename to templates/nostrmarket/components/merchant-details.html index 35f9e31..e48a8e8 100644 --- a/static/components/merchant-details/merchant-details.html +++ b/templates/nostrmarket/components/merchant-details.html @@ -15,7 +15,7 @@ > - + Show Keys Hide Keys diff --git a/templates/nostrmarket/components/order-list.html b/templates/nostrmarket/components/order-list.html new file mode 100644 index 0000000..13e684f --- /dev/null +++ b/templates/nostrmarket/components/order-list.html @@ -0,0 +1,369 @@ + + + + + + + + + + + + + + + + + + + + Restore Orders + Restore previous orders from Nostr + + + + + + + + + + + + + + + + + + new + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Products: + + + Quantity + + Name + Price + + + + + + + + + + + + + x + + + + + + + + + + + + + + Shipping Cost + + + + + + + + + + + Exchange Rate (1 BTC): + + + + + + + Error: + + + + + + + Order ID: + + + + + + + + Customer Public Key: + + + + + + + + Address: + + + + + + + + Phone: + + + + + + + Email: + + + + + + + Shipping Zone: + + + + + + + Invoice ID: + + + + + + + + + + + + + + + + + + + + + + + + + + + Cancel + + + + + diff --git a/static/components/shipping-zones/shipping-zones.html b/templates/nostrmarket/components/shipping-zones.html similarity index 91% rename from static/components/shipping-zones/shipping-zones.html rename to templates/nostrmarket/components/shipping-zones.html index 04f5650..3f0fd08 100644 --- a/static/components/shipping-zones/shipping-zones.html +++ b/templates/nostrmarket/components/shipping-zones.html @@ -22,12 +22,13 @@ @click="openZoneDialog(zone)" > - {{zone.name}} - {{zone.countries.join(", ")}} + + - - + diff --git a/templates/nostrmarket/components/stall-details.html b/templates/nostrmarket/components/stall-details.html new file mode 100644 index 0000000..aa67673 --- /dev/null +++ b/templates/nostrmarket/components/stall-details.html @@ -0,0 +1,466 @@ + + + + + + + + + + + ID: + + + + + + + Name: + + + + + + + Description: + + + + + + + Wallet: + + + + + + + + Currency: + + + + + + + Shipping Zones: + + + + + + + + + Update Stall + + + Delete Stall + + + + + + + + + + + New Product + Create a new product + + + + + Restore Product + Restore existing product from Nostr + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Create Product + + Cancel + + + + + + + + + + + + + + + Restore + + + + + + + There are no products to be restored. + + Restore All + Close + + + + diff --git a/templates/nostrmarket/components/stall-list.html b/templates/nostrmarket/components/stall-list.html new file mode 100644 index 0000000..673e8a7 --- /dev/null +++ b/templates/nostrmarket/components/stall-list.html @@ -0,0 +1,214 @@ + + + + + + + New Stall + Create a new stall + + + + + Restore Stall + Restore existing stall from Nostr + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Cancel + + + + + + + + + + + + + + + Restore + + + + + + + There are no stalls to be restored. + + Close + + + + + diff --git a/templates/nostrmarket/index.html b/templates/nostrmarket/index.html index c39eb32..d95d965 100644 --- a/templates/nostrmarket/index.html +++ b/templates/nostrmarket/index.html @@ -5,32 +5,37 @@ - - + + - - + + + + + - + @@ -231,15 +236,38 @@ } +{% include("nostrmarket/components/key-pair.html") %} +{% include("nostrmarket/components/shipping-zones.html") %} +{% include("nostrmarket/components/stall-details.html") %} +{% include("nostrmarket/components/stall-list.html") %} +{% include("nostrmarket/components/order-list.html") %}{% include("nostrmarket/components/direct-messages.html") %} + +{% include("nostrmarket/components/merchant-details.html") %} + - - - - - - - + + + + + + + + {% endblock %} diff --git a/templates/nostrmarket/market.html b/templates/nostrmarket/market.html index b03a7a4..4c72d1a 100644 --- a/templates/nostrmarket/market.html +++ b/templates/nostrmarket/market.html @@ -1,36 +1,59 @@ - + - - + Nostr Market App - - - - - - + + + + + - + - - - + + + - - - + + + - - - - - \ No newline at end of file + + + +
- {{shortLabel(productName(props.row, item.product_id))}} -
+ +