From 9cbfb09b416723698caf6b4479159187323a528e Mon Sep 17 00:00:00 2001 From: giles Date: Wed, 4 Mar 2026 16:58:47 +0000 Subject: [PATCH] =?UTF-8?q?Slim=20market/sxc/pages/=5F=5Finit=5F=5F.py=20?= =?UTF-8?q?=E2=86=92=2021=20lines?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move ~1670 lines to 6 sub-modules: renders.py, layouts.py, helpers.py, cards.py, filters.py, utils.py. Update all bp route imports. Co-Authored-By: Claude Opus 4.6 --- market/bp/all_markets/routes.py | 2 +- market/bp/browse/routes.py | 6 +- market/bp/page_admin/routes.py | 4 +- market/bp/page_markets/routes.py | 2 +- market/bp/product/routes.py | 8 +- market/sxc/pages/__init__.py | 1657 +----------------------------- market/sxc/pages/cards.py | 238 +++++ market/sxc/pages/filters.py | 441 ++++++++ market/sxc/pages/helpers.py | 168 +++ market/sxc/pages/layouts.py | 334 ++++++ market/sxc/pages/renders.py | 249 +++++ market/sxc/pages/utils.py | 287 ++++++ 12 files changed, 1732 insertions(+), 1664 deletions(-) create mode 100644 market/sxc/pages/cards.py create mode 100644 market/sxc/pages/filters.py create mode 100644 market/sxc/pages/helpers.py create mode 100644 market/sxc/pages/layouts.py create mode 100644 market/sxc/pages/renders.py create mode 100644 market/sxc/pages/utils.py diff --git a/market/bp/all_markets/routes.py b/market/bp/all_markets/routes.py index 784da82..e9f6710 100644 --- a/market/bp/all_markets/routes.py +++ b/market/bp/all_markets/routes.py @@ -46,7 +46,7 @@ def register() -> Blueprint: page = int(request.args.get("page", 1)) markets, has_more, page_info = await _load_markets(page) - from sxc.pages import render_all_markets_cards + from sxc.pages.renders import render_all_markets_cards sx_src = await render_all_markets_cards(markets, has_more, page_info, page) return sx_response(sx_src) diff --git a/market/bp/browse/routes.py b/market/bp/browse/routes.py index ab531b8..c8dae5f 100644 --- a/market/bp/browse/routes.py +++ b/market/bp/browse/routes.py @@ -49,7 +49,7 @@ def register(): full_context = {**product_info, **ctx} from shared.sx.page import get_template_context - from sxc.pages import render_browse_page, render_browse_oob, render_browse_cards + from sxc.pages.renders import render_browse_page, render_browse_oob, render_browse_cards tctx = await get_template_context() tctx.update(full_context) @@ -90,7 +90,7 @@ def register(): full_context = {**product_info, **ctx} from shared.sx.page import get_template_context - from sxc.pages import render_browse_page, render_browse_oob, render_browse_cards + from sxc.pages.renders import render_browse_page, render_browse_oob, render_browse_cards tctx = await get_template_context() tctx.update(full_context) @@ -131,7 +131,7 @@ def register(): full_context = {**product_info, **ctx} from shared.sx.page import get_template_context - from sxc.pages import render_browse_page, render_browse_oob, render_browse_cards + from sxc.pages.renders import render_browse_page, render_browse_oob, render_browse_cards tctx = await get_template_context() tctx.update(full_context) diff --git a/market/bp/page_admin/routes.py b/market/bp/page_admin/routes.py index 420676a..f9451c4 100644 --- a/market/bp/page_admin/routes.py +++ b/market/bp/page_admin/routes.py @@ -47,7 +47,7 @@ def register(): return await make_response(render_comp("error-inline", message=str(e)), 422) from shared.sx.page import get_template_context - from sxc.pages import render_markets_admin_list_panel + from sxc.pages.renders import render_markets_admin_list_panel ctx = await get_template_context() html = await render_markets_admin_list_panel(ctx) return sx_response(html) @@ -65,7 +65,7 @@ def register(): await services.market.soft_delete_marketplace(g.s, "page", post_id, market_slug) from shared.sx.page import get_template_context - from sxc.pages import render_markets_admin_list_panel + from sxc.pages.renders import render_markets_admin_list_panel ctx = await get_template_context() html = await render_markets_admin_list_panel(ctx) return sx_response(html) diff --git a/market/bp/page_markets/routes.py b/market/bp/page_markets/routes.py index 40628c4..3b62fd0 100644 --- a/market/bp/page_markets/routes.py +++ b/market/bp/page_markets/routes.py @@ -30,7 +30,7 @@ def register() -> Blueprint: markets, has_more = await _load_markets(post["id"], page) - from sxc.pages import render_page_markets_cards + from sxc.pages.renders import render_page_markets_cards post_slug = post.get("slug", "") sx_src = await render_page_markets_cards(markets, has_more, page, post_slug) return sx_response(sx_src) diff --git a/market/bp/product/routes.py b/market/bp/product/routes.py index 046192f..f78e795 100644 --- a/market/bp/product/routes.py +++ b/market/bp/product/routes.py @@ -108,7 +108,7 @@ def register(): from shared.browser.app.utils.htmx import is_htmx_request from shared.sx.page import get_template_context - from sxc.pages import render_product_page, render_product_oob + from sxc.pages.renders import render_product_page, render_product_oob tctx = await get_template_context() item_data = getattr(g, "item_data", {}) @@ -126,7 +126,7 @@ def register(): async def like_toggle(): product_slug = g.product_slug - from sxc.pages import render_like_toggle_button + from sxc.pages.renders import render_like_toggle_button if not g.user: return sx_response(await render_like_toggle_button(product_slug, False), status=403) @@ -147,7 +147,7 @@ def register(): from shared.browser.app.utils.htmx import is_htmx_request from shared.sx.page import get_template_context - from sxc.pages import render_product_admin_page, render_product_admin_oob + from sxc.pages.renders import render_product_admin_page, render_product_admin_oob tctx = await get_template_context() item_data = getattr(g, "item_data", {}) @@ -254,7 +254,7 @@ def register(): # htmx response: OOB-swap mini cart + product buttons if request.headers.get("SX-Request") == "true" or request.headers.get("HX-Request") == "true": - from sxc.pages import render_cart_added_response + from sxc.pages.renders import render_cart_added_response item_data = getattr(g, "item_data", {}) d = item_data.get("d", {}) return sx_response(await render_cart_added_response(g.cart, ci_ns, d)) diff --git a/market/sxc/pages/__init__.py b/market/sxc/pages/__init__.py index 22ae39d..13c01ce 100644 --- a/market/sxc/pages/__init__.py +++ b/market/sxc/pages/__init__.py @@ -1,22 +1,11 @@ """Market defpage setup — registers layouts, page helpers, and loads .sx pages.""" from __future__ import annotations -from typing import Any - -from shared.sx.parser import serialize, SxExpr -from shared.sx.helpers import ( - render_to_sx, - post_header_sx as _post_header_sx, - post_admin_header_sx, - oob_header_sx as _oob_header_sx, - header_child_sx, - search_mobile_sx, search_desktop_sx, - full_page_sx, oob_page_sx, -) - def setup_market_pages() -> None: """Register market-specific layouts, page helpers, and load page definitions.""" + from .layouts import _register_market_layouts + from .helpers import _register_market_helpers _register_market_layouts() _register_market_helpers() _load_market_page_files() @@ -26,1645 +15,7 @@ def _load_market_page_files() -> None: import os from shared.sx.pages import load_page_dir from shared.sx.jinja_bridge import load_service_components - sxc_dir = os.path.dirname(os.path.dirname(__file__)) # market/sxc/ - service_root = os.path.dirname(sxc_dir) # market/ + sxc_dir = os.path.dirname(os.path.dirname(__file__)) + service_root = os.path.dirname(sxc_dir) load_service_components(service_root, service_name="market") load_page_dir(os.path.dirname(__file__), "market") - - -# --------------------------------------------------------------------------- -# OOB orphan cleanup -# --------------------------------------------------------------------------- - -_MARKET_DEEP_IDS = [ - "product-admin-row", "product-admin-header-child", - "product-row", "product-header-child", - "market-admin-row", "market-admin-header-child", - "market-row", "market-header-child", - "post-admin-row", "post-admin-header-child", -] - - -def _clear_deeper_oob(*keep_ids: str) -> str: - """Clear all market header rows/children NOT in keep_ids.""" - to_clear = [i for i in _MARKET_DEEP_IDS if i not in keep_ids] - return " ".join(f'(div :id "{i}" :sx-swap-oob "outerHTML")' for i in to_clear) - - -# --------------------------------------------------------------------------- -# Price helpers -# --------------------------------------------------------------------------- - -_SYM = {"GBP": "\u00a3", "EUR": "\u20ac", "USD": "$"} - - -def _price_str(val, raw, cur) -> str: - if raw: - return str(raw) - if isinstance(val, (int, float)): - return f"{_SYM.get(cur, '')}{val:.2f}" - return str(val or "") - - -def _set_prices(item: dict) -> dict: - """Extract price values from product dict (mirrors prices.html set_prices macro).""" - oe = item.get("oe_list_price") or {} - sp_val = item.get("special_price") or (oe.get("special") if oe else None) - sp_raw = item.get("special_price_raw") or (oe.get("special_raw") if oe else None) - sp_cur = item.get("special_price_currency") or (oe.get("special_currency") if oe else None) - rp_val = item.get("regular_price") or item.get("rrp") or (oe.get("rrp") if oe else None) - rp_raw = item.get("regular_price_raw") or item.get("rrp_raw") or (oe.get("rrp_raw") if oe else None) - rp_cur = item.get("regular_price_currency") or item.get("rrp_currency") or (oe.get("rrp_currency") if oe else None) - return dict(sp_val=sp_val, sp_raw=sp_raw, sp_cur=sp_cur, - rp_val=rp_val, rp_raw=rp_raw, rp_cur=rp_cur) - - -async def _card_price_sx(p: dict) -> str: - """Build price line for product card as sx call.""" - pr = _set_prices(p) - sp_str = _price_str(pr["sp_val"], pr["sp_raw"], pr["sp_cur"]) - rp_str = _price_str(pr["rp_val"], pr["rp_raw"], pr["rp_cur"]) - parts: list[str] = [] - if pr["sp_val"]: - parts.append(await render_to_sx("market-price-special", price=sp_str)) - if pr["rp_val"]: - parts.append(await render_to_sx("market-price-regular-strike", price=rp_str)) - elif pr["rp_val"]: - parts.append(await render_to_sx("market-price-regular", price=rp_str)) - inner = "(<> " + " ".join(parts) + ")" if parts else None - return await render_to_sx("market-price-line", inner=SxExpr(inner) if inner else None) - - -# --------------------------------------------------------------------------- -# Header helpers -- _post_header_sx and _oob_header_sx imported from shared -# --------------------------------------------------------------------------- - - -async def _market_header_sx(ctx: dict, *, oob: bool = False) -> str: - """Build the market-level header row as sx call string.""" - from quart import url_for - - market_title = ctx.get("market_title", "") - top_slug = ctx.get("top_slug", "") - sub_slug = ctx.get("sub_slug", "") - hx_select_search = ctx.get("hx_select_search", "#main-panel") - - sub_div = f'(div {serialize(sub_slug)})' if sub_slug else "" - label_sx = await render_to_sx( - "market-shop-label", - title=market_title, top_slug=top_slug or "", - sub_div=SxExpr(sub_div) if sub_div else None, - ) - - link_href = url_for("defpage_market_home") - - # Build desktop nav from categories - categories = ctx.get("categories", {}) - qs = ctx.get("qs", "") - nav_sx = await _desktop_category_nav_sx(ctx, categories, qs, hx_select_search) - - return await render_to_sx( - "menu-row-sx", - id="market-row", level=2, - link_href=link_href, link_label_content=SxExpr(label_sx), - nav=SxExpr(nav_sx) if nav_sx else None, - child_id="market-header-child", oob=oob, - ) - - -async def _desktop_category_nav_sx(ctx: dict, categories: dict, qs: str, - hx_select: str) -> str: - """Build desktop category navigation links as sx.""" - from quart import url_for - from shared.utils import route_prefix - - prefix = route_prefix() - category_label = ctx.get("category_label", "") - select_colours = ctx.get("select_colours", "") - rights = ctx.get("rights", {}) - - all_href = prefix + url_for("market.browse.browse_all") + qs - all_active = (category_label == "All Products") - link_parts = [await render_to_sx( - "market-category-link", - href=all_href, hx_select=hx_select, active=all_active, - select_colours=select_colours, label="All", - )] - - for cat, data in categories.items(): - cat_href = prefix + url_for("market.browse.browse_top", top_slug=data["slug"]) + qs - cat_active = (cat == category_label) - link_parts.append(await render_to_sx( - "market-category-link", - href=cat_href, hx_select=hx_select, active=cat_active, - select_colours=select_colours, label=cat, - )) - - links_sx = "(<> " + " ".join(link_parts) + ")" - - admin_sx = "" - if rights and rights.get("admin"): - admin_href = prefix + url_for("defpage_market_admin") - admin_sx = await render_to_sx("market-admin-link", href=admin_href, hx_select=hx_select) - - return await render_to_sx("market-desktop-category-nav", - links=SxExpr(links_sx), - admin=SxExpr(admin_sx) if admin_sx else None) - - -async def _product_header_sx(ctx: dict, d: dict, *, oob: bool = False) -> str: - """Build the product-level header row as sx call string.""" - from quart import url_for - from shared.browser.app.csrf import generate_csrf_token - - slug = d.get("slug", "") - title = d.get("title", "") - hx_select_search = ctx.get("hx_select_search", "#main-panel") - link_href = url_for("market.browse.product.product_detail", product_slug=slug) - - label_sx = await render_to_sx("market-product-label", title=title) - - # Prices in nav area - pr = _set_prices(d) - cart = ctx.get("cart", []) - prices_nav = await _prices_header_sx(d, pr, cart, slug, ctx) - - rights = ctx.get("rights", {}) - nav_parts = [prices_nav] - if rights and rights.get("admin"): - admin_href = url_for("market.browse.product.admin", product_slug=slug) - nav_parts.append(await render_to_sx("market-admin-link", href=admin_href, hx_select=hx_select_search)) - nav_sx = "(<> " + " ".join(nav_parts) + ")" - - return await render_to_sx( - "menu-row-sx", - id="product-row", level=3, - link_href=link_href, link_label_content=SxExpr(label_sx), - nav=SxExpr(nav_sx), child_id="product-header-child", oob=oob, - ) - - -async def _prices_header_sx(d: dict, pr: dict, cart: list, slug: str, ctx: dict) -> str: - """Build prices + add-to-cart for product header row as sx.""" - from quart import url_for - from shared.browser.app.csrf import generate_csrf_token - - csrf = generate_csrf_token() - cart_action = url_for("market.browse.product.cart", product_slug=slug) - cart_url_fn = ctx.get("cart_url") - - # Add-to-cart button - quantity = sum(ci.quantity for ci in cart if ci.product.slug == slug) if cart else 0 - add_sx = await _cart_add_sx(slug, quantity, cart_action, csrf, cart_url_fn) - - parts = [add_sx] - sp_val, rp_val = pr.get("sp_val"), pr.get("rp_val") - if sp_val: - parts.append(await render_to_sx("market-header-price-special-label")) - parts.append(await render_to_sx("market-header-price-special", - price=_price_str(sp_val, pr["sp_raw"], pr["sp_cur"]))) - if rp_val: - parts.append(await render_to_sx("market-header-price-strike", - price=_price_str(rp_val, pr["rp_raw"], pr["rp_cur"]))) - elif rp_val: - parts.append(await render_to_sx("market-header-price-regular-label")) - parts.append(await render_to_sx("market-header-price-regular", - price=_price_str(rp_val, pr["rp_raw"], pr["rp_cur"]))) - - # RRP - rrp_raw = d.get("rrp_raw") - rrp_val = d.get("rrp") - case_size = d.get("case_size_count") or 1 - if rrp_raw and rrp_val: - rrp_str = f"{rrp_raw[0]}{rrp_val * case_size:.2f}" - parts.append(await render_to_sx("market-header-rrp", rrp=rrp_str)) - - inner_sx = "(<> " + " ".join(parts) + ")" - return await render_to_sx("market-prices-row", inner=SxExpr(inner_sx)) - - -async def _cart_add_sx(slug: str, quantity: int, action: str, csrf: str, - cart_url_fn: Any = None) -> str: - """Build add-to-cart button or quantity controls as sx.""" - if not quantity: - return await render_to_sx( - "market-cart-add-empty", - cart_id=f"cart-{slug}", action=action, csrf=csrf, - ) - - cart_href = cart_url_fn("/") if callable(cart_url_fn) else "/" - return await render_to_sx( - "market-cart-add-quantity", - cart_id=f"cart-{slug}", action=action, csrf=csrf, - minus_val=str(quantity - 1), plus_val=str(quantity + 1), - quantity=str(quantity), cart_href=cart_href, - ) - - -# --------------------------------------------------------------------------- -# Mobile nav panel -# --------------------------------------------------------------------------- - -async def _mobile_nav_panel_sx(ctx: dict) -> str: - """Build mobile nav panel with category accordion as sx.""" - from quart import url_for - from shared.utils import route_prefix - - prefix = route_prefix() - categories = ctx.get("categories", {}) - qs = ctx.get("qs", "") - category_label = ctx.get("category_label", "") - top_slug = ctx.get("top_slug", "") - sub_slug = ctx.get("sub_slug", "") - hx_select = ctx.get("hx_select_search", "#main-panel") - select_colours = ctx.get("select_colours", "") - - all_href = prefix + url_for("market.browse.browse_all") + qs - all_active = (category_label == "All Products") - item_parts = [await render_to_sx( - "market-mobile-all-link", - href=all_href, hx_select=hx_select, active=all_active, - select_colours=select_colours, - )] - - for cat, data in categories.items(): - cat_slug = data.get("slug", "") - cat_active = (top_slug == cat_slug.lower() if top_slug else False) - cat_href = prefix + url_for("market.browse.browse_top", top_slug=cat_slug) + qs - bg_cls = " bg-stone-900 text-white hover:bg-stone-900" if cat_active else "" - - chevron_sx = await render_to_sx("market-mobile-chevron") - - cat_count = data.get("count", 0) - summary_sx = await render_to_sx( - "market-mobile-cat-summary", - bg_cls=bg_cls, href=cat_href, hx_select=hx_select, - select_colours=select_colours, cat_name=cat, - count_label=f"{cat_count} products", count_str=str(cat_count), - chevron=SxExpr(chevron_sx), - ) - - subs = data.get("subs", []) - subs_sx = "" - if subs: - sub_link_parts = [] - for sub in subs: - sub_href = prefix + url_for("market.browse.browse_sub", top_slug=cat_slug, sub_slug=sub["slug"]) + qs - sub_active = (cat_active and sub_slug == sub.get("slug")) - sub_label = sub.get("html_label") or sub.get("name", "") - sub_count = sub.get("count", 0) - sub_link_parts.append(await render_to_sx( - "market-mobile-sub-link", - select_colours=select_colours, active=sub_active, - href=sub_href, hx_select=hx_select, label=sub_label, - count_label=f"{sub_count} products", count_str=str(sub_count), - )) - sub_links_sx = "(<> " + " ".join(sub_link_parts) + ")" - subs_sx = await render_to_sx("market-mobile-subs-panel", links=SxExpr(sub_links_sx)) - else: - view_href = prefix + url_for("market.browse.browse_top", top_slug=cat_slug) + qs - subs_sx = await render_to_sx("market-mobile-view-all", href=view_href, hx_select=hx_select) - - item_parts.append(await render_to_sx( - "market-mobile-cat-details", - open=cat_active or None, - summary=SxExpr(summary_sx), - subs=SxExpr(subs_sx), - )) - - items_sx = "(<> " + " ".join(item_parts) + ")" - return await render_to_sx("market-mobile-nav-wrapper", items=SxExpr(items_sx)) - - -# --------------------------------------------------------------------------- -# Product card (browse grid item) -# --------------------------------------------------------------------------- - -async def _product_card_sx(p: dict, ctx: dict) -> str: - """Build a single product card for browse grid as sx call.""" - from quart import url_for - from shared.browser.app.csrf import generate_csrf_token - from shared.utils import route_prefix - - prefix = route_prefix() - slug = p.get("slug", "") - item_href = prefix + url_for("market.browse.product.product_detail", product_slug=slug) - hx_select = ctx.get("hx_select_search", "#main-panel") - asset_url_fn = ctx.get("asset_url") - cart = ctx.get("cart", []) - selected_brands = ctx.get("selected_brands", []) - selected_stickers = ctx.get("selected_stickers", []) - search = ctx.get("search", "") - user = ctx.get("user") - csrf = generate_csrf_token() - - # Price data - pr = _set_prices(p) - sp_str = _price_str(pr["sp_val"], pr["sp_raw"], pr["sp_cur"]) - rp_str = _price_str(pr["rp_val"], pr["rp_raw"], pr["rp_cur"]) - - # Image labels as src URLs - labels = p.get("labels", []) - label_srcs = [] - if p.get("image") and callable(asset_url_fn): - label_srcs = [asset_url_fn("labels/" + l + ".svg") for l in labels] - - # Stickers as data - raw_stickers = p.get("stickers", []) - sticker_data = [] - if raw_stickers and callable(asset_url_fn): - for s in raw_stickers: - ring = " ring-2 ring-emerald-500 rounded" if s in selected_stickers else "" - sticker_data.append({"src": asset_url_fn(f"stickers/{s}.svg"), "name": s, "ring-cls": ring}) - - # Title highlighting - title = p.get("title", "") - has_highlight = False - search_pre = search_mid = search_post = "" - if search and search.lower() in title.lower(): - idx = title.lower().index(search.lower()) - has_highlight = True - search_pre = title[:idx] - search_mid = title[idx:idx + len(search)] - search_post = title[idx + len(search):] - - # Cart - quantity = sum(ci.quantity for ci in cart if ci.product.slug == slug) if cart else 0 - cart_action = url_for("market.browse.product.cart", product_slug=slug) - cart_url_fn = ctx.get("cart_url") - cart_href = cart_url_fn("/") if callable(cart_url_fn) else "/" - - brand = p.get("brand", "") - brand_highlight = " bg-yellow-200" if brand in selected_brands else "" - - kwargs = dict( - href=item_href, hx_select=hx_select, slug=slug, - image=p.get("image", ""), brand=brand, brand_highlight=brand_highlight, - special_price=sp_str, regular_price=rp_str, - cart_action=cart_action, quantity=quantity, cart_href=cart_href, csrf=csrf, - title=title, - has_like=bool(user), - ) - - if label_srcs: - kwargs["labels"] = label_srcs - elif labels: - kwargs["labels"] = labels - - if user: - kwargs["liked"] = p.get("is_liked", False) - kwargs["like_action"] = url_for("market.browse.product.like_toggle", product_slug=slug) - - if sticker_data: - kwargs["stickers"] = sticker_data - - if has_highlight: - kwargs["has_highlight"] = True - kwargs["search_pre"] = search_pre - kwargs["search_mid"] = search_mid - kwargs["search_post"] = search_post - - return await render_to_sx("market-product-card", **kwargs) - - -async def _product_cards_sx(ctx: dict) -> str: - """S-expression wire format for product cards (client renders).""" - from shared.utils import route_prefix - - prefix = route_prefix() - products = ctx.get("products", []) - page = ctx.get("page", 1) - total_pages = ctx.get("total_pages", 1) - current_local_href = ctx.get("current_local_href", "/") - qs_fn = ctx.get("qs_filter") - - parts = [] - for p in products: - parts.append(await _product_card_sx(p, ctx)) - - if page < total_pages: - if callable(qs_fn): - next_qs = qs_fn({"page": page + 1}) - else: - next_qs = f"?page={page + 1}" - next_url = prefix + current_local_href + next_qs - parts.append(await render_to_sx("sentinel-mobile", - id=f"sentinel-{page}-m", next_url=next_url, - hyperscript=_MOBILE_SENTINEL_HS)) - parts.append(await render_to_sx("sentinel-desktop", - id=f"sentinel-{page}-d", next_url=next_url, - hyperscript=_DESKTOP_SENTINEL_HS)) - else: - parts.append(await render_to_sx("end-of-results")) - - return "(<> " + " ".join(parts) + ")" - - -async def _like_button_sx(slug: str, liked: bool, csrf: str, ctx: dict) -> str: - """Build the like/unlike heart button overlay as sx.""" - from quart import url_for - - action = url_for("market.browse.product.like_toggle", product_slug=slug) - icon_cls = "fa-solid fa-heart text-red-500" if liked else "fa-regular fa-heart text-stone-400" - return await render_to_sx( - "market-like-button", - form_id=f"like-{slug}", action=action, slug=slug, - csrf=csrf, icon_cls=icon_cls, - ) - - -# --------------------------------------------------------------------------- -# Product cards (pagination fragment) -# --------------------------------------------------------------------------- - -_MOBILE_SENTINEL_HS = ( - "init\n" - " if not me.dataset.retryMs then set me.dataset.retryMs to 1000 end\n" - " if window.matchMedia('(min-width: 768px)').matches then set @hx-disabled to '' end\n" - "on resize from window\n" - " if window.matchMedia('(min-width: 768px)').matches then set @hx-disabled to '' else remove @hx-disabled end\n" - "on htmx:beforeRequest\n" - " if window.matchMedia('(min-width: 768px)').matches then halt end\n" - " add .hidden to .js-neterr in me\n" - " remove .hidden from .js-loading in me\n" - " remove .opacity-100 from me\n" - " add .opacity-0 to me\n" - "def backoff()\n" - " set ms to me.dataset.retryMs\n" - " if ms > 30000 then set ms to 30000 end\n" - " add .hidden to .js-loading in me\n" - " remove .hidden from .js-neterr in me\n" - " remove .opacity-0 from me\n" - " add .opacity-100 to me\n" - " wait ms ms\n" - " trigger sentinelmobile:retry\n" - " set ms to ms * 2\n" - " if ms > 30000 then set ms to 30000 end\n" - " set me.dataset.retryMs to ms\n" - "end\n" - "on htmx:sendError call backoff()\n" - "on htmx:responseError call backoff()\n" - "on htmx:timeout call backoff()" -) - -_DESKTOP_SENTINEL_HS = ( - "init\n" - " if not me.dataset.retryMs then set me.dataset.retryMs to 1000 end\n" - "on htmx:beforeRequest(event)\n" - " add .hidden to .js-neterr in me\n" - " remove .hidden from .js-loading in me\n" - " remove .opacity-100 from me\n" - " add .opacity-0 to me\n" - " set trig to null\n" - " if event.detail and event.detail.triggeringEvent then set trig to event.detail.triggeringEvent end\n" - " if trig and trig.type is 'intersect'\n" - " set scroller to the closest .js-grid-viewport\n" - " if scroller is null then halt end\n" - " if scroller.scrollTop < 20 then halt end\n" - " end\n" - "def backoff()\n" - " set ms to me.dataset.retryMs\n" - " if ms > 30000 then set ms to 30000 end\n" - " add .hidden to .js-loading in me\n" - " remove .hidden from .js-neterr in me\n" - " remove .opacity-0 from me\n" - " add .opacity-100 to me\n" - " wait ms ms\n" - " trigger sentinel:retry\n" - " set ms to ms * 2\n" - " if ms > 30000 then set ms to 30000 end\n" - " set me.dataset.retryMs to ms\n" - "end\n" - "on htmx:sendError call backoff()\n" - "on htmx:responseError call backoff()\n" - "on htmx:timeout call backoff()" -) - - -# --------------------------------------------------------------------------- -# Browse filter panels (mobile + desktop) -# --------------------------------------------------------------------------- - -async def _desktop_filter_sx(ctx: dict) -> str: - """Build the desktop aside filter panel as sx.""" - category_label = ctx.get("category_label", "") - sort_options = ctx.get("sort_options", []) - sort = ctx.get("sort", "") - labels = ctx.get("labels", []) - selected_labels = ctx.get("selected_labels", []) - stickers = ctx.get("stickers", []) - selected_stickers = ctx.get("selected_stickers", []) - brands = ctx.get("brands", []) - selected_brands = ctx.get("selected_brands", []) - liked = ctx.get("liked", False) - liked_count = ctx.get("liked_count", 0) - subs_local = ctx.get("subs_local", []) - top_local_href = ctx.get("top_local_href", "") - sub_slug = ctx.get("sub_slug", "") - - # Search - search_sx = await search_desktop_sx(ctx) - - # Category summary + sort + like + labels + stickers - cat_parts = [await render_to_sx("market-filter-category-label", label=category_label)] - - if sort_options: - cat_parts.append(await _sort_stickers_sx(sort_options, sort, ctx)) - - like_label_parts = [await _like_filter_sx(liked, liked_count, ctx)] - if labels: - like_label_parts.append(await _labels_filter_sx(labels, selected_labels, ctx, prefix="nav-labels")) - like_labels_sx = "(<> " + " ".join(like_label_parts) + ")" - cat_parts.append(await render_to_sx("market-filter-like-labels-nav", inner=SxExpr(like_labels_sx))) - - if stickers: - cat_parts.append(await _stickers_filter_sx(stickers, selected_stickers, ctx)) - - if subs_local and top_local_href: - cat_parts.append(await _subcategory_selector_sx(subs_local, top_local_href, sub_slug, ctx)) - - cat_inner_sx = "(<> " + " ".join(cat_parts) + ")" - cat_summary = await render_to_sx("market-desktop-category-summary", inner=SxExpr(cat_inner_sx)) - - # Brand filter - brand_inner = "" - if brands: - brand_inner = await _brand_filter_sx(brands, selected_brands, ctx) - brand_summary = await render_to_sx("market-desktop-brand-summary", - inner=SxExpr(brand_inner) if brand_inner else None) - - return "(<> " + " ".join([search_sx, cat_summary, brand_summary]) + ")" - - -async def _mobile_filter_summary_sx(ctx: dict) -> str: - """Build mobile filter summary as sx.""" - asset_url_fn = ctx.get("asset_url") - sort = ctx.get("sort", "") - sort_options = ctx.get("sort_options", []) - liked = ctx.get("liked", False) - liked_count = ctx.get("liked_count", 0) - selected_labels = ctx.get("selected_labels", []) - selected_stickers = ctx.get("selected_stickers", []) - selected_brands = ctx.get("selected_brands", []) - labels = ctx.get("labels", []) - stickers = ctx.get("stickers", []) - brands = ctx.get("brands", []) - - # Search bar - search_bar = await search_mobile_sx(ctx) - - # Summary chips showing active filters - chip_parts: list[str] = [] - - if sort and sort_options: - for k, l, i in sort_options: - if k == sort and callable(asset_url_fn): - chip_parts.append(await render_to_sx("market-mobile-chip-sort", src=asset_url_fn(i), label=l)) - if liked: - liked_parts = [await render_to_sx("market-mobile-chip-liked-icon")] - if liked_count is not None: - cls = "text-[10px] text-stone-500" if liked_count != 0 else "text-md text-red-500 font-bold" - liked_parts.append(await render_to_sx("market-mobile-chip-count", cls=cls, count=str(liked_count))) - liked_inner = "(<> " + " ".join(liked_parts) + ")" - chip_parts.append(await render_to_sx("market-mobile-chip-liked", inner=SxExpr(liked_inner))) - - # Selected labels - if selected_labels: - label_item_parts = [] - for sl in selected_labels: - for lb in labels: - if lb.get("name") == sl and callable(asset_url_fn): - li_parts = [await render_to_sx( - "market-mobile-chip-image", - src=asset_url_fn("nav-labels/" + sl + ".svg"), name=sl, - )] - if lb.get("count") is not None: - cls = "text-[10px] text-stone-500" if lb["count"] != 0 else "text-md text-red-500 font-bold" - li_parts.append(await render_to_sx("market-mobile-chip-count", cls=cls, count=str(lb["count"]))) - li_inner = "(<> " + " ".join(li_parts) + ")" - label_item_parts.append(await render_to_sx("market-mobile-chip-item", inner=SxExpr(li_inner))) - if label_item_parts: - label_items = "(<> " + " ".join(label_item_parts) + ")" - chip_parts.append(await render_to_sx("market-mobile-chip-list", items=SxExpr(label_items))) - - # Selected stickers - if selected_stickers: - sticker_item_parts = [] - for ss in selected_stickers: - for st in stickers: - if st.get("name") == ss and callable(asset_url_fn): - si_parts = [await render_to_sx( - "market-mobile-chip-image", - src=asset_url_fn("stickers/" + ss + ".svg"), name=ss, - )] - if st.get("count") is not None: - cls = "text-[10px] text-stone-500" if st["count"] != 0 else "text-md text-red-500 font-bold" - si_parts.append(await render_to_sx("market-mobile-chip-count", cls=cls, count=str(st["count"]))) - si_inner = "(<> " + " ".join(si_parts) + ")" - sticker_item_parts.append(await render_to_sx("market-mobile-chip-item", inner=SxExpr(si_inner))) - if sticker_item_parts: - sticker_items = "(<> " + " ".join(sticker_item_parts) + ")" - chip_parts.append(await render_to_sx("market-mobile-chip-list", items=SxExpr(sticker_items))) - - # Selected brands - if selected_brands: - brand_item_parts = [] - for b in selected_brands: - count = 0 - for br in brands: - if br.get("name") == b: - count = br.get("count", 0) - if count: - brand_item_parts.append(await render_to_sx("market-mobile-chip-brand", name=b, count=str(count))) - else: - brand_item_parts.append(await render_to_sx("market-mobile-chip-brand-zero", name=b)) - brand_items = "(<> " + " ".join(brand_item_parts) + ")" - chip_parts.append(await render_to_sx("market-mobile-chip-brand-list", items=SxExpr(brand_items))) - - chips_sx = "(<> " + " ".join(chip_parts) + ")" if chip_parts else '(<>)' - chips_row = await render_to_sx("market-mobile-chips-row", inner=SxExpr(chips_sx)) - - # Full mobile filter details - from shared.utils import route_prefix - prefix = route_prefix() - mobile_filter = await _mobile_filter_content_sx(ctx, prefix) - - return await render_to_sx( - "market-mobile-filter-summary", - search_bar=SxExpr(search_bar), - chips=SxExpr(chips_row), - filter=SxExpr(mobile_filter), - ) - - -async def _mobile_filter_content_sx(ctx: dict, prefix: str) -> str: - """Build the expanded mobile filter panel contents as sx.""" - selected_labels = ctx.get("selected_labels", []) - selected_stickers = ctx.get("selected_stickers", []) - selected_brands = ctx.get("selected_brands", []) - current_local_href = ctx.get("current_local_href", "/") - hx_select = ctx.get("hx_select_search", "#main-panel") - sort_options = ctx.get("sort_options", []) - sort = ctx.get("sort", "") - liked = ctx.get("liked", False) - liked_count = ctx.get("liked_count", 0) - labels = ctx.get("labels", []) - stickers = ctx.get("stickers", []) - brands = ctx.get("brands", []) - search = ctx.get("search", "") - qs_fn = ctx.get("qs_filter") - - parts: list[str] = [] - - # Sort options - if sort_options: - parts.append(await _sort_stickers_sx(sort_options, sort, ctx, mobile=True)) - - # Clear filters button - has_filters = search or selected_labels or selected_stickers or selected_brands - if has_filters and callable(qs_fn): - clear_url = prefix + current_local_href + qs_fn({"clear_filters": True}) - parts.append(await render_to_sx("market-mobile-clear-filters", href=clear_url, hx_select=hx_select)) - - # Like + labels row - like_label_parts = [await _like_filter_sx(liked, liked_count, ctx, mobile=True)] - if labels: - like_label_parts.append(await _labels_filter_sx(labels, selected_labels, ctx, prefix="nav-labels", mobile=True)) - like_labels_sx = "(<> " + " ".join(like_label_parts) + ")" - parts.append(await render_to_sx("market-mobile-like-labels-row", inner=SxExpr(like_labels_sx))) - - # Stickers - if stickers: - parts.append(await _stickers_filter_sx(stickers, selected_stickers, ctx, mobile=True)) - - # Brands - if brands: - parts.append(await _brand_filter_sx(brands, selected_brands, ctx, mobile=True)) - - return "(<> " + " ".join(parts) + ")" if parts else "(<>)" - - -async def _sort_stickers_sx(sort_options: list, current_sort: str, ctx: dict, mobile: bool = False) -> str: - """Build sort option stickers as sx.""" - asset_url_fn = ctx.get("asset_url") - current_local_href = ctx.get("current_local_href", "/") - hx_select = ctx.get("hx_select_search", "#main-panel") - qs_fn = ctx.get("qs_filter") - from shared.utils import route_prefix - prefix = route_prefix() - - item_parts: list[str] = [] - for k, label, icon in sort_options: - if callable(qs_fn): - href = prefix + current_local_href + qs_fn({"sort": k}) - else: - href = "#" - active = (k == current_sort) - ring = " ring-2 ring-emerald-500 rounded" if active else "" - src = asset_url_fn(icon) if callable(asset_url_fn) else icon - item_parts.append(await render_to_sx( - "market-filter-sort-item", - href=href, hx_select=hx_select, ring_cls=ring, src=src, label=label, - )) - items_sx = "(<> " + " ".join(item_parts) + ")" if item_parts else "(<>)" - return await render_to_sx("market-filter-sort-row", items=SxExpr(items_sx)) - - -async def _like_filter_sx(liked: bool, liked_count: int, ctx: dict, mobile: bool = False) -> str: - """Build the like filter toggle as sx.""" - current_local_href = ctx.get("current_local_href", "/") - hx_select = ctx.get("hx_select_search", "#main-panel") - qs_fn = ctx.get("qs_filter") - from shared.utils import route_prefix - prefix = route_prefix() - - if callable(qs_fn): - href = prefix + current_local_href + qs_fn({"liked": not liked}) - else: - href = "#" - - icon_cls = "fa-solid fa-heart text-red-500" if liked else "fa-regular fa-heart text-stone-400" - size = "text-[40px]" if mobile else "text-2xl" - return await render_to_sx( - "market-filter-like", - href=href, hx_select=hx_select, icon_cls=icon_cls, size_cls=size, - ) - - -async def _labels_filter_sx(labels: list, selected: list, ctx: dict, *, - prefix: str = "nav-labels", mobile: bool = False) -> str: - """Build label filter buttons as sx.""" - asset_url_fn = ctx.get("asset_url") - current_local_href = ctx.get("current_local_href", "/") - hx_select = ctx.get("hx_select_search", "#main-panel") - qs_fn = ctx.get("qs_filter") - from shared.utils import route_prefix - rp = route_prefix() - - item_parts: list[str] = [] - for lb in labels: - name = lb.get("name", "") - is_sel = name in selected - if callable(qs_fn): - new_sel = [s for s in selected if s != name] if is_sel else selected + [name] - href = rp + current_local_href + qs_fn({"labels": new_sel}) - else: - href = "#" - ring = " ring-2 ring-emerald-500 rounded" if is_sel else "" - src = asset_url_fn(f"{prefix}/{name}.svg") if callable(asset_url_fn) else "" - item_parts.append(await render_to_sx( - "market-filter-label-item", - href=href, hx_select=hx_select, ring_cls=ring, src=src, name=name, - )) - return "(<> " + " ".join(item_parts) + ")" if item_parts else "(<>)" - - -async def _stickers_filter_sx(stickers: list, selected: list, ctx: dict, mobile: bool = False) -> str: - """Build sticker filter grid as sx.""" - asset_url_fn = ctx.get("asset_url") - current_local_href = ctx.get("current_local_href", "/") - hx_select = ctx.get("hx_select_search", "#main-panel") - qs_fn = ctx.get("qs_filter") - from shared.utils import route_prefix - rp = route_prefix() - - item_parts: list[str] = [] - for st in stickers: - name = st.get("name", "") - count = st.get("count", 0) - is_sel = name in selected - if callable(qs_fn): - new_sel = [s for s in selected if s != name] if is_sel else selected + [name] - href = rp + current_local_href + qs_fn({"stickers": new_sel}) - else: - href = "#" - ring = " ring-2 ring-emerald-500 rounded" if is_sel else "" - src = asset_url_fn(f"stickers/{name}.svg") if callable(asset_url_fn) else "" - cls = "text-[10px] text-stone-500" if count != 0 else "text-md text-red-500 font-bold" - item_parts.append(await render_to_sx( - "market-filter-sticker-item", - href=href, hx_select=hx_select, ring_cls=ring, - src=src, name=name, count_cls=cls, count=str(count), - )) - items_sx = "(<> " + " ".join(item_parts) + ")" if item_parts else "(<>)" - return await render_to_sx("market-filter-stickers-row", items=SxExpr(items_sx)) - - -async def _brand_filter_sx(brands: list, selected: list, ctx: dict, mobile: bool = False) -> str: - """Build brand filter checkboxes as sx.""" - current_local_href = ctx.get("current_local_href", "/") - hx_select = ctx.get("hx_select_search", "#main-panel") - qs_fn = ctx.get("qs_filter") - from shared.utils import route_prefix - rp = route_prefix() - - item_parts: list[str] = [] - for br in brands: - name = br.get("name", "") - count = br.get("count", 0) - is_sel = name in selected - if callable(qs_fn): - new_sel = [s for s in selected if s != name] if is_sel else selected + [name] - href = rp + current_local_href + qs_fn({"brands": new_sel}) - else: - href = "#" - bg = " bg-yellow-200" if is_sel else "" - cls = "text-md" if count else "text-md text-red-500" - item_parts.append(await render_to_sx( - "market-filter-brand-item", - href=href, hx_select=hx_select, bg_cls=bg, - name_cls=cls, name=name, count=str(count), - )) - items_sx = "(<> " + " ".join(item_parts) + ")" if item_parts else "(<>)" - return await render_to_sx("market-filter-brands-panel", items=SxExpr(items_sx)) - - -async def _subcategory_selector_sx(subs: list, top_href: str, current_sub: str, ctx: dict) -> str: - """Build subcategory vertical nav as sx.""" - hx_select = ctx.get("hx_select_search", "#main-panel") - from shared.utils import route_prefix - rp = route_prefix() - - all_cls = " bg-stone-200 font-medium" if not current_sub else "" - all_full_href = rp + top_href - item_parts = [await render_to_sx( - "market-filter-subcategory-item", - href=all_full_href, hx_select=hx_select, active_cls=all_cls, name="All", - )] - for sub in subs: - slug = sub.get("slug", "") - name = sub.get("name", "") - href = sub.get("href", "") - active = (slug == current_sub) - active_cls = " bg-stone-200 font-medium" if active else "" - full_href = rp + href - item_parts.append(await render_to_sx( - "market-filter-subcategory-item", - href=full_href, hx_select=hx_select, active_cls=active_cls, name=name, - )) - items_sx = "(<> " + " ".join(item_parts) + ")" - return await render_to_sx("market-filter-subcategory-panel", items=SxExpr(items_sx)) - - -# --------------------------------------------------------------------------- -# Product detail page content -# --------------------------------------------------------------------------- - -async def _product_detail_sx(d: dict, ctx: dict) -> str: - """Build product detail main panel content as sx.""" - from quart import url_for - from shared.browser.app.csrf import generate_csrf_token - - asset_url_fn = ctx.get("asset_url") - user = ctx.get("user") - liked_by_current_user = ctx.get("liked_by_current_user", False) - csrf = generate_csrf_token() - - images = d.get("images", []) - labels = d.get("labels", []) - stickers = d.get("stickers", []) - brand = d.get("brand", "") - slug = d.get("slug", "") - - # Gallery - if images: - # Like button - like_sx = "" - if user: - like_sx = await _like_button_sx(slug, liked_by_current_user, csrf, ctx) - - # Main image + labels - label_parts: list[str] = [] - if callable(asset_url_fn): - for l in labels: - label_parts.append(await render_to_sx( - "market-label-overlay", - src=asset_url_fn("labels/" + l + ".svg"), - )) - labels_sx = "(<> " + " ".join(label_parts) + ")" if label_parts else None - - gallery_inner = await render_to_sx( - "market-detail-gallery-inner", - like=SxExpr(like_sx) if like_sx else None, - image=images[0], alt=d.get("title", ""), - labels=SxExpr(labels_sx) if labels_sx else None, - brand=brand, - ) - - # Prev/next buttons - nav_buttons = "" - if len(images) > 1: - nav_buttons = await render_to_sx("market-detail-nav-buttons") - - gallery_sx = await render_to_sx( - "market-detail-gallery", - inner=SxExpr(gallery_inner), - nav=SxExpr(nav_buttons) if nav_buttons else None, - ) - - # Thumbnails - gallery_parts = [gallery_sx] - if len(images) > 1: - thumb_parts = [] - for i, u in enumerate(images): - thumb_parts.append(await render_to_sx( - "market-detail-thumb", - title=f"Image {i+1}", src=u, alt=f"thumb {i+1}", - )) - thumbs_sx = "(<> " + " ".join(thumb_parts) + ")" - gallery_parts.append(await render_to_sx("market-detail-thumbs", thumbs=SxExpr(thumbs_sx))) - gallery_final = "(<> " + " ".join(gallery_parts) + ")" - else: - like_sx = "" - if user: - like_sx = await _like_button_sx(slug, liked_by_current_user, csrf, ctx) - gallery_final = await render_to_sx("market-detail-no-image", - like=SxExpr(like_sx) if like_sx else None) - - # Stickers below gallery - stickers_sx = "" - if stickers and callable(asset_url_fn): - sticker_parts = [] - for s in stickers: - sticker_parts.append(await render_to_sx( - "market-detail-sticker", - src=asset_url_fn("stickers/" + s + ".svg"), name=s, - )) - sticker_items_sx = "(<> " + " ".join(sticker_parts) + ")" - stickers_sx = await render_to_sx("market-detail-stickers", items=SxExpr(sticker_items_sx)) - - # Right column: prices, description, sections - pr = _set_prices(d) - detail_parts: list[str] = [] - - # Unit price / case size extras - extra_parts: list[str] = [] - ppu = d.get("price_per_unit") or d.get("price_per_unit_raw") - if ppu: - extra_parts.append(await render_to_sx( - "market-detail-unit-price", - price=_price_str(d.get("price_per_unit"), d.get("price_per_unit_raw"), d.get("price_per_unit_currency")), - )) - if d.get("case_size_raw"): - extra_parts.append(await render_to_sx("market-detail-case-size", size=d["case_size_raw"])) - if extra_parts: - extras_sx = "(<> " + " ".join(extra_parts) + ")" - detail_parts.append(await render_to_sx("market-detail-extras", inner=SxExpr(extras_sx))) - - # Description - desc_short = d.get("description_short") - desc_html_val = d.get("description_html") - if desc_short or desc_html_val: - desc_parts: list[str] = [] - if desc_short: - desc_parts.append(await render_to_sx("market-detail-desc-short", text=desc_short)) - if desc_html_val: - desc_parts.append(await render_to_sx("market-detail-desc-html", html=desc_html_val)) - desc_inner = "(<> " + " ".join(desc_parts) + ")" - detail_parts.append(await render_to_sx("market-detail-desc-wrapper", inner=SxExpr(desc_inner))) - - # Sections (expandable) - sections = d.get("sections", []) - if sections: - sec_parts = [] - for sec in sections: - sec_parts.append(await render_to_sx( - "market-detail-section", - title=sec.get("title", ""), html=sec.get("html", ""), - )) - sec_items_sx = "(<> " + " ".join(sec_parts) + ")" - detail_parts.append(await render_to_sx("market-detail-sections", items=SxExpr(sec_items_sx))) - - details_inner_sx = "(<> " + " ".join(detail_parts) + ")" if detail_parts else "(<>)" - details_sx = await render_to_sx("market-detail-right-col", inner=SxExpr(details_inner_sx)) - - return await render_to_sx( - "market-detail-layout", - gallery=SxExpr(gallery_final), - stickers=SxExpr(stickers_sx) if stickers_sx else None, - details=SxExpr(details_sx), - ) - - -# --------------------------------------------------------------------------- -# Product meta (OpenGraph, JSON-LD) -# --------------------------------------------------------------------------- - -async def _product_meta_sx(d: dict, ctx: dict) -> str: - """Build product meta tags as sx (auto-hoisted to by sx.js).""" - import json - from quart import request - - title = d.get("title", "") - desc_source = d.get("description_short") or "" - if not desc_source and d.get("description_html"): - import re - desc_source = re.sub(r"<[^>]+>", "", d.get("description_html", "")) - description = desc_source.strip().replace("\n", " ")[:160] - image_url = d.get("image") or (d.get("images", [None])[0] if d.get("images") else None) - canonical = request.url if request else "" - brand = d.get("brand", "") - sku = d.get("sku", "") - price = d.get("special_price") or d.get("regular_price") or d.get("rrp") - price_currency = d.get("special_price_currency") or d.get("regular_price_currency") or d.get("rrp_currency") - - parts = [await render_to_sx("market-meta-title", title=title)] - parts.append(await render_to_sx("market-meta-description", description=description)) - if canonical: - parts.append(await render_to_sx("market-meta-canonical", href=canonical)) - - # OpenGraph - site_title = ctx.get("base_title", "") - parts.append(await render_to_sx("market-meta-og", property="og:site_name", content=site_title)) - parts.append(await render_to_sx("market-meta-og", property="og:type", content="product")) - parts.append(await render_to_sx("market-meta-og", property="og:title", content=title)) - parts.append(await render_to_sx("market-meta-og", property="og:description", content=description)) - if canonical: - parts.append(await render_to_sx("market-meta-og", property="og:url", content=canonical)) - if image_url: - parts.append(await render_to_sx("market-meta-og", property="og:image", content=image_url)) - if price and price_currency: - parts.append(await render_to_sx("market-meta-og", property="product:price:amount", content=f"{price:.2f}")) - parts.append(await render_to_sx("market-meta-og", property="product:price:currency", content=price_currency)) - if brand: - parts.append(await render_to_sx("market-meta-og", property="product:brand", content=brand)) - - # Twitter - card_type = "summary_large_image" if image_url else "summary" - parts.append(await render_to_sx("market-meta-twitter", name="twitter:card", content=card_type)) - parts.append(await render_to_sx("market-meta-twitter", name="twitter:title", content=title)) - parts.append(await render_to_sx("market-meta-twitter", name="twitter:description", content=description)) - if image_url: - parts.append(await render_to_sx("market-meta-twitter", name="twitter:image", content=image_url)) - - # JSON-LD - jsonld = { - "@context": "https://schema.org", - "@type": "Product", - "name": title, - "image": image_url, - "description": description, - "sku": sku, - "url": canonical, - } - if brand: - jsonld["brand"] = {"@type": "Brand", "name": brand} - if price and price_currency: - jsonld["offers"] = { - "@type": "Offer", - "price": price, - "priceCurrency": price_currency, - "url": canonical, - "availability": "https://schema.org/InStock", - } - parts.append(await render_to_sx("market-meta-jsonld", json=json.dumps(jsonld))) - - return "(<> " + " ".join(parts) + ")" - - -# --------------------------------------------------------------------------- -# Market cards (all markets / page markets) -# --------------------------------------------------------------------------- - -async def _market_card_sx(market: Any, page_info: dict, *, show_page_badge: bool = True, - post_slug: str = "") -> str: - """Build a single market card as sx.""" - from shared.infrastructure.urls import market_url - - name = getattr(market, "name", "") - description = getattr(market, "description", "") - slug = getattr(market, "slug", "") - container_id = getattr(market, "container_id", None) - - if show_page_badge and page_info: - pi = page_info.get(container_id, {}) - p_slug = pi.get("slug", "") - p_title = pi.get("title", "") - market_href = market_url(f"/{p_slug}/{slug}/") if p_slug else "" - else: - p_slug = post_slug - p_title = "" - market_href = market_url(f"/{post_slug}/{slug}/") if post_slug else "" - - title_sx = "" - if market_href: - title_sx = await render_to_sx("market-market-card-title-link", href=market_href, name=name) - else: - title_sx = await render_to_sx("market-market-card-title", name=name) - - desc_sx = "" - if description: - desc_sx = await render_to_sx("market-market-card-desc", description=description) - - badge_sx = "" - if show_page_badge and p_title: - badge_href = market_url(f"/{p_slug}/") - badge_sx = await render_to_sx("market-market-card-badge", href=badge_href, title=p_title) - - return await render_to_sx( - "market-market-card", - title_content=SxExpr(title_sx) if title_sx else None, - desc_content=SxExpr(desc_sx) if desc_sx else None, - badge_content=SxExpr(badge_sx) if badge_sx else None, - ) - - -async def _market_cards_sx(markets: list, page_info: dict, page: int, has_more: bool, - next_url: str, *, show_page_badge: bool = True, - post_slug: str = "") -> str: - """Build market cards with infinite scroll sentinel as sx.""" - parts = [] - for m in markets: - parts.append(await _market_card_sx(m, page_info, show_page_badge=show_page_badge, - post_slug=post_slug)) - if has_more: - parts.append(await render_to_sx( - "sentinel-simple", - id=f"sentinel-{page}", next_url=next_url, - )) - return "(<> " + " ".join(parts) + ")" - - -async def _markets_grid(cards_sx: str) -> str: - """Wrap market cards in a grid as sx.""" - return await render_to_sx("market-markets-grid", cards=SxExpr(cards_sx)) - - -async def _no_markets_sx(message: str = "No markets available") -> str: - """Empty state for markets as sx.""" - return await render_to_sx("empty-state", icon="fa fa-store", message=message, - cls="px-3 py-12 text-center text-stone-400") - - -# --------------------------------------------------------------------------- -# Market landing page -# --------------------------------------------------------------------------- - -async def _market_landing_content_sx(post: dict) -> str: - """Build market landing page content as sx.""" - parts: list[str] = [] - if post.get("custom_excerpt"): - parts.append(await render_to_sx("market-landing-excerpt", text=post["custom_excerpt"])) - if post.get("feature_image"): - parts.append(await render_to_sx("market-landing-image", src=post["feature_image"])) - if post.get("html"): - parts.append(await render_to_sx("market-landing-html", html=post["html"])) - inner = "(<> " + " ".join(parts) + ")" if parts else "(<>)" - return await render_to_sx("market-landing-content", inner=SxExpr(inner)) - - -# --------------------------------------------------------------------------- -# Browse page -# --------------------------------------------------------------------------- - -async def _product_grid(cards_sx: str) -> str: - """Wrap product cards in a grid as sx.""" - return await render_to_sx("market-product-grid", cards=SxExpr(cards_sx)) - - -async def render_browse_page(ctx: dict) -> str: - """Full page: product browse with filters.""" - cards = await _product_cards_sx(ctx) - content = await _product_grid(cards) - - from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env - hdr = await render_to_sx_with_env("market-browse-layout-full", _ctx_to_env(ctx), - post_header=SxExpr(await _post_header_sx(ctx)), - market_header=SxExpr(await _market_header_sx(ctx))) - menu = await _mobile_nav_panel_sx(ctx) - filter_sx = await _mobile_filter_summary_sx(ctx) - aside_sx = await _desktop_filter_sx(ctx) - - return await full_page_sx(ctx, header_rows=hdr, content=content, - menu=menu, filter=filter_sx, aside=aside_sx) - - -async def render_browse_oob(ctx: dict) -> str: - """OOB response: product browse.""" - cards = await _product_cards_sx(ctx) - content = await _product_grid(cards) - - oob_hdr = await _oob_header_sx("post-header-child", "market-header-child", - await _market_header_sx(ctx)) - oobs = await render_to_sx("market-browse-layout-oob", - oob_header=SxExpr(oob_hdr), - post_header_oob=SxExpr(await _post_header_sx(ctx, oob=True)), - clear_oob=SxExpr(_clear_deeper_oob("post-row", "post-header-child", - "market-row", "market-header-child"))) - menu = await _mobile_nav_panel_sx(ctx) - filter_sx = await _mobile_filter_summary_sx(ctx) - aside_sx = await _desktop_filter_sx(ctx) - - return await oob_page_sx(oobs=oobs, content=content, - menu=menu, filter=filter_sx, aside=aside_sx) - - -async def render_browse_cards(ctx: dict) -> str: - """Pagination fragment: product cards -- sx wire format.""" - return await _product_cards_sx(ctx) - - -# --------------------------------------------------------------------------- -# Product detail -# --------------------------------------------------------------------------- - -async def render_product_page(ctx: dict, d: dict) -> str: - """Full page: product detail.""" - content = await _product_detail_sx(d, ctx) - meta = await _product_meta_sx(d, ctx) - - from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env - hdr = await render_to_sx_with_env("market-product-layout-full", _ctx_to_env(ctx), - post_header=SxExpr(await _post_header_sx(ctx)), - market_header=SxExpr(await _market_header_sx(ctx)), - product_header=SxExpr(await _product_header_sx(ctx, d))) - return await full_page_sx(ctx, header_rows=hdr, content=content, meta=meta) - - -async def render_product_oob(ctx: dict, d: dict) -> str: - """OOB response: product detail.""" - content = await _product_detail_sx(d, ctx) - - oobs = await render_to_sx("market-oob-wrap", - parts=SxExpr("(<> " + await _market_header_sx(ctx, oob=True) + " " - + await _oob_header_sx("market-header-child", "product-header-child", - await _product_header_sx(ctx, d)) + " " - + _clear_deeper_oob("post-row", "post-header-child", - "market-row", "market-header-child", - "product-row", "product-header-child") + ")")) - menu = await _mobile_nav_panel_sx(ctx) - return await oob_page_sx(oobs=oobs, content=content, menu=menu) - - -# --------------------------------------------------------------------------- -# Product admin -# --------------------------------------------------------------------------- - -async def render_product_admin_page(ctx: dict, d: dict) -> str: - """Full page: product admin.""" - content = await _product_detail_sx(d, ctx) - - from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env - hdr = await render_to_sx_with_env("market-product-admin-layout-full", _ctx_to_env(ctx), - post_header=SxExpr(await _post_header_sx(ctx)), - market_header=SxExpr(await _market_header_sx(ctx)), - product_header=SxExpr(await _product_header_sx(ctx, d)), - admin_header=SxExpr(await _product_admin_header_sx(ctx, d))) - return await full_page_sx(ctx, header_rows=hdr, content=content) - - -async def render_product_admin_oob(ctx: dict, d: dict) -> str: - """OOB response: product admin.""" - content = await _product_detail_sx(d, ctx) - - oobs = await render_to_sx("market-oob-wrap", - parts=SxExpr("(<> " + await _product_header_sx(ctx, d, oob=True) + " " - + await _oob_header_sx("product-header-child", "product-admin-header-child", - await _product_admin_header_sx(ctx, d)) + " " - + _clear_deeper_oob("post-row", "post-header-child", - "market-row", "market-header-child", - "product-row", "product-header-child", - "product-admin-row", "product-admin-header-child") + ")")) - return await oob_page_sx(oobs=oobs, content=content) - - -async def _product_admin_header_sx(ctx: dict, d: dict, *, oob: bool = False) -> str: - """Build product admin header row as sx.""" - from quart import url_for - - slug = d.get("slug", "") - link_href = url_for("market.browse.product.admin", product_slug=slug) - return await render_to_sx( - "menu-row-sx", - id="product-admin-row", level=4, - link_href=link_href, link_label="admin!!", icon="fa fa-cog", - child_id="product-admin-header-child", oob=oob, - ) - - -# --------------------------------------------------------------------------- -# Market admin -# --------------------------------------------------------------------------- - -async def _market_admin_header_sx(ctx: dict, *, oob: bool = False, selected: str = "") -> str: - """Build market admin header row -- delegates to shared helper.""" - slug = (ctx.get("post") or {}).get("slug", "") - return await post_admin_header_sx(ctx, slug, oob=oob, selected=selected) - - -# --------------------------------------------------------------------------- -# Page admin (//admin/) -- post-level admin for markets -# --------------------------------------------------------------------------- - -async def _markets_admin_panel_sx(ctx: dict) -> str: - """Render the markets list + create form panel.""" - from quart import g, url_for - from shared.services.registry import services - - rights = ctx.get("rights") or {} - is_admin = getattr(rights, "admin", False) if hasattr(rights, "admin") else rights.get("admin", False) - has_access = ctx.get("has_access") - can_create = has_access("page_admin.create_market") if callable(has_access) else is_admin - csrf_token = ctx.get("csrf_token") - csrf = csrf_token() if callable(csrf_token) else (csrf_token or "") - - post = ctx.get("post") or {} - post_id = post.get("id") - markets = await services.market.marketplaces_for_container(g.s, "page", post_id) if post_id else [] - - form_html = "" - if can_create: - create_url = url_for("page_admin.create_market") - form_html = await render_to_sx("crud-create-form", - create_url=create_url, csrf=csrf, - errors_id="market-create-errors", - list_id="markets-list", - placeholder="e.g. Suma, Craft Fair", - btn_label="Add market") - - list_html = await _markets_admin_list_sx(ctx, markets) - return await render_to_sx("crud-panel", - form=SxExpr(form_html), list=SxExpr(list_html), - list_id="markets-list") - - -async def _markets_admin_list_sx(ctx: dict, markets: list) -> str: - """Render the markets list items.""" - from quart import url_for - from shared.utils import route_prefix - csrf_token = ctx.get("csrf_token") - csrf = csrf_token() if callable(csrf_token) else (csrf_token or "") - prefix = route_prefix() - - if not markets: - return await render_to_sx("empty-state", - message="No markets yet. Create one above.", - cls="text-gray-500 mt-4") - - parts = [] - for m in markets: - m_slug = getattr(m, "slug", "") or (m.get("slug", "") if isinstance(m, dict) else "") - m_name = getattr(m, "name", "") or (m.get("name", "") if isinstance(m, dict) else "") - post_slug = (ctx.get("post") or {}).get("slug", "") - href = prefix + f"/{post_slug}/{m_slug}/" - del_url = url_for("page_admin.delete_market", market_slug=m_slug) - csrf_hdr = f'{{"X-CSRFToken":"{csrf}"}}' - parts.append(await render_to_sx("crud-item", - href=href, name=m_name, slug=m_slug, - del_url=del_url, csrf_hdr=csrf_hdr, - list_id="markets-list", - confirm_title="Delete market?", - confirm_text="Products will be hidden (soft delete)")) - return "".join(parts) - - -async def render_markets_admin_list_panel(ctx: dict) -> str: - """Render the markets admin panel HTML for POST/DELETE response.""" - return await _markets_admin_panel_sx(ctx) - - -# --------------------------------------------------------------------------- -# Public API: POST handler fragment renderers -# --------------------------------------------------------------------------- - -async def render_all_markets_cards(markets: list, has_more: bool, - page_info: dict, page: int) -> str: - """Pagination fragment: all markets cards.""" - from quart import url_for - from shared.utils import route_prefix - - prefix = route_prefix() - next_url = prefix + url_for("all_markets.markets_fragment", page=page + 1) - return await _market_cards_sx(markets, page_info, page, has_more, next_url) - - -async def render_page_markets_cards(markets: list, has_more: bool, - page: int, post_slug: str) -> str: - """Pagination fragment: page-scoped markets cards.""" - from quart import url_for - from shared.utils import route_prefix - - prefix = route_prefix() - next_url = prefix + url_for("page_markets.markets_fragment", page=page + 1) - return await _market_cards_sx(markets, {}, page, has_more, next_url, - show_page_badge=False, post_slug=post_slug) - - -async def render_like_toggle_button(slug: str, liked: bool, *, - like_url: str | None = None, - item_type: str = "product") -> str: - """Render a standalone like toggle button for HTMX POST response.""" - from shared.browser.app.csrf import generate_csrf_token - from quart import url_for - from shared.utils import host_url - - csrf = generate_csrf_token() - if not like_url: - like_url = host_url(url_for("market.browse.product.like_toggle", product_slug=slug)) - - if liked: - colour = "text-red-600" - icon = "fa-solid fa-heart" - label = f"Unlike this {item_type}" - else: - colour = "text-stone-300" - icon = "fa-regular fa-heart" - label = f"Like this {item_type}" - - return await render_to_sx( - "market-like-toggle-button", - colour=colour, action=like_url, - hx_headers=f'{{"X-CSRFToken": "{csrf}"}}', - label=label, icon_cls=icon, - ) - - -async def render_cart_added_response(cart: list, item: Any, d: dict) -> str: - """Render the HTMX response after add-to-cart. - - Returns OOB fragments: cart-mini icon + product add/remove buttons + cart item row. - """ - from shared.browser.app.csrf import generate_csrf_token - from quart import url_for, g - from shared.infrastructure.urls import cart_url as _cart_url - - csrf = generate_csrf_token() - slug = d.get("slug", "") - count = sum(getattr(ci, "quantity", 0) for ci in cart) - - # 1. Cart mini icon OOB - if count > 0: - cart_href = _cart_url("/") - cart_mini = await render_to_sx("market-cart-mini-count", href=cart_href, count=str(count)) - else: - from shared.config import config - blog_href = config().get("blog_url", "/") - logo = config().get("logo", "") - cart_mini = await render_to_sx("market-cart-mini-empty", href=blog_href, logo=logo) - - # 2. Add/remove buttons OOB - action = url_for("market.browse.product.cart", product_slug=slug) - quantity = getattr(item, "quantity", 0) if item else 0 - if not quantity: - cart_add = await render_to_sx( - "market-cart-add-empty", - cart_id=f"cart-{slug}", action=action, csrf=csrf, - ) - else: - cart_href = _cart_url("/") if callable(_cart_url) else "/" - cart_add = await render_to_sx( - "market-cart-add-quantity", - cart_id=f"cart-{slug}", action=action, csrf=csrf, - minus_val=str(quantity - 1), plus_val=str(quantity + 1), - quantity=str(quantity), cart_href=cart_href, - ) - add_sx = await render_to_sx( - "market-cart-add-oob", - id=f"cart-add-{slug}", - inner=SxExpr(cart_add), - ) - - return "(<> " + cart_mini + " " + add_sx + ")" - - -# =========================================================================== -# Layouts -# =========================================================================== - -def _register_market_layouts() -> None: - from shared.sx.layouts import register_custom_layout - register_custom_layout("market", _market_full, _market_oob, _market_mobile) - register_custom_layout("market-admin", _market_admin_full, _market_admin_oob) - - -async def _market_full(ctx: dict, **kw: Any) -> str: - from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env - return await render_to_sx_with_env("market-browse-layout-full", _ctx_to_env(ctx), - post_header=SxExpr(await _post_header_sx(ctx)), - market_header=SxExpr(await _market_header_sx(ctx))) - - -async def _market_oob(ctx: dict, **kw: Any) -> str: - oob_hdr = await _oob_header_sx("post-header-child", "market-header-child", - await _market_header_sx(ctx)) - return await render_to_sx("market-browse-layout-oob", - oob_header=SxExpr(oob_hdr), - post_header_oob=SxExpr(await _post_header_sx(ctx, oob=True)), - clear_oob=SxExpr(_clear_deeper_oob("post-row", "post-header-child", - "market-row", "market-header-child"))) - - -async def _market_mobile(ctx: dict, **kw: Any) -> str: - return await _mobile_nav_panel_sx(ctx) - - -async def _market_admin_full(ctx: dict, **kw: Any) -> str: - from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env - selected = kw.get("selected", "") - return await render_to_sx_with_env("market-admin-layout-full", _ctx_to_env(ctx), - post_header=SxExpr(await _post_header_sx(ctx)), - market_header=SxExpr(await _market_header_sx(ctx)), - admin_header=SxExpr(await _market_admin_header_sx(ctx, selected=selected))) - - -async def _market_admin_oob(ctx: dict, **kw: Any) -> str: - selected = kw.get("selected", "") - return await render_to_sx("market-admin-layout-oob", - market_header_oob=SxExpr(await _market_header_sx(ctx, oob=True)), - admin_oob_header=SxExpr(await _oob_header_sx("market-header-child", "market-admin-header-child", - await _market_admin_header_sx(ctx, selected=selected))), - clear_oob=SxExpr(_clear_deeper_oob("post-row", "post-header-child", - "market-row", "market-header-child", - "market-admin-row", "market-admin-header-child"))) - - -# =========================================================================== -# Page helpers -# =========================================================================== - -def _register_market_helpers() -> None: - from shared.sx.pages import register_page_helpers - - register_page_helpers("market", { - "all-markets-content": _h_all_markets_content, - "page-markets-content": _h_page_markets_content, - "page-admin-content": _h_page_admin_content, - "market-home-content": _h_market_home_content, - "market-admin-content": _h_market_admin_content, - }) - - -async def _h_all_markets_content(**kw): - from quart import g, url_for, request - from shared.utils import route_prefix - from shared.services.registry import services - from shared.infrastructure.data_client import fetch_data - from shared.contracts.dtos import PostDTO, dto_from_dict - - page = int(request.args.get("page", 1)) - markets, has_more = await services.market.list_marketplaces( - g.s, page=page, per_page=20, - ) - - page_info = {} - if markets: - post_ids = list({m.container_id for m in markets if m.container_type == "page"}) - if post_ids: - raw_posts = await fetch_data("blog", "posts-by-ids", - params={"ids": ",".join(str(i) for i in post_ids)}, - required=False) or [] - for raw_p in raw_posts: - p = dto_from_dict(PostDTO, raw_p) - page_info[p.id] = {"title": p.title, "slug": p.slug} - - if not markets: - return await _no_markets_sx() - - prefix = route_prefix() - next_url = prefix + url_for("all_markets.markets_fragment", page=page + 1) - - cards = await _market_cards_sx(markets, page_info, page, has_more, next_url) - content = await _markets_grid(cards) - return "(<> " + content + " " + '(div :class "pb-8")' + ")" - - -async def _h_page_markets_content(slug=None, **kw): - from quart import g, url_for, request - from shared.utils import route_prefix - from shared.services.registry import services - - post = g.post_data["post"] - page = int(request.args.get("page", 1)) - markets, has_more = await services.market.list_marketplaces( - g.s, "page", post["id"], page=page, per_page=20, - ) - post_slug = post.get("slug", "") - - if not markets: - return await _no_markets_sx("No markets for this page") - - prefix = route_prefix() - next_url = prefix + url_for("page_markets.markets_fragment", page=page + 1) - - cards = await _market_cards_sx(markets, {}, page, has_more, next_url, - show_page_badge=False, post_slug=post_slug) - content = await _markets_grid(cards) - return "(<> " + content + " " + '(div :class "pb-8")' + ")" - - -async def _h_page_admin_content(slug=None, **kw): - from shared.sx.page import get_template_context - ctx = await get_template_context() - content = await _markets_admin_panel_sx(ctx) - return '(div :id "main-panel" ' + content + ')' - - -async def _h_market_home_content(page_slug=None, market_slug=None, **kw): - from quart import g - post_data = getattr(g, "post_data", {}) - post = post_data.get("post", {}) - return await _market_landing_content_sx(post) - - -def _h_market_admin_content(page_slug=None, market_slug=None, **kw): - return '"market admin"' diff --git a/market/sxc/pages/cards.py b/market/sxc/pages/cards.py new file mode 100644 index 0000000..76aa979 --- /dev/null +++ b/market/sxc/pages/cards.py @@ -0,0 +1,238 @@ +"""Product/market card builders.""" +from __future__ import annotations + +from typing import Any + +from shared.sx.parser import SxExpr +from shared.sx.helpers import render_to_sx + +from .utils import _set_prices, _price_str +from .filters import _MOBILE_SENTINEL_HS, _DESKTOP_SENTINEL_HS + + +# --------------------------------------------------------------------------- +# Product card (browse grid item) +# --------------------------------------------------------------------------- + +async def _product_card_sx(p: dict, ctx: dict) -> str: + """Build a single product card for browse grid as sx call.""" + from quart import url_for + from shared.browser.app.csrf import generate_csrf_token + from shared.utils import route_prefix + + prefix = route_prefix() + slug = p.get("slug", "") + item_href = prefix + url_for("market.browse.product.product_detail", product_slug=slug) + hx_select = ctx.get("hx_select_search", "#main-panel") + asset_url_fn = ctx.get("asset_url") + cart = ctx.get("cart", []) + selected_brands = ctx.get("selected_brands", []) + selected_stickers = ctx.get("selected_stickers", []) + search = ctx.get("search", "") + user = ctx.get("user") + csrf = generate_csrf_token() + + # Price data + pr = _set_prices(p) + sp_str = _price_str(pr["sp_val"], pr["sp_raw"], pr["sp_cur"]) + rp_str = _price_str(pr["rp_val"], pr["rp_raw"], pr["rp_cur"]) + + # Image labels as src URLs + labels = p.get("labels", []) + label_srcs = [] + if p.get("image") and callable(asset_url_fn): + label_srcs = [asset_url_fn("labels/" + l + ".svg") for l in labels] + + # Stickers as data + raw_stickers = p.get("stickers", []) + sticker_data = [] + if raw_stickers and callable(asset_url_fn): + for s in raw_stickers: + ring = " ring-2 ring-emerald-500 rounded" if s in selected_stickers else "" + sticker_data.append({"src": asset_url_fn(f"stickers/{s}.svg"), "name": s, "ring-cls": ring}) + + # Title highlighting + title = p.get("title", "") + has_highlight = False + search_pre = search_mid = search_post = "" + if search and search.lower() in title.lower(): + idx = title.lower().index(search.lower()) + has_highlight = True + search_pre = title[:idx] + search_mid = title[idx:idx + len(search)] + search_post = title[idx + len(search):] + + # Cart + quantity = sum(ci.quantity for ci in cart if ci.product.slug == slug) if cart else 0 + cart_action = url_for("market.browse.product.cart", product_slug=slug) + cart_url_fn = ctx.get("cart_url") + cart_href = cart_url_fn("/") if callable(cart_url_fn) else "/" + + brand = p.get("brand", "") + brand_highlight = " bg-yellow-200" if brand in selected_brands else "" + + kwargs = dict( + href=item_href, hx_select=hx_select, slug=slug, + image=p.get("image", ""), brand=brand, brand_highlight=brand_highlight, + special_price=sp_str, regular_price=rp_str, + cart_action=cart_action, quantity=quantity, cart_href=cart_href, csrf=csrf, + title=title, + has_like=bool(user), + ) + + if label_srcs: + kwargs["labels"] = label_srcs + elif labels: + kwargs["labels"] = labels + + if user: + kwargs["liked"] = p.get("is_liked", False) + kwargs["like_action"] = url_for("market.browse.product.like_toggle", product_slug=slug) + + if sticker_data: + kwargs["stickers"] = sticker_data + + if has_highlight: + kwargs["has_highlight"] = True + kwargs["search_pre"] = search_pre + kwargs["search_mid"] = search_mid + kwargs["search_post"] = search_post + + return await render_to_sx("market-product-card", **kwargs) + + +async def _product_cards_sx(ctx: dict) -> str: + """S-expression wire format for product cards (client renders).""" + from shared.utils import route_prefix + + prefix = route_prefix() + products = ctx.get("products", []) + page = ctx.get("page", 1) + total_pages = ctx.get("total_pages", 1) + current_local_href = ctx.get("current_local_href", "/") + qs_fn = ctx.get("qs_filter") + + parts = [] + for p in products: + parts.append(await _product_card_sx(p, ctx)) + + if page < total_pages: + if callable(qs_fn): + next_qs = qs_fn({"page": page + 1}) + else: + next_qs = f"?page={page + 1}" + next_url = prefix + current_local_href + next_qs + parts.append(await render_to_sx("sentinel-mobile", + id=f"sentinel-{page}-m", next_url=next_url, + hyperscript=_MOBILE_SENTINEL_HS)) + parts.append(await render_to_sx("sentinel-desktop", + id=f"sentinel-{page}-d", next_url=next_url, + hyperscript=_DESKTOP_SENTINEL_HS)) + else: + parts.append(await render_to_sx("end-of-results")) + + return "(<> " + " ".join(parts) + ")" + + +async def _like_button_sx(slug: str, liked: bool, csrf: str, ctx: dict) -> str: + """Build the like/unlike heart button overlay as sx.""" + from quart import url_for + + action = url_for("market.browse.product.like_toggle", product_slug=slug) + icon_cls = "fa-solid fa-heart text-red-500" if liked else "fa-regular fa-heart text-stone-400" + return await render_to_sx( + "market-like-button", + form_id=f"like-{slug}", action=action, slug=slug, + csrf=csrf, icon_cls=icon_cls, + ) + + +# --------------------------------------------------------------------------- +# Market cards (all markets / page markets) +# --------------------------------------------------------------------------- + +async def _market_card_sx(market: Any, page_info: dict, *, show_page_badge: bool = True, + post_slug: str = "") -> str: + """Build a single market card as sx.""" + from shared.infrastructure.urls import market_url + + name = getattr(market, "name", "") + description = getattr(market, "description", "") + slug = getattr(market, "slug", "") + container_id = getattr(market, "container_id", None) + + if show_page_badge and page_info: + pi = page_info.get(container_id, {}) + p_slug = pi.get("slug", "") + p_title = pi.get("title", "") + market_href = market_url(f"/{p_slug}/{slug}/") if p_slug else "" + else: + p_slug = post_slug + p_title = "" + market_href = market_url(f"/{post_slug}/{slug}/") if post_slug else "" + + title_sx = "" + if market_href: + title_sx = await render_to_sx("market-market-card-title-link", href=market_href, name=name) + else: + title_sx = await render_to_sx("market-market-card-title", name=name) + + desc_sx = "" + if description: + desc_sx = await render_to_sx("market-market-card-desc", description=description) + + badge_sx = "" + if show_page_badge and p_title: + badge_href = market_url(f"/{p_slug}/") + badge_sx = await render_to_sx("market-market-card-badge", href=badge_href, title=p_title) + + return await render_to_sx( + "market-market-card", + title_content=SxExpr(title_sx) if title_sx else None, + desc_content=SxExpr(desc_sx) if desc_sx else None, + badge_content=SxExpr(badge_sx) if badge_sx else None, + ) + + +async def _market_cards_sx(markets: list, page_info: dict, page: int, has_more: bool, + next_url: str, *, show_page_badge: bool = True, + post_slug: str = "") -> str: + """Build market cards with infinite scroll sentinel as sx.""" + parts = [] + for m in markets: + parts.append(await _market_card_sx(m, page_info, show_page_badge=show_page_badge, + post_slug=post_slug)) + if has_more: + parts.append(await render_to_sx( + "sentinel-simple", + id=f"sentinel-{page}", next_url=next_url, + )) + return "(<> " + " ".join(parts) + ")" + + +async def _markets_grid(cards_sx: str) -> str: + """Wrap market cards in a grid as sx.""" + return await render_to_sx("market-markets-grid", cards=SxExpr(cards_sx)) + + +async def _no_markets_sx(message: str = "No markets available") -> str: + """Empty state for markets as sx.""" + return await render_to_sx("empty-state", icon="fa fa-store", message=message, + cls="px-3 py-12 text-center text-stone-400") + + +# --------------------------------------------------------------------------- +# Market landing page +# --------------------------------------------------------------------------- + +async def _market_landing_content_sx(post: dict) -> str: + """Build market landing page content as sx.""" + parts: list[str] = [] + if post.get("custom_excerpt"): + parts.append(await render_to_sx("market-landing-excerpt", text=post["custom_excerpt"])) + if post.get("feature_image"): + parts.append(await render_to_sx("market-landing-image", src=post["feature_image"])) + if post.get("html"): + parts.append(await render_to_sx("market-landing-html", html=post["html"])) + inner = "(<> " + " ".join(parts) + ")" if parts else "(<>)" + return await render_to_sx("market-landing-content", inner=SxExpr(inner)) diff --git a/market/sxc/pages/filters.py b/market/sxc/pages/filters.py new file mode 100644 index 0000000..c1c8987 --- /dev/null +++ b/market/sxc/pages/filters.py @@ -0,0 +1,441 @@ +"""Filter panel functions (mobile + desktop).""" +from __future__ import annotations + +from shared.sx.parser import SxExpr +from shared.sx.helpers import ( + render_to_sx, + search_mobile_sx, search_desktop_sx, +) + + +# --------------------------------------------------------------------------- +# Sentinel hyperscript constants +# --------------------------------------------------------------------------- + +_MOBILE_SENTINEL_HS = ( + "init\n" + " if not me.dataset.retryMs then set me.dataset.retryMs to 1000 end\n" + " if window.matchMedia('(min-width: 768px)').matches then set @hx-disabled to '' end\n" + "on resize from window\n" + " if window.matchMedia('(min-width: 768px)').matches then set @hx-disabled to '' else remove @hx-disabled end\n" + "on htmx:beforeRequest\n" + " if window.matchMedia('(min-width: 768px)').matches then halt end\n" + " add .hidden to .js-neterr in me\n" + " remove .hidden from .js-loading in me\n" + " remove .opacity-100 from me\n" + " add .opacity-0 to me\n" + "def backoff()\n" + " set ms to me.dataset.retryMs\n" + " if ms > 30000 then set ms to 30000 end\n" + " add .hidden to .js-loading in me\n" + " remove .hidden from .js-neterr in me\n" + " remove .opacity-0 from me\n" + " add .opacity-100 to me\n" + " wait ms ms\n" + " trigger sentinelmobile:retry\n" + " set ms to ms * 2\n" + " if ms > 30000 then set ms to 30000 end\n" + " set me.dataset.retryMs to ms\n" + "end\n" + "on htmx:sendError call backoff()\n" + "on htmx:responseError call backoff()\n" + "on htmx:timeout call backoff()" +) + +_DESKTOP_SENTINEL_HS = ( + "init\n" + " if not me.dataset.retryMs then set me.dataset.retryMs to 1000 end\n" + "on htmx:beforeRequest(event)\n" + " add .hidden to .js-neterr in me\n" + " remove .hidden from .js-loading in me\n" + " remove .opacity-100 from me\n" + " add .opacity-0 to me\n" + " set trig to null\n" + " if event.detail and event.detail.triggeringEvent then set trig to event.detail.triggeringEvent end\n" + " if trig and trig.type is 'intersect'\n" + " set scroller to the closest .js-grid-viewport\n" + " if scroller is null then halt end\n" + " if scroller.scrollTop < 20 then halt end\n" + " end\n" + "def backoff()\n" + " set ms to me.dataset.retryMs\n" + " if ms > 30000 then set ms to 30000 end\n" + " add .hidden to .js-loading in me\n" + " remove .hidden from .js-neterr in me\n" + " remove .opacity-0 from me\n" + " add .opacity-100 to me\n" + " wait ms ms\n" + " trigger sentinel:retry\n" + " set ms to ms * 2\n" + " if ms > 30000 then set ms to 30000 end\n" + " set me.dataset.retryMs to ms\n" + "end\n" + "on htmx:sendError call backoff()\n" + "on htmx:responseError call backoff()\n" + "on htmx:timeout call backoff()" +) + + +# --------------------------------------------------------------------------- +# Browse filter panels (mobile + desktop) +# --------------------------------------------------------------------------- + +async def _desktop_filter_sx(ctx: dict) -> str: + """Build the desktop aside filter panel as sx.""" + category_label = ctx.get("category_label", "") + sort_options = ctx.get("sort_options", []) + sort = ctx.get("sort", "") + labels = ctx.get("labels", []) + selected_labels = ctx.get("selected_labels", []) + stickers = ctx.get("stickers", []) + selected_stickers = ctx.get("selected_stickers", []) + brands = ctx.get("brands", []) + selected_brands = ctx.get("selected_brands", []) + liked = ctx.get("liked", False) + liked_count = ctx.get("liked_count", 0) + subs_local = ctx.get("subs_local", []) + top_local_href = ctx.get("top_local_href", "") + sub_slug = ctx.get("sub_slug", "") + + # Search + search_sx = await search_desktop_sx(ctx) + + # Category summary + sort + like + labels + stickers + cat_parts = [await render_to_sx("market-filter-category-label", label=category_label)] + + if sort_options: + cat_parts.append(await _sort_stickers_sx(sort_options, sort, ctx)) + + like_label_parts = [await _like_filter_sx(liked, liked_count, ctx)] + if labels: + like_label_parts.append(await _labels_filter_sx(labels, selected_labels, ctx, prefix="nav-labels")) + like_labels_sx = "(<> " + " ".join(like_label_parts) + ")" + cat_parts.append(await render_to_sx("market-filter-like-labels-nav", inner=SxExpr(like_labels_sx))) + + if stickers: + cat_parts.append(await _stickers_filter_sx(stickers, selected_stickers, ctx)) + + if subs_local and top_local_href: + cat_parts.append(await _subcategory_selector_sx(subs_local, top_local_href, sub_slug, ctx)) + + cat_inner_sx = "(<> " + " ".join(cat_parts) + ")" + cat_summary = await render_to_sx("market-desktop-category-summary", inner=SxExpr(cat_inner_sx)) + + # Brand filter + brand_inner = "" + if brands: + brand_inner = await _brand_filter_sx(brands, selected_brands, ctx) + brand_summary = await render_to_sx("market-desktop-brand-summary", + inner=SxExpr(brand_inner) if brand_inner else None) + + return "(<> " + " ".join([search_sx, cat_summary, brand_summary]) + ")" + + +async def _mobile_filter_summary_sx(ctx: dict) -> str: + """Build mobile filter summary as sx.""" + asset_url_fn = ctx.get("asset_url") + sort = ctx.get("sort", "") + sort_options = ctx.get("sort_options", []) + liked = ctx.get("liked", False) + liked_count = ctx.get("liked_count", 0) + selected_labels = ctx.get("selected_labels", []) + selected_stickers = ctx.get("selected_stickers", []) + selected_brands = ctx.get("selected_brands", []) + labels = ctx.get("labels", []) + stickers = ctx.get("stickers", []) + brands = ctx.get("brands", []) + + # Search bar + search_bar = await search_mobile_sx(ctx) + + # Summary chips showing active filters + chip_parts: list[str] = [] + + if sort and sort_options: + for k, l, i in sort_options: + if k == sort and callable(asset_url_fn): + chip_parts.append(await render_to_sx("market-mobile-chip-sort", src=asset_url_fn(i), label=l)) + if liked: + liked_parts = [await render_to_sx("market-mobile-chip-liked-icon")] + if liked_count is not None: + cls = "text-[10px] text-stone-500" if liked_count != 0 else "text-md text-red-500 font-bold" + liked_parts.append(await render_to_sx("market-mobile-chip-count", cls=cls, count=str(liked_count))) + liked_inner = "(<> " + " ".join(liked_parts) + ")" + chip_parts.append(await render_to_sx("market-mobile-chip-liked", inner=SxExpr(liked_inner))) + + # Selected labels + if selected_labels: + label_item_parts = [] + for sl in selected_labels: + for lb in labels: + if lb.get("name") == sl and callable(asset_url_fn): + li_parts = [await render_to_sx( + "market-mobile-chip-image", + src=asset_url_fn("nav-labels/" + sl + ".svg"), name=sl, + )] + if lb.get("count") is not None: + cls = "text-[10px] text-stone-500" if lb["count"] != 0 else "text-md text-red-500 font-bold" + li_parts.append(await render_to_sx("market-mobile-chip-count", cls=cls, count=str(lb["count"]))) + li_inner = "(<> " + " ".join(li_parts) + ")" + label_item_parts.append(await render_to_sx("market-mobile-chip-item", inner=SxExpr(li_inner))) + if label_item_parts: + label_items = "(<> " + " ".join(label_item_parts) + ")" + chip_parts.append(await render_to_sx("market-mobile-chip-list", items=SxExpr(label_items))) + + # Selected stickers + if selected_stickers: + sticker_item_parts = [] + for ss in selected_stickers: + for st in stickers: + if st.get("name") == ss and callable(asset_url_fn): + si_parts = [await render_to_sx( + "market-mobile-chip-image", + src=asset_url_fn("stickers/" + ss + ".svg"), name=ss, + )] + if st.get("count") is not None: + cls = "text-[10px] text-stone-500" if st["count"] != 0 else "text-md text-red-500 font-bold" + si_parts.append(await render_to_sx("market-mobile-chip-count", cls=cls, count=str(st["count"]))) + si_inner = "(<> " + " ".join(si_parts) + ")" + sticker_item_parts.append(await render_to_sx("market-mobile-chip-item", inner=SxExpr(si_inner))) + if sticker_item_parts: + sticker_items = "(<> " + " ".join(sticker_item_parts) + ")" + chip_parts.append(await render_to_sx("market-mobile-chip-list", items=SxExpr(sticker_items))) + + # Selected brands + if selected_brands: + brand_item_parts = [] + for b in selected_brands: + count = 0 + for br in brands: + if br.get("name") == b: + count = br.get("count", 0) + if count: + brand_item_parts.append(await render_to_sx("market-mobile-chip-brand", name=b, count=str(count))) + else: + brand_item_parts.append(await render_to_sx("market-mobile-chip-brand-zero", name=b)) + brand_items = "(<> " + " ".join(brand_item_parts) + ")" + chip_parts.append(await render_to_sx("market-mobile-chip-brand-list", items=SxExpr(brand_items))) + + chips_sx = "(<> " + " ".join(chip_parts) + ")" if chip_parts else '(<>)' + chips_row = await render_to_sx("market-mobile-chips-row", inner=SxExpr(chips_sx)) + + # Full mobile filter details + from shared.utils import route_prefix + prefix = route_prefix() + mobile_filter = await _mobile_filter_content_sx(ctx, prefix) + + return await render_to_sx( + "market-mobile-filter-summary", + search_bar=SxExpr(search_bar), + chips=SxExpr(chips_row), + filter=SxExpr(mobile_filter), + ) + + +async def _mobile_filter_content_sx(ctx: dict, prefix: str) -> str: + """Build the expanded mobile filter panel contents as sx.""" + selected_labels = ctx.get("selected_labels", []) + selected_stickers = ctx.get("selected_stickers", []) + selected_brands = ctx.get("selected_brands", []) + current_local_href = ctx.get("current_local_href", "/") + hx_select = ctx.get("hx_select_search", "#main-panel") + sort_options = ctx.get("sort_options", []) + sort = ctx.get("sort", "") + liked = ctx.get("liked", False) + liked_count = ctx.get("liked_count", 0) + labels = ctx.get("labels", []) + stickers = ctx.get("stickers", []) + brands = ctx.get("brands", []) + search = ctx.get("search", "") + qs_fn = ctx.get("qs_filter") + + parts: list[str] = [] + + # Sort options + if sort_options: + parts.append(await _sort_stickers_sx(sort_options, sort, ctx, mobile=True)) + + # Clear filters button + has_filters = search or selected_labels or selected_stickers or selected_brands + if has_filters and callable(qs_fn): + clear_url = prefix + current_local_href + qs_fn({"clear_filters": True}) + parts.append(await render_to_sx("market-mobile-clear-filters", href=clear_url, hx_select=hx_select)) + + # Like + labels row + like_label_parts = [await _like_filter_sx(liked, liked_count, ctx, mobile=True)] + if labels: + like_label_parts.append(await _labels_filter_sx(labels, selected_labels, ctx, prefix="nav-labels", mobile=True)) + like_labels_sx = "(<> " + " ".join(like_label_parts) + ")" + parts.append(await render_to_sx("market-mobile-like-labels-row", inner=SxExpr(like_labels_sx))) + + # Stickers + if stickers: + parts.append(await _stickers_filter_sx(stickers, selected_stickers, ctx, mobile=True)) + + # Brands + if brands: + parts.append(await _brand_filter_sx(brands, selected_brands, ctx, mobile=True)) + + return "(<> " + " ".join(parts) + ")" if parts else "(<>)" + + +async def _sort_stickers_sx(sort_options: list, current_sort: str, ctx: dict, mobile: bool = False) -> str: + """Build sort option stickers as sx.""" + asset_url_fn = ctx.get("asset_url") + current_local_href = ctx.get("current_local_href", "/") + hx_select = ctx.get("hx_select_search", "#main-panel") + qs_fn = ctx.get("qs_filter") + from shared.utils import route_prefix + prefix = route_prefix() + + item_parts: list[str] = [] + for k, label, icon in sort_options: + if callable(qs_fn): + href = prefix + current_local_href + qs_fn({"sort": k}) + else: + href = "#" + active = (k == current_sort) + ring = " ring-2 ring-emerald-500 rounded" if active else "" + src = asset_url_fn(icon) if callable(asset_url_fn) else icon + item_parts.append(await render_to_sx( + "market-filter-sort-item", + href=href, hx_select=hx_select, ring_cls=ring, src=src, label=label, + )) + items_sx = "(<> " + " ".join(item_parts) + ")" if item_parts else "(<>)" + return await render_to_sx("market-filter-sort-row", items=SxExpr(items_sx)) + + +async def _like_filter_sx(liked: bool, liked_count: int, ctx: dict, mobile: bool = False) -> str: + """Build the like filter toggle as sx.""" + current_local_href = ctx.get("current_local_href", "/") + hx_select = ctx.get("hx_select_search", "#main-panel") + qs_fn = ctx.get("qs_filter") + from shared.utils import route_prefix + prefix = route_prefix() + + if callable(qs_fn): + href = prefix + current_local_href + qs_fn({"liked": not liked}) + else: + href = "#" + + icon_cls = "fa-solid fa-heart text-red-500" if liked else "fa-regular fa-heart text-stone-400" + size = "text-[40px]" if mobile else "text-2xl" + return await render_to_sx( + "market-filter-like", + href=href, hx_select=hx_select, icon_cls=icon_cls, size_cls=size, + ) + + +async def _labels_filter_sx(labels: list, selected: list, ctx: dict, *, + prefix: str = "nav-labels", mobile: bool = False) -> str: + """Build label filter buttons as sx.""" + asset_url_fn = ctx.get("asset_url") + current_local_href = ctx.get("current_local_href", "/") + hx_select = ctx.get("hx_select_search", "#main-panel") + qs_fn = ctx.get("qs_filter") + from shared.utils import route_prefix + rp = route_prefix() + + item_parts: list[str] = [] + for lb in labels: + name = lb.get("name", "") + is_sel = name in selected + if callable(qs_fn): + new_sel = [s for s in selected if s != name] if is_sel else selected + [name] + href = rp + current_local_href + qs_fn({"labels": new_sel}) + else: + href = "#" + ring = " ring-2 ring-emerald-500 rounded" if is_sel else "" + src = asset_url_fn(f"{prefix}/{name}.svg") if callable(asset_url_fn) else "" + item_parts.append(await render_to_sx( + "market-filter-label-item", + href=href, hx_select=hx_select, ring_cls=ring, src=src, name=name, + )) + return "(<> " + " ".join(item_parts) + ")" if item_parts else "(<>)" + + +async def _stickers_filter_sx(stickers: list, selected: list, ctx: dict, mobile: bool = False) -> str: + """Build sticker filter grid as sx.""" + asset_url_fn = ctx.get("asset_url") + current_local_href = ctx.get("current_local_href", "/") + hx_select = ctx.get("hx_select_search", "#main-panel") + qs_fn = ctx.get("qs_filter") + from shared.utils import route_prefix + rp = route_prefix() + + item_parts: list[str] = [] + for st in stickers: + name = st.get("name", "") + count = st.get("count", 0) + is_sel = name in selected + if callable(qs_fn): + new_sel = [s for s in selected if s != name] if is_sel else selected + [name] + href = rp + current_local_href + qs_fn({"stickers": new_sel}) + else: + href = "#" + ring = " ring-2 ring-emerald-500 rounded" if is_sel else "" + src = asset_url_fn(f"stickers/{name}.svg") if callable(asset_url_fn) else "" + cls = "text-[10px] text-stone-500" if count != 0 else "text-md text-red-500 font-bold" + item_parts.append(await render_to_sx( + "market-filter-sticker-item", + href=href, hx_select=hx_select, ring_cls=ring, + src=src, name=name, count_cls=cls, count=str(count), + )) + items_sx = "(<> " + " ".join(item_parts) + ")" if item_parts else "(<>)" + return await render_to_sx("market-filter-stickers-row", items=SxExpr(items_sx)) + + +async def _brand_filter_sx(brands: list, selected: list, ctx: dict, mobile: bool = False) -> str: + """Build brand filter checkboxes as sx.""" + current_local_href = ctx.get("current_local_href", "/") + hx_select = ctx.get("hx_select_search", "#main-panel") + qs_fn = ctx.get("qs_filter") + from shared.utils import route_prefix + rp = route_prefix() + + item_parts: list[str] = [] + for br in brands: + name = br.get("name", "") + count = br.get("count", 0) + is_sel = name in selected + if callable(qs_fn): + new_sel = [s for s in selected if s != name] if is_sel else selected + [name] + href = rp + current_local_href + qs_fn({"brands": new_sel}) + else: + href = "#" + bg = " bg-yellow-200" if is_sel else "" + cls = "text-md" if count else "text-md text-red-500" + item_parts.append(await render_to_sx( + "market-filter-brand-item", + href=href, hx_select=hx_select, bg_cls=bg, + name_cls=cls, name=name, count=str(count), + )) + items_sx = "(<> " + " ".join(item_parts) + ")" if item_parts else "(<>)" + return await render_to_sx("market-filter-brands-panel", items=SxExpr(items_sx)) + + +async def _subcategory_selector_sx(subs: list, top_href: str, current_sub: str, ctx: dict) -> str: + """Build subcategory vertical nav as sx.""" + hx_select = ctx.get("hx_select_search", "#main-panel") + from shared.utils import route_prefix + rp = route_prefix() + + all_cls = " bg-stone-200 font-medium" if not current_sub else "" + all_full_href = rp + top_href + item_parts = [await render_to_sx( + "market-filter-subcategory-item", + href=all_full_href, hx_select=hx_select, active_cls=all_cls, name="All", + )] + for sub in subs: + slug = sub.get("slug", "") + name = sub.get("name", "") + href = sub.get("href", "") + active = (slug == current_sub) + active_cls = " bg-stone-200 font-medium" if active else "" + full_href = rp + href + item_parts.append(await render_to_sx( + "market-filter-subcategory-item", + href=full_href, hx_select=hx_select, active_cls=active_cls, name=name, + )) + items_sx = "(<> " + " ".join(item_parts) + ")" + return await render_to_sx("market-filter-subcategory-panel", items=SxExpr(items_sx)) diff --git a/market/sxc/pages/helpers.py b/market/sxc/pages/helpers.py new file mode 100644 index 0000000..d6183b4 --- /dev/null +++ b/market/sxc/pages/helpers.py @@ -0,0 +1,168 @@ +"""Page helpers for market defpage system.""" +from __future__ import annotations + +from .cards import ( + _market_cards_sx, _markets_grid, _no_markets_sx, + _market_landing_content_sx, +) + + +# --------------------------------------------------------------------------- +# Page admin panel (used by _h_page_admin_content) +# --------------------------------------------------------------------------- + +async def _markets_admin_panel_sx(ctx: dict) -> str: + """Render the markets list + create form panel.""" + from quart import g, url_for + from shared.services.registry import services + from shared.sx.helpers import render_to_sx + from shared.sx.parser import SxExpr + + rights = ctx.get("rights") or {} + is_admin = getattr(rights, "admin", False) if hasattr(rights, "admin") else rights.get("admin", False) + has_access = ctx.get("has_access") + can_create = has_access("page_admin.create_market") if callable(has_access) else is_admin + csrf_token = ctx.get("csrf_token") + csrf = csrf_token() if callable(csrf_token) else (csrf_token or "") + + post = ctx.get("post") or {} + post_id = post.get("id") + markets = await services.market.marketplaces_for_container(g.s, "page", post_id) if post_id else [] + + form_html = "" + if can_create: + create_url = url_for("page_admin.create_market") + form_html = await render_to_sx("crud-create-form", + create_url=create_url, csrf=csrf, + errors_id="market-create-errors", + list_id="markets-list", + placeholder="e.g. Suma, Craft Fair", + btn_label="Add market") + + list_html = await _markets_admin_list_sx(ctx, markets) + return await render_to_sx("crud-panel", + form=SxExpr(form_html), list=SxExpr(list_html), + list_id="markets-list") + + +async def _markets_admin_list_sx(ctx: dict, markets: list) -> str: + """Render the markets list items.""" + from quart import url_for + from shared.utils import route_prefix + from shared.sx.helpers import render_to_sx + csrf_token = ctx.get("csrf_token") + csrf = csrf_token() if callable(csrf_token) else (csrf_token or "") + prefix = route_prefix() + + if not markets: + return await render_to_sx("empty-state", + message="No markets yet. Create one above.", + cls="text-gray-500 mt-4") + + parts = [] + for m in markets: + m_slug = getattr(m, "slug", "") or (m.get("slug", "") if isinstance(m, dict) else "") + m_name = getattr(m, "name", "") or (m.get("name", "") if isinstance(m, dict) else "") + post_slug = (ctx.get("post") or {}).get("slug", "") + href = prefix + f"/{post_slug}/{m_slug}/" + del_url = url_for("page_admin.delete_market", market_slug=m_slug) + csrf_hdr = f'{{"X-CSRFToken":"{csrf}"}}' + parts.append(await render_to_sx("crud-item", + href=href, name=m_name, slug=m_slug, + del_url=del_url, csrf_hdr=csrf_hdr, + list_id="markets-list", + confirm_title="Delete market?", + confirm_text="Products will be hidden (soft delete)")) + return "".join(parts) + + +# =========================================================================== +# Page helpers +# =========================================================================== + +def _register_market_helpers() -> None: + from shared.sx.pages import register_page_helpers + + register_page_helpers("market", { + "all-markets-content": _h_all_markets_content, + "page-markets-content": _h_page_markets_content, + "page-admin-content": _h_page_admin_content, + "market-home-content": _h_market_home_content, + "market-admin-content": _h_market_admin_content, + }) + + +async def _h_all_markets_content(**kw): + from quart import g, url_for, request + from shared.utils import route_prefix + from shared.services.registry import services + from shared.infrastructure.data_client import fetch_data + from shared.contracts.dtos import PostDTO, dto_from_dict + + page = int(request.args.get("page", 1)) + markets, has_more = await services.market.list_marketplaces( + g.s, page=page, per_page=20, + ) + + page_info = {} + if markets: + post_ids = list({m.container_id for m in markets if m.container_type == "page"}) + if post_ids: + raw_posts = await fetch_data("blog", "posts-by-ids", + params={"ids": ",".join(str(i) for i in post_ids)}, + required=False) or [] + for raw_p in raw_posts: + p = dto_from_dict(PostDTO, raw_p) + page_info[p.id] = {"title": p.title, "slug": p.slug} + + if not markets: + return await _no_markets_sx() + + prefix = route_prefix() + next_url = prefix + url_for("all_markets.markets_fragment", page=page + 1) + + cards = await _market_cards_sx(markets, page_info, page, has_more, next_url) + content = await _markets_grid(cards) + return "(<> " + content + " " + '(div :class "pb-8")' + ")" + + +async def _h_page_markets_content(slug=None, **kw): + from quart import g, url_for, request + from shared.utils import route_prefix + from shared.services.registry import services + + post = g.post_data["post"] + page = int(request.args.get("page", 1)) + markets, has_more = await services.market.list_marketplaces( + g.s, "page", post["id"], page=page, per_page=20, + ) + post_slug = post.get("slug", "") + + if not markets: + return await _no_markets_sx("No markets for this page") + + prefix = route_prefix() + next_url = prefix + url_for("page_markets.markets_fragment", page=page + 1) + + cards = await _market_cards_sx(markets, {}, page, has_more, next_url, + show_page_badge=False, post_slug=post_slug) + content = await _markets_grid(cards) + return "(<> " + content + " " + '(div :class "pb-8")' + ")" + + +async def _h_page_admin_content(slug=None, **kw): + from shared.sx.page import get_template_context + ctx = await get_template_context() + content = await _markets_admin_panel_sx(ctx) + return '(div :id "main-panel" ' + content + ')' + + +async def _h_market_home_content(page_slug=None, market_slug=None, **kw): + from quart import g + post_data = getattr(g, "post_data", {}) + post = post_data.get("post", {}) + return await _market_landing_content_sx(post) + + +def _h_market_admin_content(page_slug=None, market_slug=None, **kw): + return '"market admin"' diff --git a/market/sxc/pages/layouts.py b/market/sxc/pages/layouts.py new file mode 100644 index 0000000..0f0f563 --- /dev/null +++ b/market/sxc/pages/layouts.py @@ -0,0 +1,334 @@ +"""Layout registration + header builders.""" +from __future__ import annotations + +from typing import Any + +from shared.sx.parser import serialize, SxExpr +from shared.sx.helpers import ( + render_to_sx, + post_header_sx as _post_header_sx, + post_admin_header_sx, + oob_header_sx as _oob_header_sx, + header_child_sx, +) + +from .utils import _set_prices, _price_str, _clear_deeper_oob + + +# --------------------------------------------------------------------------- +# Header helpers +# --------------------------------------------------------------------------- + +async def _market_header_sx(ctx: dict, *, oob: bool = False) -> str: + """Build the market-level header row as sx call string.""" + from quart import url_for + + market_title = ctx.get("market_title", "") + top_slug = ctx.get("top_slug", "") + sub_slug = ctx.get("sub_slug", "") + hx_select_search = ctx.get("hx_select_search", "#main-panel") + + sub_div = f'(div {serialize(sub_slug)})' if sub_slug else "" + label_sx = await render_to_sx( + "market-shop-label", + title=market_title, top_slug=top_slug or "", + sub_div=SxExpr(sub_div) if sub_div else None, + ) + + link_href = url_for("defpage_market_home") + + # Build desktop nav from categories + categories = ctx.get("categories", {}) + qs = ctx.get("qs", "") + nav_sx = await _desktop_category_nav_sx(ctx, categories, qs, hx_select_search) + + return await render_to_sx( + "menu-row-sx", + id="market-row", level=2, + link_href=link_href, link_label_content=SxExpr(label_sx), + nav=SxExpr(nav_sx) if nav_sx else None, + child_id="market-header-child", oob=oob, + ) + + +async def _desktop_category_nav_sx(ctx: dict, categories: dict, qs: str, + hx_select: str) -> str: + """Build desktop category navigation links as sx.""" + from quart import url_for + from shared.utils import route_prefix + + prefix = route_prefix() + category_label = ctx.get("category_label", "") + select_colours = ctx.get("select_colours", "") + rights = ctx.get("rights", {}) + + all_href = prefix + url_for("market.browse.browse_all") + qs + all_active = (category_label == "All Products") + link_parts = [await render_to_sx( + "market-category-link", + href=all_href, hx_select=hx_select, active=all_active, + select_colours=select_colours, label="All", + )] + + for cat, data in categories.items(): + cat_href = prefix + url_for("market.browse.browse_top", top_slug=data["slug"]) + qs + cat_active = (cat == category_label) + link_parts.append(await render_to_sx( + "market-category-link", + href=cat_href, hx_select=hx_select, active=cat_active, + select_colours=select_colours, label=cat, + )) + + links_sx = "(<> " + " ".join(link_parts) + ")" + + admin_sx = "" + if rights and rights.get("admin"): + admin_href = prefix + url_for("defpage_market_admin") + admin_sx = await render_to_sx("market-admin-link", href=admin_href, hx_select=hx_select) + + return await render_to_sx("market-desktop-category-nav", + links=SxExpr(links_sx), + admin=SxExpr(admin_sx) if admin_sx else None) + + +async def _product_header_sx(ctx: dict, d: dict, *, oob: bool = False) -> str: + """Build the product-level header row as sx call string.""" + from quart import url_for + from shared.browser.app.csrf import generate_csrf_token + + slug = d.get("slug", "") + title = d.get("title", "") + hx_select_search = ctx.get("hx_select_search", "#main-panel") + link_href = url_for("market.browse.product.product_detail", product_slug=slug) + + label_sx = await render_to_sx("market-product-label", title=title) + + # Prices in nav area + pr = _set_prices(d) + cart = ctx.get("cart", []) + prices_nav = await _prices_header_sx(d, pr, cart, slug, ctx) + + rights = ctx.get("rights", {}) + nav_parts = [prices_nav] + if rights and rights.get("admin"): + admin_href = url_for("market.browse.product.admin", product_slug=slug) + nav_parts.append(await render_to_sx("market-admin-link", href=admin_href, hx_select=hx_select_search)) + nav_sx = "(<> " + " ".join(nav_parts) + ")" + + return await render_to_sx( + "menu-row-sx", + id="product-row", level=3, + link_href=link_href, link_label_content=SxExpr(label_sx), + nav=SxExpr(nav_sx), child_id="product-header-child", oob=oob, + ) + + +async def _prices_header_sx(d: dict, pr: dict, cart: list, slug: str, ctx: dict) -> str: + """Build prices + add-to-cart for product header row as sx.""" + from quart import url_for + from shared.browser.app.csrf import generate_csrf_token + + csrf = generate_csrf_token() + cart_action = url_for("market.browse.product.cart", product_slug=slug) + cart_url_fn = ctx.get("cart_url") + + # Add-to-cart button + quantity = sum(ci.quantity for ci in cart if ci.product.slug == slug) if cart else 0 + add_sx = await _cart_add_sx(slug, quantity, cart_action, csrf, cart_url_fn) + + parts = [add_sx] + sp_val, rp_val = pr.get("sp_val"), pr.get("rp_val") + if sp_val: + parts.append(await render_to_sx("market-header-price-special-label")) + parts.append(await render_to_sx("market-header-price-special", + price=_price_str(sp_val, pr["sp_raw"], pr["sp_cur"]))) + if rp_val: + parts.append(await render_to_sx("market-header-price-strike", + price=_price_str(rp_val, pr["rp_raw"], pr["rp_cur"]))) + elif rp_val: + parts.append(await render_to_sx("market-header-price-regular-label")) + parts.append(await render_to_sx("market-header-price-regular", + price=_price_str(rp_val, pr["rp_raw"], pr["rp_cur"]))) + + # RRP + rrp_raw = d.get("rrp_raw") + rrp_val = d.get("rrp") + case_size = d.get("case_size_count") or 1 + if rrp_raw and rrp_val: + rrp_str = f"{rrp_raw[0]}{rrp_val * case_size:.2f}" + parts.append(await render_to_sx("market-header-rrp", rrp=rrp_str)) + + inner_sx = "(<> " + " ".join(parts) + ")" + return await render_to_sx("market-prices-row", inner=SxExpr(inner_sx)) + + +async def _cart_add_sx(slug: str, quantity: int, action: str, csrf: str, + cart_url_fn: Any = None) -> str: + """Build add-to-cart button or quantity controls as sx.""" + if not quantity: + return await render_to_sx( + "market-cart-add-empty", + cart_id=f"cart-{slug}", action=action, csrf=csrf, + ) + + cart_href = cart_url_fn("/") if callable(cart_url_fn) else "/" + return await render_to_sx( + "market-cart-add-quantity", + cart_id=f"cart-{slug}", action=action, csrf=csrf, + minus_val=str(quantity - 1), plus_val=str(quantity + 1), + quantity=str(quantity), cart_href=cart_href, + ) + + +# --------------------------------------------------------------------------- +# Mobile nav panel +# --------------------------------------------------------------------------- + +async def _mobile_nav_panel_sx(ctx: dict) -> str: + """Build mobile nav panel with category accordion as sx.""" + from quart import url_for + from shared.utils import route_prefix + + prefix = route_prefix() + categories = ctx.get("categories", {}) + qs = ctx.get("qs", "") + category_label = ctx.get("category_label", "") + top_slug = ctx.get("top_slug", "") + sub_slug = ctx.get("sub_slug", "") + hx_select = ctx.get("hx_select_search", "#main-panel") + select_colours = ctx.get("select_colours", "") + + all_href = prefix + url_for("market.browse.browse_all") + qs + all_active = (category_label == "All Products") + item_parts = [await render_to_sx( + "market-mobile-all-link", + href=all_href, hx_select=hx_select, active=all_active, + select_colours=select_colours, + )] + + for cat, data in categories.items(): + cat_slug = data.get("slug", "") + cat_active = (top_slug == cat_slug.lower() if top_slug else False) + cat_href = prefix + url_for("market.browse.browse_top", top_slug=cat_slug) + qs + bg_cls = " bg-stone-900 text-white hover:bg-stone-900" if cat_active else "" + + chevron_sx = await render_to_sx("market-mobile-chevron") + + cat_count = data.get("count", 0) + summary_sx = await render_to_sx( + "market-mobile-cat-summary", + bg_cls=bg_cls, href=cat_href, hx_select=hx_select, + select_colours=select_colours, cat_name=cat, + count_label=f"{cat_count} products", count_str=str(cat_count), + chevron=SxExpr(chevron_sx), + ) + + subs = data.get("subs", []) + subs_sx = "" + if subs: + sub_link_parts = [] + for sub in subs: + sub_href = prefix + url_for("market.browse.browse_sub", top_slug=cat_slug, sub_slug=sub["slug"]) + qs + sub_active = (cat_active and sub_slug == sub.get("slug")) + sub_label = sub.get("html_label") or sub.get("name", "") + sub_count = sub.get("count", 0) + sub_link_parts.append(await render_to_sx( + "market-mobile-sub-link", + select_colours=select_colours, active=sub_active, + href=sub_href, hx_select=hx_select, label=sub_label, + count_label=f"{sub_count} products", count_str=str(sub_count), + )) + sub_links_sx = "(<> " + " ".join(sub_link_parts) + ")" + subs_sx = await render_to_sx("market-mobile-subs-panel", links=SxExpr(sub_links_sx)) + else: + view_href = prefix + url_for("market.browse.browse_top", top_slug=cat_slug) + qs + subs_sx = await render_to_sx("market-mobile-view-all", href=view_href, hx_select=hx_select) + + item_parts.append(await render_to_sx( + "market-mobile-cat-details", + open=cat_active or None, + summary=SxExpr(summary_sx), + subs=SxExpr(subs_sx), + )) + + items_sx = "(<> " + " ".join(item_parts) + ")" + return await render_to_sx("market-mobile-nav-wrapper", items=SxExpr(items_sx)) + + +# --------------------------------------------------------------------------- +# Product admin header +# --------------------------------------------------------------------------- + +async def _product_admin_header_sx(ctx: dict, d: dict, *, oob: bool = False) -> str: + """Build product admin header row as sx.""" + from quart import url_for + + slug = d.get("slug", "") + link_href = url_for("market.browse.product.admin", product_slug=slug) + return await render_to_sx( + "menu-row-sx", + id="product-admin-row", level=4, + link_href=link_href, link_label="admin!!", icon="fa fa-cog", + child_id="product-admin-header-child", oob=oob, + ) + + +# --------------------------------------------------------------------------- +# Market admin header +# --------------------------------------------------------------------------- + +async def _market_admin_header_sx(ctx: dict, *, oob: bool = False, selected: str = "") -> str: + """Build market admin header row -- delegates to shared helper.""" + slug = (ctx.get("post") or {}).get("slug", "") + return await post_admin_header_sx(ctx, slug, oob=oob, selected=selected) + + +# =========================================================================== +# Layout registration +# =========================================================================== + +def _register_market_layouts() -> None: + from shared.sx.layouts import register_custom_layout + register_custom_layout("market", _market_full, _market_oob, _market_mobile) + register_custom_layout("market-admin", _market_admin_full, _market_admin_oob) + + +async def _market_full(ctx: dict, **kw: Any) -> str: + from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env + return await render_to_sx_with_env("market-browse-layout-full", _ctx_to_env(ctx), + post_header=SxExpr(await _post_header_sx(ctx)), + market_header=SxExpr(await _market_header_sx(ctx))) + + +async def _market_oob(ctx: dict, **kw: Any) -> str: + oob_hdr = await _oob_header_sx("post-header-child", "market-header-child", + await _market_header_sx(ctx)) + return await render_to_sx("market-browse-layout-oob", + oob_header=SxExpr(oob_hdr), + post_header_oob=SxExpr(await _post_header_sx(ctx, oob=True)), + clear_oob=SxExpr(_clear_deeper_oob("post-row", "post-header-child", + "market-row", "market-header-child"))) + + +async def _market_mobile(ctx: dict, **kw: Any) -> str: + return await _mobile_nav_panel_sx(ctx) + + +async def _market_admin_full(ctx: dict, **kw: Any) -> str: + from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env + selected = kw.get("selected", "") + return await render_to_sx_with_env("market-admin-layout-full", _ctx_to_env(ctx), + post_header=SxExpr(await _post_header_sx(ctx)), + market_header=SxExpr(await _market_header_sx(ctx)), + admin_header=SxExpr(await _market_admin_header_sx(ctx, selected=selected))) + + +async def _market_admin_oob(ctx: dict, **kw: Any) -> str: + selected = kw.get("selected", "") + return await render_to_sx("market-admin-layout-oob", + market_header_oob=SxExpr(await _market_header_sx(ctx, oob=True)), + admin_oob_header=SxExpr(await _oob_header_sx("market-header-child", "market-admin-header-child", + await _market_admin_header_sx(ctx, selected=selected))), + clear_oob=SxExpr(_clear_deeper_oob("post-row", "post-header-child", + "market-row", "market-header-child", + "market-admin-row", "market-admin-header-child"))) diff --git a/market/sxc/pages/renders.py b/market/sxc/pages/renders.py new file mode 100644 index 0000000..1178bd7 --- /dev/null +++ b/market/sxc/pages/renders.py @@ -0,0 +1,249 @@ +"""Public render functions called from bp routes.""" +from __future__ import annotations + +from typing import Any + +from shared.sx.parser import SxExpr +from shared.sx.helpers import ( + render_to_sx, + post_header_sx as _post_header_sx, + oob_header_sx as _oob_header_sx, + full_page_sx, oob_page_sx, +) + +from .utils import _set_prices, _price_str, _clear_deeper_oob, _product_detail_sx, _product_meta_sx +from .cards import _product_cards_sx, _market_cards_sx +from .filters import _desktop_filter_sx, _mobile_filter_summary_sx +from .layouts import ( + _market_header_sx, _product_header_sx, _product_admin_header_sx, + _mobile_nav_panel_sx, +) +from .helpers import _markets_admin_panel_sx + + +# --------------------------------------------------------------------------- +# Browse page +# --------------------------------------------------------------------------- + +async def _product_grid(cards_sx: str) -> str: + """Wrap product cards in a grid as sx.""" + return await render_to_sx("market-product-grid", cards=SxExpr(cards_sx)) + + +async def render_browse_page(ctx: dict) -> str: + """Full page: product browse with filters.""" + cards = await _product_cards_sx(ctx) + content = await _product_grid(cards) + + from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env + hdr = await render_to_sx_with_env("market-browse-layout-full", _ctx_to_env(ctx), + post_header=SxExpr(await _post_header_sx(ctx)), + market_header=SxExpr(await _market_header_sx(ctx))) + menu = await _mobile_nav_panel_sx(ctx) + filter_sx = await _mobile_filter_summary_sx(ctx) + aside_sx = await _desktop_filter_sx(ctx) + + return await full_page_sx(ctx, header_rows=hdr, content=content, + menu=menu, filter=filter_sx, aside=aside_sx) + + +async def render_browse_oob(ctx: dict) -> str: + """OOB response: product browse.""" + cards = await _product_cards_sx(ctx) + content = await _product_grid(cards) + + oob_hdr = await _oob_header_sx("post-header-child", "market-header-child", + await _market_header_sx(ctx)) + oobs = await render_to_sx("market-browse-layout-oob", + oob_header=SxExpr(oob_hdr), + post_header_oob=SxExpr(await _post_header_sx(ctx, oob=True)), + clear_oob=SxExpr(_clear_deeper_oob("post-row", "post-header-child", + "market-row", "market-header-child"))) + menu = await _mobile_nav_panel_sx(ctx) + filter_sx = await _mobile_filter_summary_sx(ctx) + aside_sx = await _desktop_filter_sx(ctx) + + return await oob_page_sx(oobs=oobs, content=content, + menu=menu, filter=filter_sx, aside=aside_sx) + + +async def render_browse_cards(ctx: dict) -> str: + """Pagination fragment: product cards -- sx wire format.""" + return await _product_cards_sx(ctx) + + +# --------------------------------------------------------------------------- +# Product detail +# --------------------------------------------------------------------------- + +async def render_product_page(ctx: dict, d: dict) -> str: + """Full page: product detail.""" + content = await _product_detail_sx(d, ctx) + meta = await _product_meta_sx(d, ctx) + + from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env + hdr = await render_to_sx_with_env("market-product-layout-full", _ctx_to_env(ctx), + post_header=SxExpr(await _post_header_sx(ctx)), + market_header=SxExpr(await _market_header_sx(ctx)), + product_header=SxExpr(await _product_header_sx(ctx, d))) + return await full_page_sx(ctx, header_rows=hdr, content=content, meta=meta) + + +async def render_product_oob(ctx: dict, d: dict) -> str: + """OOB response: product detail.""" + content = await _product_detail_sx(d, ctx) + + oobs = await render_to_sx("market-oob-wrap", + parts=SxExpr("(<> " + await _market_header_sx(ctx, oob=True) + " " + + await _oob_header_sx("market-header-child", "product-header-child", + await _product_header_sx(ctx, d)) + " " + + _clear_deeper_oob("post-row", "post-header-child", + "market-row", "market-header-child", + "product-row", "product-header-child") + ")")) + menu = await _mobile_nav_panel_sx(ctx) + return await oob_page_sx(oobs=oobs, content=content, menu=menu) + + +# --------------------------------------------------------------------------- +# Product admin +# --------------------------------------------------------------------------- + +async def render_product_admin_page(ctx: dict, d: dict) -> str: + """Full page: product admin.""" + content = await _product_detail_sx(d, ctx) + + from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env + hdr = await render_to_sx_with_env("market-product-admin-layout-full", _ctx_to_env(ctx), + post_header=SxExpr(await _post_header_sx(ctx)), + market_header=SxExpr(await _market_header_sx(ctx)), + product_header=SxExpr(await _product_header_sx(ctx, d)), + admin_header=SxExpr(await _product_admin_header_sx(ctx, d))) + return await full_page_sx(ctx, header_rows=hdr, content=content) + + +async def render_product_admin_oob(ctx: dict, d: dict) -> str: + """OOB response: product admin.""" + content = await _product_detail_sx(d, ctx) + + oobs = await render_to_sx("market-oob-wrap", + parts=SxExpr("(<> " + await _product_header_sx(ctx, d, oob=True) + " " + + await _oob_header_sx("product-header-child", "product-admin-header-child", + await _product_admin_header_sx(ctx, d)) + " " + + _clear_deeper_oob("post-row", "post-header-child", + "market-row", "market-header-child", + "product-row", "product-header-child", + "product-admin-row", "product-admin-header-child") + ")")) + return await oob_page_sx(oobs=oobs, content=content) + + +# --------------------------------------------------------------------------- +# Market admin list panel +# --------------------------------------------------------------------------- + +async def render_markets_admin_list_panel(ctx: dict) -> str: + """Render the markets admin panel HTML for POST/DELETE response.""" + return await _markets_admin_panel_sx(ctx) + + +# --------------------------------------------------------------------------- +# Public API: POST handler fragment renderers +# --------------------------------------------------------------------------- + +async def render_all_markets_cards(markets: list, has_more: bool, + page_info: dict, page: int) -> str: + """Pagination fragment: all markets cards.""" + from quart import url_for + from shared.utils import route_prefix + + prefix = route_prefix() + next_url = prefix + url_for("all_markets.markets_fragment", page=page + 1) + return await _market_cards_sx(markets, page_info, page, has_more, next_url) + + +async def render_page_markets_cards(markets: list, has_more: bool, + page: int, post_slug: str) -> str: + """Pagination fragment: page-scoped markets cards.""" + from quart import url_for + from shared.utils import route_prefix + + prefix = route_prefix() + next_url = prefix + url_for("page_markets.markets_fragment", page=page + 1) + return await _market_cards_sx(markets, {}, page, has_more, next_url, + show_page_badge=False, post_slug=post_slug) + + +async def render_like_toggle_button(slug: str, liked: bool, *, + like_url: str | None = None, + item_type: str = "product") -> str: + """Render a standalone like toggle button for HTMX POST response.""" + from shared.browser.app.csrf import generate_csrf_token + from quart import url_for + from shared.utils import host_url + + csrf = generate_csrf_token() + if not like_url: + like_url = host_url(url_for("market.browse.product.like_toggle", product_slug=slug)) + + if liked: + colour = "text-red-600" + icon = "fa-solid fa-heart" + label = f"Unlike this {item_type}" + else: + colour = "text-stone-300" + icon = "fa-regular fa-heart" + label = f"Like this {item_type}" + + return await render_to_sx( + "market-like-toggle-button", + colour=colour, action=like_url, + hx_headers=f'{{"X-CSRFToken": "{csrf}"}}', + label=label, icon_cls=icon, + ) + + +async def render_cart_added_response(cart: list, item: Any, d: dict) -> str: + """Render the HTMX response after add-to-cart. + + Returns OOB fragments: cart-mini icon + product add/remove buttons + cart item row. + """ + from shared.browser.app.csrf import generate_csrf_token + from quart import url_for, g + from shared.infrastructure.urls import cart_url as _cart_url + + csrf = generate_csrf_token() + slug = d.get("slug", "") + count = sum(getattr(ci, "quantity", 0) for ci in cart) + + # 1. Cart mini icon OOB + if count > 0: + cart_href = _cart_url("/") + cart_mini = await render_to_sx("market-cart-mini-count", href=cart_href, count=str(count)) + else: + from shared.config import config + blog_href = config().get("blog_url", "/") + logo = config().get("logo", "") + cart_mini = await render_to_sx("market-cart-mini-empty", href=blog_href, logo=logo) + + # 2. Add/remove buttons OOB + action = url_for("market.browse.product.cart", product_slug=slug) + quantity = getattr(item, "quantity", 0) if item else 0 + if not quantity: + cart_add = await render_to_sx( + "market-cart-add-empty", + cart_id=f"cart-{slug}", action=action, csrf=csrf, + ) + else: + cart_href = _cart_url("/") if callable(_cart_url) else "/" + cart_add = await render_to_sx( + "market-cart-add-quantity", + cart_id=f"cart-{slug}", action=action, csrf=csrf, + minus_val=str(quantity - 1), plus_val=str(quantity + 1), + quantity=str(quantity), cart_href=cart_href, + ) + add_sx = await render_to_sx( + "market-cart-add-oob", + id=f"cart-add-{slug}", + inner=SxExpr(cart_add), + ) + + return "(<> " + cart_mini + " " + add_sx + ")" diff --git a/market/sxc/pages/utils.py b/market/sxc/pages/utils.py new file mode 100644 index 0000000..25caa65 --- /dev/null +++ b/market/sxc/pages/utils.py @@ -0,0 +1,287 @@ +"""Price helpers, OOB helpers, product detail/meta builders.""" +from __future__ import annotations + +from typing import Any + +from shared.sx.parser import serialize, SxExpr +from shared.sx.helpers import render_to_sx + + +# --------------------------------------------------------------------------- +# OOB orphan cleanup +# --------------------------------------------------------------------------- + +_MARKET_DEEP_IDS = [ + "product-admin-row", "product-admin-header-child", + "product-row", "product-header-child", + "market-admin-row", "market-admin-header-child", + "market-row", "market-header-child", + "post-admin-row", "post-admin-header-child", +] + + +def _clear_deeper_oob(*keep_ids: str) -> str: + """Clear all market header rows/children NOT in keep_ids.""" + to_clear = [i for i in _MARKET_DEEP_IDS if i not in keep_ids] + return " ".join(f'(div :id "{i}" :sx-swap-oob "outerHTML")' for i in to_clear) + + +# --------------------------------------------------------------------------- +# Price helpers +# --------------------------------------------------------------------------- + +_SYM = {"GBP": "\u00a3", "EUR": "\u20ac", "USD": "$"} + + +def _price_str(val, raw, cur) -> str: + if raw: + return str(raw) + if isinstance(val, (int, float)): + return f"{_SYM.get(cur, '')}{val:.2f}" + return str(val or "") + + +def _set_prices(item: dict) -> dict: + """Extract price values from product dict (mirrors prices.html set_prices macro).""" + oe = item.get("oe_list_price") or {} + sp_val = item.get("special_price") or (oe.get("special") if oe else None) + sp_raw = item.get("special_price_raw") or (oe.get("special_raw") if oe else None) + sp_cur = item.get("special_price_currency") or (oe.get("special_currency") if oe else None) + rp_val = item.get("regular_price") or item.get("rrp") or (oe.get("rrp") if oe else None) + rp_raw = item.get("regular_price_raw") or item.get("rrp_raw") or (oe.get("rrp_raw") if oe else None) + rp_cur = item.get("regular_price_currency") or item.get("rrp_currency") or (oe.get("rrp_currency") if oe else None) + return dict(sp_val=sp_val, sp_raw=sp_raw, sp_cur=sp_cur, + rp_val=rp_val, rp_raw=rp_raw, rp_cur=rp_cur) + + +async def _card_price_sx(p: dict) -> str: + """Build price line for product card as sx call.""" + pr = _set_prices(p) + sp_str = _price_str(pr["sp_val"], pr["sp_raw"], pr["sp_cur"]) + rp_str = _price_str(pr["rp_val"], pr["rp_raw"], pr["rp_cur"]) + parts: list[str] = [] + if pr["sp_val"]: + parts.append(await render_to_sx("market-price-special", price=sp_str)) + if pr["rp_val"]: + parts.append(await render_to_sx("market-price-regular-strike", price=rp_str)) + elif pr["rp_val"]: + parts.append(await render_to_sx("market-price-regular", price=rp_str)) + inner = "(<> " + " ".join(parts) + ")" if parts else None + return await render_to_sx("market-price-line", inner=SxExpr(inner) if inner else None) + + +# --------------------------------------------------------------------------- +# Product detail page content +# --------------------------------------------------------------------------- + +async def _product_detail_sx(d: dict, ctx: dict) -> str: + """Build product detail main panel content as sx.""" + from quart import url_for + from shared.browser.app.csrf import generate_csrf_token + from .cards import _like_button_sx + + asset_url_fn = ctx.get("asset_url") + user = ctx.get("user") + liked_by_current_user = ctx.get("liked_by_current_user", False) + csrf = generate_csrf_token() + + images = d.get("images", []) + labels = d.get("labels", []) + stickers = d.get("stickers", []) + brand = d.get("brand", "") + slug = d.get("slug", "") + + # Gallery + if images: + # Like button + like_sx = "" + if user: + like_sx = await _like_button_sx(slug, liked_by_current_user, csrf, ctx) + + # Main image + labels + label_parts: list[str] = [] + if callable(asset_url_fn): + for l in labels: + label_parts.append(await render_to_sx( + "market-label-overlay", + src=asset_url_fn("labels/" + l + ".svg"), + )) + labels_sx = "(<> " + " ".join(label_parts) + ")" if label_parts else None + + gallery_inner = await render_to_sx( + "market-detail-gallery-inner", + like=SxExpr(like_sx) if like_sx else None, + image=images[0], alt=d.get("title", ""), + labels=SxExpr(labels_sx) if labels_sx else None, + brand=brand, + ) + + # Prev/next buttons + nav_buttons = "" + if len(images) > 1: + nav_buttons = await render_to_sx("market-detail-nav-buttons") + + gallery_sx = await render_to_sx( + "market-detail-gallery", + inner=SxExpr(gallery_inner), + nav=SxExpr(nav_buttons) if nav_buttons else None, + ) + + # Thumbnails + gallery_parts = [gallery_sx] + if len(images) > 1: + thumb_parts = [] + for i, u in enumerate(images): + thumb_parts.append(await render_to_sx( + "market-detail-thumb", + title=f"Image {i+1}", src=u, alt=f"thumb {i+1}", + )) + thumbs_sx = "(<> " + " ".join(thumb_parts) + ")" + gallery_parts.append(await render_to_sx("market-detail-thumbs", thumbs=SxExpr(thumbs_sx))) + gallery_final = "(<> " + " ".join(gallery_parts) + ")" + else: + like_sx = "" + if user: + like_sx = await _like_button_sx(slug, liked_by_current_user, csrf, ctx) + gallery_final = await render_to_sx("market-detail-no-image", + like=SxExpr(like_sx) if like_sx else None) + + # Stickers below gallery + stickers_sx = "" + if stickers and callable(asset_url_fn): + sticker_parts = [] + for s in stickers: + sticker_parts.append(await render_to_sx( + "market-detail-sticker", + src=asset_url_fn("stickers/" + s + ".svg"), name=s, + )) + sticker_items_sx = "(<> " + " ".join(sticker_parts) + ")" + stickers_sx = await render_to_sx("market-detail-stickers", items=SxExpr(sticker_items_sx)) + + # Right column: prices, description, sections + pr = _set_prices(d) + detail_parts: list[str] = [] + + # Unit price / case size extras + extra_parts: list[str] = [] + ppu = d.get("price_per_unit") or d.get("price_per_unit_raw") + if ppu: + extra_parts.append(await render_to_sx( + "market-detail-unit-price", + price=_price_str(d.get("price_per_unit"), d.get("price_per_unit_raw"), d.get("price_per_unit_currency")), + )) + if d.get("case_size_raw"): + extra_parts.append(await render_to_sx("market-detail-case-size", size=d["case_size_raw"])) + if extra_parts: + extras_sx = "(<> " + " ".join(extra_parts) + ")" + detail_parts.append(await render_to_sx("market-detail-extras", inner=SxExpr(extras_sx))) + + # Description + desc_short = d.get("description_short") + desc_html_val = d.get("description_html") + if desc_short or desc_html_val: + desc_parts: list[str] = [] + if desc_short: + desc_parts.append(await render_to_sx("market-detail-desc-short", text=desc_short)) + if desc_html_val: + desc_parts.append(await render_to_sx("market-detail-desc-html", html=desc_html_val)) + desc_inner = "(<> " + " ".join(desc_parts) + ")" + detail_parts.append(await render_to_sx("market-detail-desc-wrapper", inner=SxExpr(desc_inner))) + + # Sections (expandable) + sections = d.get("sections", []) + if sections: + sec_parts = [] + for sec in sections: + sec_parts.append(await render_to_sx( + "market-detail-section", + title=sec.get("title", ""), html=sec.get("html", ""), + )) + sec_items_sx = "(<> " + " ".join(sec_parts) + ")" + detail_parts.append(await render_to_sx("market-detail-sections", items=SxExpr(sec_items_sx))) + + details_inner_sx = "(<> " + " ".join(detail_parts) + ")" if detail_parts else "(<>)" + details_sx = await render_to_sx("market-detail-right-col", inner=SxExpr(details_inner_sx)) + + return await render_to_sx( + "market-detail-layout", + gallery=SxExpr(gallery_final), + stickers=SxExpr(stickers_sx) if stickers_sx else None, + details=SxExpr(details_sx), + ) + + +# --------------------------------------------------------------------------- +# Product meta (OpenGraph, JSON-LD) +# --------------------------------------------------------------------------- + +async def _product_meta_sx(d: dict, ctx: dict) -> str: + """Build product meta tags as sx (auto-hoisted to by sx.js).""" + import json + from quart import request + + title = d.get("title", "") + desc_source = d.get("description_short") or "" + if not desc_source and d.get("description_html"): + import re + desc_source = re.sub(r"<[^>]+>", "", d.get("description_html", "")) + description = desc_source.strip().replace("\n", " ")[:160] + image_url = d.get("image") or (d.get("images", [None])[0] if d.get("images") else None) + canonical = request.url if request else "" + brand = d.get("brand", "") + sku = d.get("sku", "") + price = d.get("special_price") or d.get("regular_price") or d.get("rrp") + price_currency = d.get("special_price_currency") or d.get("regular_price_currency") or d.get("rrp_currency") + + parts = [await render_to_sx("market-meta-title", title=title)] + parts.append(await render_to_sx("market-meta-description", description=description)) + if canonical: + parts.append(await render_to_sx("market-meta-canonical", href=canonical)) + + # OpenGraph + site_title = ctx.get("base_title", "") + parts.append(await render_to_sx("market-meta-og", property="og:site_name", content=site_title)) + parts.append(await render_to_sx("market-meta-og", property="og:type", content="product")) + parts.append(await render_to_sx("market-meta-og", property="og:title", content=title)) + parts.append(await render_to_sx("market-meta-og", property="og:description", content=description)) + if canonical: + parts.append(await render_to_sx("market-meta-og", property="og:url", content=canonical)) + if image_url: + parts.append(await render_to_sx("market-meta-og", property="og:image", content=image_url)) + if price and price_currency: + parts.append(await render_to_sx("market-meta-og", property="product:price:amount", content=f"{price:.2f}")) + parts.append(await render_to_sx("market-meta-og", property="product:price:currency", content=price_currency)) + if brand: + parts.append(await render_to_sx("market-meta-og", property="product:brand", content=brand)) + + # Twitter + card_type = "summary_large_image" if image_url else "summary" + parts.append(await render_to_sx("market-meta-twitter", name="twitter:card", content=card_type)) + parts.append(await render_to_sx("market-meta-twitter", name="twitter:title", content=title)) + parts.append(await render_to_sx("market-meta-twitter", name="twitter:description", content=description)) + if image_url: + parts.append(await render_to_sx("market-meta-twitter", name="twitter:image", content=image_url)) + + # JSON-LD + jsonld = { + "@context": "https://schema.org", + "@type": "Product", + "name": title, + "image": image_url, + "description": description, + "sku": sku, + "url": canonical, + } + if brand: + jsonld["brand"] = {"@type": "Brand", "name": brand} + if price and price_currency: + jsonld["offers"] = { + "@type": "Offer", + "price": price, + "priceCurrency": price_currency, + "url": canonical, + "availability": "https://schema.org/InStock", + } + parts.append(await render_to_sx("market-meta-jsonld", json=json.dumps(jsonld))) + + return "(<> " + " ".join(parts) + ")"