diff --git a/market/app.py b/market/app.py
index 05c6e6b..a0c7868 100644
--- a/market/app.py
+++ b/market/app.py
@@ -1,6 +1,6 @@
from __future__ import annotations
import path_setup # noqa: F401 # adds shared/ to sys.path
-import sx.sx_components as sx_components # noqa: F401 # ensure Hypercorn --reload watches this file
+
from pathlib import Path
diff --git a/market/bp/all_markets/routes.py b/market/bp/all_markets/routes.py
index f6bc8c3..784da82 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 sx.sx_components import render_all_markets_cards
+ from sxc.pages 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 f6fe2bf..ab531b8 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 sx.sx_components import render_browse_page, render_browse_oob, render_browse_cards
+ from sxc.pages 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 sx.sx_components import render_browse_page, render_browse_oob, render_browse_cards
+ from sxc.pages 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 sx.sx_components import render_browse_page, render_browse_oob, render_browse_cards
+ from sxc.pages 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 93873ad..420676a 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 sx.sx_components import render_markets_admin_list_panel
+ from sxc.pages 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 sx.sx_components import render_markets_admin_list_panel
+ from sxc.pages 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 10d1f2f..40628c4 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 sx.sx_components import render_page_markets_cards
+ from sxc.pages 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 959e8b8..046192f 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 sx.sx_components import render_product_page, render_product_oob
+ from sxc.pages 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 sx.sx_components import render_like_toggle_button
+ from sxc.pages 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 sx.sx_components import render_product_admin_page, render_product_admin_oob
+ from sxc.pages 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 sx.sx_components import render_cart_added_response
+ from sxc.pages 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/sx/sx_components.py b/market/sx/sx_components.py
deleted file mode 100644
index 931045b..0000000
--- a/market/sx/sx_components.py
+++ /dev/null
@@ -1,1546 +0,0 @@
-"""
-Market service s-expression page components.
-
-Renders market landing, browse (category/subcategory), product detail,
-product admin, market admin, page markets, and all markets pages.
-Called from route handlers in place of ``render_template()``.
-"""
-from __future__ import annotations
-
-import os
-from typing import Any
-from shared.sx.jinja_bridge import load_service_components
-from shared.sx.parser import serialize, SxExpr
-from shared.sx.helpers import (
- call_url, get_asset_url, render_to_sx,
- root_header_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,
-)
-
-# Load market-specific .sx components + handlers at import time
-load_service_components(os.path.dirname(os.path.dirname(__file__)),
- service_name="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("market.admin.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) + ")"
-
-
-# ---------------------------------------------------------------------------
-# OOB header helpers -- _oob_header_sx imported from shared
-# ---------------------------------------------------------------------------
-
-
-# ===========================================================================
-# PUBLIC API
-# ===========================================================================
-
-
-# ---------------------------------------------------------------------------
-# All markets
-# ---------------------------------------------------------------------------
-
-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")
-
-
-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)
-
-
-# ---------------------------------------------------------------------------
-# Page markets
-# ---------------------------------------------------------------------------
-
-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)
-
-
-# ---------------------------------------------------------------------------
-# 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)
-
- hdr = await root_header_sx(ctx)
- child = "(<> " + await _post_header_sx(ctx) + " " + await _market_header_sx(ctx) + ")"
- hdr = "(<> " + hdr + " " + await header_child_sx(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 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)
-
- oobs = await _oob_header_sx("post-header-child", "market-header-child",
- await _market_header_sx(ctx))
- post_hdr = await _post_header_sx(ctx, oob=True)
- oobs = "(<> " + oobs + " " + post_hdr + " "
- oobs += _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)
-
- hdr = await root_header_sx(ctx)
- post_hdr = await _post_header_sx(ctx)
- child = "(<> " + post_hdr + " " + await _market_header_sx(ctx) + " " + await _product_header_sx(ctx, d) + ")"
- hdr_child = await header_child_sx(child)
- hdr = "(<> " + hdr + " " + hdr_child + ")"
- 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 _market_header_sx(ctx, oob=True) + " "
- oob_hdr = await _oob_header_sx("market-header-child", "product-header-child",
- await _product_header_sx(ctx, d))
- oobs += oob_hdr + " "
- oobs += _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)
-
- hdr = await root_header_sx(ctx)
- post_hdr = await _post_header_sx(ctx)
- child = "(<> " + post_hdr + " " + await _market_header_sx(ctx)
- child += " " + await _product_header_sx(ctx, d) + " " + await _product_admin_header_sx(ctx, d) + ")"
- hdr_child = await header_child_sx(child)
- hdr = "(<> " + hdr + " " + hdr_child + ")"
- 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 _product_header_sx(ctx, d, oob=True) + " "
- oob_hdr = await _oob_header_sx("product-header-child", "product-admin-header-child",
- await _product_admin_header_sx(ctx, d))
- oobs += oob_hdr + " "
- oobs += _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_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.
-
- Used by both market and blog like_toggle handlers.
- """
- 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/__init__.py b/market/sxc/pages/__init__.py
index bf7b745..90202b9 100644
--- a/market/sxc/pages/__init__.py
+++ b/market/sxc/pages/__init__.py
@@ -3,6 +3,18 @@ from __future__ import annotations
from typing import Any
+from shared.sx.parser import serialize, SxExpr
+from shared.sx.helpers import (
+ render_to_sx,
+ root_header_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."""
@@ -14,13 +26,1509 @@ def setup_market_pages() -> None:
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
+ base = os.path.dirname(os.path.dirname(__file__))
+ load_service_components(base, service_name="market")
load_page_dir(os.path.dirname(__file__), "market")
# ---------------------------------------------------------------------------
-# Layouts
+# 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("market.admin.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)
+
+ hdr = await root_header_sx(ctx)
+ child = "(<> " + await _post_header_sx(ctx) + " " + await _market_header_sx(ctx) + ")"
+ hdr = "(<> " + hdr + " " + await header_child_sx(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 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)
+
+ oobs = await _oob_header_sx("post-header-child", "market-header-child",
+ await _market_header_sx(ctx))
+ post_hdr = await _post_header_sx(ctx, oob=True)
+ oobs = "(<> " + oobs + " " + post_hdr + " "
+ oobs += _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)
+
+ hdr = await root_header_sx(ctx)
+ post_hdr = await _post_header_sx(ctx)
+ child = "(<> " + post_hdr + " " + await _market_header_sx(ctx) + " " + await _product_header_sx(ctx, d) + ")"
+ hdr_child = await header_child_sx(child)
+ hdr = "(<> " + hdr + " " + hdr_child + ")"
+ 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 _market_header_sx(ctx, oob=True) + " "
+ oob_hdr = await _oob_header_sx("market-header-child", "product-header-child",
+ await _product_header_sx(ctx, d))
+ oobs += oob_hdr + " "
+ oobs += _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)
+
+ hdr = await root_header_sx(ctx)
+ post_hdr = await _post_header_sx(ctx)
+ child = "(<> " + post_hdr + " " + await _market_header_sx(ctx)
+ child += " " + await _product_header_sx(ctx, d) + " " + await _product_admin_header_sx(ctx, d) + ")"
+ hdr_child = await header_child_sx(child)
+ hdr = "(<> " + hdr + " " + hdr_child + ")"
+ 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 _product_header_sx(ctx, d, oob=True) + " "
+ oob_hdr = await _oob_header_sx("product-header-child", "product-admin-header-child",
+ await _product_admin_header_sx(ctx, d))
+ oobs += oob_hdr + " "
+ oobs += _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)
@@ -28,19 +1536,13 @@ def _register_market_layouts() -> None:
async def _market_full(ctx: dict, **kw: Any) -> str:
- from shared.sx.helpers import root_header_sx, header_child_sx
- from sx.sx_components import _post_header_sx, _market_header_sx
-
- root_hdr = await root_header_sx(ctx)
+ hdr = await root_header_sx(ctx)
child = "(<> " + await _post_header_sx(ctx) + " " + await _market_header_sx(ctx) + ")"
- return "(<> " + root_hdr + " " + await header_child_sx(child) + ")"
+ return "(<> " + hdr + " " + await header_child_sx(child) + ")"
async def _market_oob(ctx: dict, **kw: Any) -> str:
- from shared.sx.helpers import oob_header_sx
- from sx.sx_components import _post_header_sx, _market_header_sx, _clear_deeper_oob
-
- oobs = await oob_header_sx("post-header-child", "market-header-child",
+ oobs = await _oob_header_sx("post-header-child", "market-header-child",
await _market_header_sx(ctx))
oobs = "(<> " + oobs + " " + await _post_header_sx(ctx, oob=True) + " "
oobs += _clear_deeper_oob("post-row", "post-header-child",
@@ -49,32 +1551,21 @@ async def _market_oob(ctx: dict, **kw: Any) -> str:
async def _market_mobile(ctx: dict, **kw: Any) -> str:
- from sx.sx_components import _mobile_nav_panel_sx
return await _mobile_nav_panel_sx(ctx)
async def _market_admin_full(ctx: dict, **kw: Any) -> str:
- from shared.sx.helpers import root_header_sx, header_child_sx
- from sx.sx_components import (
- _post_header_sx, _market_header_sx, _market_admin_header_sx,
- )
-
selected = kw.get("selected", "")
- root_hdr = await root_header_sx(ctx)
+ hdr = await root_header_sx(ctx)
child = "(<> " + await _post_header_sx(ctx) + " " + await _market_header_sx(ctx) + " "
child += await _market_admin_header_sx(ctx, selected=selected) + ")"
- return "(<> " + root_hdr + " " + await header_child_sx(child) + ")"
+ return "(<> " + hdr + " " + await header_child_sx(child) + ")"
async def _market_admin_oob(ctx: dict, **kw: Any) -> str:
- from shared.sx.helpers import oob_header_sx
- from sx.sx_components import (
- _market_header_sx, _market_admin_header_sx, _clear_deeper_oob,
- )
-
selected = kw.get("selected", "")
oobs = "(<> " + await _market_header_sx(ctx, oob=True) + " "
- oobs += await oob_header_sx("market-header-child", "market-admin-header-child",
+ oobs += await _oob_header_sx("market-header-child", "market-admin-header-child",
await _market_admin_header_sx(ctx, selected=selected)) + " "
oobs += _clear_deeper_oob("post-row", "post-header-child",
"market-row", "market-header-child",
@@ -82,9 +1573,9 @@ async def _market_admin_oob(ctx: dict, **kw: Any) -> str:
return oobs
-# ---------------------------------------------------------------------------
+# ===========================================================================
# Page helpers
-# ---------------------------------------------------------------------------
+# ===========================================================================
def _register_market_helpers() -> None:
from shared.sx.pages import register_page_helpers
@@ -122,13 +1613,11 @@ async def _h_all_markets_content(**kw):
page_info[p.id] = {"title": p.title, "slug": p.slug}
if not markets:
- from sx.sx_components import _no_markets_sx
return await _no_markets_sx()
prefix = route_prefix()
next_url = prefix + url_for("all_markets.markets_fragment", page=page + 1)
- from sx.sx_components import _market_cards_sx, _markets_grid
cards = await _market_cards_sx(markets, page_info, page, has_more, next_url)
content = await _markets_grid(cards)
return "(<> " + content + " " + '(div :class "pb-8")' + ")"
@@ -147,13 +1636,11 @@ async def _h_page_markets_content(slug=None, **kw):
post_slug = post.get("slug", "")
if not markets:
- from sx.sx_components import _no_markets_sx
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)
- from sx.sx_components import _market_cards_sx, _markets_grid
cards = await _market_cards_sx(markets, {}, page, has_more, next_url,
show_page_badge=False, post_slug=post_slug)
content = await _markets_grid(cards)
@@ -162,7 +1649,6 @@ async def _h_page_markets_content(slug=None, **kw):
async def _h_page_admin_content(slug=None, **kw):
from shared.sx.page import get_template_context
- from sx.sx_components import _markets_admin_panel_sx
ctx = await get_template_context()
content = await _markets_admin_panel_sx(ctx)
return '(div :id "main-panel" ' + content + ')'
@@ -172,7 +1658,6 @@ 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", {})
- from sx.sx_components import _market_landing_content_sx
return await _market_landing_content_sx(post)