diff --git a/crud.py b/crud.py index 0f52c67..ce190bf 100644 --- a/crud.py +++ b/crud.py @@ -5,7 +5,16 @@ from typing import List, Optional from lnbits.helpers import urlsafe_short_hash from . import db -from .models import Merchant, PartialMerchant, PartialStall, PartialZone, Stall, Zone +from .models import ( + Merchant, + PartialMerchant, + PartialProduct, + PartialStall, + PartialZone, + Product, + Stall, + Zone, +) ######################################## MERCHANT ######################################## @@ -177,3 +186,45 @@ async def delete_stall(user_id: str, stall_id: str) -> None: stall_id, ), ) + + +######################################## STALL ######################################## + + +async def create_product(user_id: str, data: PartialProduct) -> Product: + product_id = urlsafe_short_hash() + + await db.execute( + f""" + INSERT INTO nostrmarket.products (user_id, id, stall_id, name, category_list, description, images, price, quantity) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + user_id, + product_id, + data.stall_id, + data.name, + json.dumps(data.categories), + data.description, + data.image, + data.price, + data.quantity, + ), + ) + product = await get_product(user_id, product_id) + assert product, "Newly created product couldn't be retrieved" + + return product + + +async def get_product(user_id: str, product_id: str) -> Optional[Product]: + row = await db.fetchone( + "SELECT * FROM nostrmarket.products WHERE user_id =? AND id = ?", + ( + user_id, + product_id, + ), + ) + product = Product.from_row(row) if row else None + + return product diff --git a/migrations.py b/migrations.py index 8af5726..2e0a158 100644 --- a/migrations.py +++ b/migrations.py @@ -39,12 +39,13 @@ async def m001_initial(db): await db.execute( f""" CREATE TABLE nostrmarket.products ( + user_id TEXT NOT NULL, id TEXT PRIMARY KEY, stall_id TEXT NOT NULL, name TEXT NOT NULL, - categories TEXT, + category_list TEXT DEFAULT '[]', description TEXT, - images TEXT NOT NULL DEFAULT '[]', + images TEXT DEFAULT '[]', price REAL NOT NULL, quantity INTEGER NOT NULL ); diff --git a/models.py b/models.py index 7892c93..b2bab4d 100644 --- a/models.py +++ b/models.py @@ -116,3 +116,45 @@ class Stall(PartialStall): stall.config = StallConfig(**json.loads(row["meta"])) stall.shipping_zones = [Zone(**z) for z in json.loads(row["zones"])] return stall + + +######################################## STALLS ######################################## + + +class PartialProduct(BaseModel): + stall_id: str + name: str + categories: List[str] = [] + description: Optional[str] + image: Optional[str] + price: float + quantity: int + + def validate_product(self): + if self.image: + image_is_url = self.image.startswith("https://") or self.image.startswith( + "http://" + ) + + if not image_is_url: + + def size(b64string): + return int((len(b64string) * 3) / 4 - b64string.count("=", -2)) + + image_size = size(self.image) / 1024 + if image_size > 100: + raise ValueError( + f""" + Image size is too big, {int(image_size)}Kb. + Max: 100kb, Compress the image at https://tinypng.com, or use an URL.""" + ) + + +class Product(PartialProduct): + id: str + + @classmethod + def from_row(cls, row: Row) -> "Product": + product = cls(**dict(row)) + product.categories = json.loads(row["category_list"]) + return product diff --git a/static/components/shipping-zones/shipping-zones.html b/static/components/shipping-zones/shipping-zones.html index 6984c6c..cedaca7 100644 --- a/static/components/shipping-zones/shipping-zones.html +++ b/static/components/shipping-zones/shipping-zones.html @@ -59,7 +59,7 @@ - -
+ +
+
+
+ New Product +
+
+
+
+
- +
+ + + + + + + + + + + + + + + + + + + + +
+ Update Product + + Create Product + + Cancel +
+
+
+
diff --git a/static/components/stall-details/stall-details.js b/static/components/stall-details/stall-details.js index 98f9bfb..dafd42a 100644 --- a/static/components/stall-details/stall-details.js +++ b/static/components/stall-details/stall-details.js @@ -1,6 +1,8 @@ async function stallDetails(path) { const template = await loadTemplateAsync(path) + const pica = window.pica() + Vue.component('stall-details', { name: 'stall-details', template, @@ -16,8 +18,21 @@ async function stallDetails(path) { data: function () { return { tab: 'info', - stall: null - // currencies: [], + stall: null, + products: [], + productDialog: { + showDialog: false, + url: true, + data: { + id: null, + name: '', + description: '', + categories: [], + image: null, + price: 0, + quantity: 0 + } + } } }, computed: { @@ -96,6 +111,97 @@ async function stallDetails(path) { LNbits.utils.notifyApiError(error) } }) + }, + imageAdded(file) { + const image = new Image() + image.src = URL.createObjectURL(file) + image.onload = async () => { + let fit = imgSizeFit(image) + let canvas = document.createElement('canvas') + canvas.setAttribute('width', fit.width) + canvas.setAttribute('height', fit.height) + output = await pica.resize(image, canvas) + this.productDialog.data.image = output.toDataURL('image/jpeg', 0.4) + this.productDialog = {...this.productDialog} + } + }, + imageCleared() { + this.productDialog.data.image = null + this.productDialog = {...this.productDialog} + }, + sendProductFormData: function () { + var data = { + stall_id: this.stall.id, + name: this.productDialog.data.name, + description: this.productDialog.data.description, + categories: this.productDialog.data.categories, + + image: this.productDialog.data.image, + price: this.productDialog.data.price, + quantity: this.productDialog.data.quantity + } + this.productDialog.showDialog = false + if (this.productDialog.data.id) { + this.updateProduct(data) + } else { + this.createProduct(data) + } + }, + updateProduct: function (data) { + var self = this + let wallet = _.findWhere(this.stalls, { + id: self.productDialog.data.stall + }).wallet + LNbits.api + .request( + 'PUT', + '/nostrmarket/api/v1/products/' + data.id, + _.findWhere(self.g.user.wallets, { + id: wallet + }).inkey, + data + ) + .then(async function (response) { + self.products = _.reject(self.products, function (obj) { + return obj.id == data.id + }) + let productData = mapProducts(response.data) + self.products.push(productData) + //SEND Nostr data + try { + await self.sendToRelays(productData, 'product', 'update') + } catch (e) { + console.error(e) + } + self.resetDialog('productDialog') + //self.productDialog.show = false + //self.productDialog.data = {} + }) + .catch(function (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) + } + }, + showNewProductDialog: async function () { + this.productDialog.showDialog = true } }, created: async function () { diff --git a/static/components/stall-list/stall-list.js b/static/components/stall-list/stall-list.js index 7fb3f8c..5fd8ffd 100644 --- a/static/components/stall-list/stall-list.js +++ b/static/components/stall-list/stall-list.js @@ -35,12 +35,6 @@ async function stallList(path) { label: 'Name', field: 'id' }, - // { - // name: 'toggle', - // align: 'left', - // label: 'Active', - // field: '' - // }, { name: 'description', align: 'left', @@ -88,6 +82,7 @@ async function stallList(path) { stall ) this.stallDialog.show = false + data.expanded = false this.stalls.unshift(data) this.$q.notify({ type: 'positive', @@ -154,6 +149,13 @@ async function stallList(path) { openCreateStallDialog: async function () { await this.getCurrencies() await this.getZones() + if (!this.zoneOptions || !this.zoneOptions.length) { + this.$q.notify({ + type: 'warning', + message: 'Please create a Shipping Zone first!' + }) + return + } this.stallDialog.data = { name: '', description: '', diff --git a/static/js/utils.js b/static/js/utils.js index 11ebc81..83e886b 100644 --- a/static/js/utils.js +++ b/static/js/utils.js @@ -16,3 +16,12 @@ function loadTemplateAsync(path) { return result } + +function imgSizeFit(img, maxWidth = 1024, maxHeight = 768) { + let ratio = Math.min( + 1, + maxWidth / img.naturalWidth, + maxHeight / img.naturalHeight + ) + return {width: img.naturalWidth * ratio, height: img.naturalHeight * ratio} +} diff --git a/templates/nostrmarket/index.html b/templates/nostrmarket/index.html index ad9c94d..fa07f17 100644 --- a/templates/nostrmarket/index.html +++ b/templates/nostrmarket/index.html @@ -143,6 +143,7 @@ {% endblock%}{% block scripts %} {{ window_vars(user) }} + diff --git a/views_api.py b/views_api.py index de83d66..472e17a 100644 --- a/views_api.py +++ b/views_api.py @@ -17,6 +17,7 @@ from lnbits.utils.exchange_rates import currencies from . import nostrmarket_ext from .crud import ( create_merchant, + create_product, create_stall, create_zone, delete_stall, @@ -29,7 +30,16 @@ from .crud import ( update_stall, update_zone, ) -from .models import Merchant, PartialMerchant, PartialStall, PartialZone, Stall, Zone +from .models import ( + Merchant, + PartialMerchant, + PartialProduct, + PartialStall, + PartialZone, + Product, + Stall, + Zone, +) from .nostr.nostr_client import publish_nostr_event ######################################## MERCHANT ######################################## @@ -170,6 +180,11 @@ async def api_create_stall( await update_stall(wallet.wallet.user, stall) return stall + except ValueError as ex: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=str(ex), + ) except Exception as ex: logger.warning(ex) raise HTTPException( @@ -202,6 +217,11 @@ async def api_update_stall( return stall except HTTPException as ex: raise ex + except ValueError as ex: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=str(ex), + ) except Exception as ex: logger.warning(ex) raise HTTPException( @@ -271,10 +291,65 @@ async def api_delete_stall( logger.warning(ex) raise HTTPException( status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail="Cannot delte stall", + detail="Cannot delete stall", ) +######################################## PRODUCTS ######################################## + + +@nostrmarket_ext.post("/api/v1/product") +async def api_market_product_create( + data: PartialProduct, + wallet: WalletTypeInfo = Depends(require_invoice_key), +) -> Product: + try: + data.validate_product() + product = await create_product(wallet.wallet.user, data=data) + + return product + except ValueError as ex: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=str(ex), + ) + except Exception as ex: + logger.warning(ex) + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Cannot create product", + ) + + +# @nostrmarket_ext.get("/api/v1/product/{stall_id}") +# async def api_market_products( +# stall_id: str, wallet: WalletTypeInfo = Depends(require_invoice_key), +# ): +# wallet_ids = [wallet.wallet.id] + + +# return [product.dict() for product in await get_products(stalls)] + + +# @market_ext.delete("/api/v1/products/{product_id}") +# async def api_market_products_delete( +# product_id, wallet: WalletTypeInfo = Depends(require_admin_key) +# ): +# product = await get_market_product(product_id) + +# if not product: +# return {"message": "Product does not exist."} + +# stall = await get_market_stall(product.stall) +# assert stall + +# if stall.wallet != wallet.wallet.id: +# return {"message": "Not your Market."} + +# await delete_market_product(product_id) +# raise HTTPException(status_code=HTTPStatus.NO_CONTENT) + + ######################################## OTHER ########################################