""" 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.sexp.jinja_bridge import load_service_components from shared.sexp.helpers import ( call_url, get_asset_url, sexp_call, SexpExpr, root_header_sexp, post_header_sexp as _post_header_sexp, post_admin_header_sexp, oob_header_sexp as _oob_header_sexp, header_child_sexp, search_mobile_sexp, search_desktop_sexp, full_page_sexp, oob_page_sexp, ) # Load market-specific .sexpr components at import time load_service_components(os.path.dirname(os.path.dirname(__file__))) # --------------------------------------------------------------------------- # 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": "£", "EUR": "€", "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) def _card_price_sexp(p: dict) -> str: """Build price line for product card as sexp 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(sexp_call("market-price-special", price=sp_str)) if pr["rp_val"]: parts.append(sexp_call("market-price-regular-strike", price=rp_str)) elif pr["rp_val"]: parts.append(sexp_call("market-price-regular", price=rp_str)) inner = "(<> " + " ".join(parts) + ")" if parts else None return sexp_call("market-price-line", inner=SexpExpr(inner) if inner else None) # --------------------------------------------------------------------------- # Header helpers — _post_header_sexp and _oob_header_sexp imported from shared # --------------------------------------------------------------------------- def _market_header_sexp(ctx: dict, *, oob: bool = False) -> str: """Build the market-level header row as sexp 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 = sexp_call("market-sub-slug", sub=sub_slug) if sub_slug else "" label_sexp = sexp_call( "market-shop-label", title=market_title, top_slug=top_slug or "", sub_div=SexpExpr(sub_div) if sub_div else None, ) link_href = url_for("market.browse.home") # Build desktop nav from categories categories = ctx.get("categories", {}) qs = ctx.get("qs", "") nav_sexp = _desktop_category_nav_sexp(ctx, categories, qs, hx_select_search) return sexp_call( "menu-row-sx", id="market-row", level=2, link_href=link_href, link_label_content=SexpExpr(label_sexp), nav=SexpExpr(nav_sexp) if nav_sexp else None, child_id="market-header-child", oob=oob, ) def _desktop_category_nav_sexp(ctx: dict, categories: dict, qs: str, hx_select: str) -> str: """Build desktop category navigation links as sexp.""" 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 = [sexp_call( "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(sexp_call( "market-category-link", href=cat_href, hx_select=hx_select, active=cat_active, select_colours=select_colours, label=cat, )) links_sexp = "(<> " + " ".join(link_parts) + ")" admin_sexp = "" if rights and rights.get("admin"): admin_href = prefix + url_for("market.admin.admin") admin_sexp = sexp_call("market-admin-link", href=admin_href, hx_select=hx_select) return sexp_call("market-desktop-category-nav", links=SexpExpr(links_sexp), admin=SexpExpr(admin_sexp) if admin_sexp else None) def _product_header_sexp(ctx: dict, d: dict, *, oob: bool = False) -> str: """Build the product-level header row as sexp 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_sexp = sexp_call("market-product-label", title=title) # Prices in nav area pr = _set_prices(d) cart = ctx.get("cart", []) prices_nav = _prices_header_sexp(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(sexp_call("market-admin-link", href=admin_href, hx_select=hx_select_search)) nav_sexp = "(<> " + " ".join(nav_parts) + ")" return sexp_call( "menu-row-sx", id="product-row", level=3, link_href=link_href, link_label_content=SexpExpr(label_sexp), nav=SexpExpr(nav_sexp), child_id="product-header-child", oob=oob, ) def _prices_header_sexp(d: dict, pr: dict, cart: list, slug: str, ctx: dict) -> str: """Build prices + add-to-cart for product header row as sexp.""" 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_sexp = _cart_add_sexp(slug, quantity, cart_action, csrf, cart_url_fn) parts = [add_sexp] sp_val, rp_val = pr.get("sp_val"), pr.get("rp_val") if sp_val: parts.append(sexp_call("market-header-price-special-label")) parts.append(sexp_call("market-header-price-special", price=_price_str(sp_val, pr["sp_raw"], pr["sp_cur"]))) if rp_val: parts.append(sexp_call("market-header-price-strike", price=_price_str(rp_val, pr["rp_raw"], pr["rp_cur"]))) elif rp_val: parts.append(sexp_call("market-header-price-regular-label")) parts.append(sexp_call("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(sexp_call("market-header-rrp", rrp=rrp_str)) inner_sexp = "(<> " + " ".join(parts) + ")" return sexp_call("market-prices-row", inner=SexpExpr(inner_sexp)) def _cart_add_sexp(slug: str, quantity: int, action: str, csrf: str, cart_url_fn: Any = None) -> str: """Build add-to-cart button or quantity controls as sexp.""" if not quantity: return sexp_call( "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 sexp_call( "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 # --------------------------------------------------------------------------- def _mobile_nav_panel_sexp(ctx: dict) -> str: """Build mobile nav panel with category accordion as sexp.""" 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 = [sexp_call( "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_sexp = sexp_call("market-mobile-chevron") cat_count = data.get("count", 0) summary_sexp = sexp_call( "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=SexpExpr(chevron_sexp), ) subs = data.get("subs", []) subs_sexp = "" 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(sexp_call( "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_sexp = "(<> " + " ".join(sub_link_parts) + ")" subs_sexp = sexp_call("market-mobile-subs-panel", links=SexpExpr(sub_links_sexp)) else: view_href = prefix + url_for("market.browse.browse_top", top_slug=cat_slug) + qs subs_sexp = sexp_call("market-mobile-view-all", href=view_href, hx_select=hx_select) item_parts.append(sexp_call( "market-mobile-cat-details", open=cat_active or None, summary=SexpExpr(summary_sexp), subs=SexpExpr(subs_sexp), )) items_sexp = "(<> " + " ".join(item_parts) + ")" return sexp_call("market-mobile-nav-wrapper", items=SexpExpr(items_sexp)) # --------------------------------------------------------------------------- # Product card (browse grid item) # --------------------------------------------------------------------------- def _product_card_sexp(p: dict, ctx: dict) -> str: """Build a single product card for browse grid as sexp 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 sexp_call("market-product-card", **kwargs) def _product_cards_sexp(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 = [_product_card_sexp(p, ctx) for p in products] 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(sexp_call("market-sentinel-mobile", id=f"sentinel-{page}-m", next_url=next_url, hyperscript=_MOBILE_SENTINEL_HS)) parts.append(sexp_call("market-sentinel-desktop", id=f"sentinel-{page}-d", next_url=next_url, hyperscript=_DESKTOP_SENTINEL_HS)) else: parts.append(sexp_call("market-sentinel-end")) return "(<> " + " ".join(parts) + ")" def _like_button_sexp(slug: str, liked: bool, csrf: str, ctx: dict) -> str: """Build the like/unlike heart button overlay as sexp.""" 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 sexp_call( "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) # --------------------------------------------------------------------------- def _desktop_filter_sexp(ctx: dict) -> str: """Build the desktop aside filter panel as sexp.""" 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_sexp = search_desktop_sexp(ctx) # Category summary + sort + like + labels + stickers cat_parts = [sexp_call("market-filter-category-label", label=category_label)] if sort_options: cat_parts.append(_sort_stickers_sexp(sort_options, sort, ctx)) like_label_parts = [_like_filter_sexp(liked, liked_count, ctx)] if labels: like_label_parts.append(_labels_filter_sexp(labels, selected_labels, ctx, prefix="nav-labels")) like_labels_sexp = "(<> " + " ".join(like_label_parts) + ")" cat_parts.append(sexp_call("market-filter-like-labels-nav", inner=SexpExpr(like_labels_sexp))) if stickers: cat_parts.append(_stickers_filter_sexp(stickers, selected_stickers, ctx)) if subs_local and top_local_href: cat_parts.append(_subcategory_selector_sexp(subs_local, top_local_href, sub_slug, ctx)) cat_inner_sexp = "(<> " + " ".join(cat_parts) + ")" cat_summary = sexp_call("market-desktop-category-summary", inner=SexpExpr(cat_inner_sexp)) # Brand filter brand_inner = "" if brands: brand_inner = _brand_filter_sexp(brands, selected_brands, ctx) brand_summary = sexp_call("market-desktop-brand-summary", inner=SexpExpr(brand_inner) if brand_inner else None) return "(<> " + " ".join([search_sexp, cat_summary, brand_summary]) + ")" def _mobile_filter_summary_sexp(ctx: dict) -> str: """Build mobile filter summary as sexp.""" 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 = search_mobile_sexp(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(sexp_call("market-mobile-chip-sort", src=asset_url_fn(i), label=l)) if liked: liked_parts = [sexp_call("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(sexp_call("market-mobile-chip-count", cls=cls, count=str(liked_count))) liked_inner = "(<> " + " ".join(liked_parts) + ")" chip_parts.append(sexp_call("market-mobile-chip-liked", inner=SexpExpr(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 = [sexp_call( "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(sexp_call("market-mobile-chip-count", cls=cls, count=str(lb["count"]))) li_inner = "(<> " + " ".join(li_parts) + ")" label_item_parts.append(sexp_call("market-mobile-chip-item", inner=SexpExpr(li_inner))) if label_item_parts: label_items = "(<> " + " ".join(label_item_parts) + ")" chip_parts.append(sexp_call("market-mobile-chip-list", items=SexpExpr(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 = [sexp_call( "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(sexp_call("market-mobile-chip-count", cls=cls, count=str(st["count"]))) si_inner = "(<> " + " ".join(si_parts) + ")" sticker_item_parts.append(sexp_call("market-mobile-chip-item", inner=SexpExpr(si_inner))) if sticker_item_parts: sticker_items = "(<> " + " ".join(sticker_item_parts) + ")" chip_parts.append(sexp_call("market-mobile-chip-list", items=SexpExpr(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(sexp_call("market-mobile-chip-brand", name=b, count=str(count))) else: brand_item_parts.append(sexp_call("market-mobile-chip-brand-zero", name=b)) brand_items = "(<> " + " ".join(brand_item_parts) + ")" chip_parts.append(sexp_call("market-mobile-chip-brand-list", items=SexpExpr(brand_items))) chips_sexp = "(<> " + " ".join(chip_parts) + ")" if chip_parts else '(<>)' chips_row = sexp_call("market-mobile-chips-row", inner=SexpExpr(chips_sexp)) # Full mobile filter details from shared.utils import route_prefix prefix = route_prefix() mobile_filter = _mobile_filter_content_sexp(ctx, prefix) return sexp_call( "market-mobile-filter-summary", search_bar=SexpExpr(search_bar), chips=SexpExpr(chips_row), filter=SexpExpr(mobile_filter), ) def _mobile_filter_content_sexp(ctx: dict, prefix: str) -> str: """Build the expanded mobile filter panel contents as sexp.""" 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(_sort_stickers_sexp(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(sexp_call("market-mobile-clear-filters", href=clear_url, hx_select=hx_select)) # Like + labels row like_label_parts = [_like_filter_sexp(liked, liked_count, ctx, mobile=True)] if labels: like_label_parts.append(_labels_filter_sexp(labels, selected_labels, ctx, prefix="nav-labels", mobile=True)) like_labels_sexp = "(<> " + " ".join(like_label_parts) + ")" parts.append(sexp_call("market-mobile-like-labels-row", inner=SexpExpr(like_labels_sexp))) # Stickers if stickers: parts.append(_stickers_filter_sexp(stickers, selected_stickers, ctx, mobile=True)) # Brands if brands: parts.append(_brand_filter_sexp(brands, selected_brands, ctx, mobile=True)) return "(<> " + " ".join(parts) + ")" if parts else "(<>)" def _sort_stickers_sexp(sort_options: list, current_sort: str, ctx: dict, mobile: bool = False) -> str: """Build sort option stickers as sexp.""" 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(sexp_call( "market-filter-sort-item", href=href, hx_select=hx_select, ring_cls=ring, src=src, label=label, )) items_sexp = "(<> " + " ".join(item_parts) + ")" if item_parts else "(<>)" return sexp_call("market-filter-sort-row", items=SexpExpr(items_sexp)) def _like_filter_sexp(liked: bool, liked_count: int, ctx: dict, mobile: bool = False) -> str: """Build the like filter toggle as sexp.""" 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 sexp_call( "market-filter-like", href=href, hx_select=hx_select, icon_cls=icon_cls, size_cls=size, ) def _labels_filter_sexp(labels: list, selected: list, ctx: dict, *, prefix: str = "nav-labels", mobile: bool = False) -> str: """Build label filter buttons as sexp.""" 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(sexp_call( "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 "(<>)" def _stickers_filter_sexp(stickers: list, selected: list, ctx: dict, mobile: bool = False) -> str: """Build sticker filter grid as sexp.""" 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(sexp_call( "market-filter-sticker-item", href=href, hx_select=hx_select, ring_cls=ring, src=src, name=name, count_cls=cls, count=str(count), )) items_sexp = "(<> " + " ".join(item_parts) + ")" if item_parts else "(<>)" return sexp_call("market-filter-stickers-row", items=SexpExpr(items_sexp)) def _brand_filter_sexp(brands: list, selected: list, ctx: dict, mobile: bool = False) -> str: """Build brand filter checkboxes as sexp.""" 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(sexp_call( "market-filter-brand-item", href=href, hx_select=hx_select, bg_cls=bg, name_cls=cls, name=name, count=str(count), )) items_sexp = "(<> " + " ".join(item_parts) + ")" if item_parts else "(<>)" return sexp_call("market-filter-brands-panel", items=SexpExpr(items_sexp)) def _subcategory_selector_sexp(subs: list, top_href: str, current_sub: str, ctx: dict) -> str: """Build subcategory vertical nav as sexp.""" 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 = [sexp_call( "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(sexp_call( "market-filter-subcategory-item", href=full_href, hx_select=hx_select, active_cls=active_cls, name=name, )) items_sexp = "(<> " + " ".join(item_parts) + ")" return sexp_call("market-filter-subcategory-panel", items=SexpExpr(items_sexp)) # --------------------------------------------------------------------------- # Product detail page content # --------------------------------------------------------------------------- def _product_detail_sexp(d: dict, ctx: dict) -> str: """Build product detail main panel content as sexp.""" 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_sexp = "" if user: like_sexp = _like_button_sexp(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(sexp_call( "market-label-overlay", src=asset_url_fn("labels/" + l + ".svg"), )) labels_sexp = "(<> " + " ".join(label_parts) + ")" if label_parts else None gallery_inner = sexp_call( "market-detail-gallery-inner", like=SexpExpr(like_sexp) if like_sexp else None, image=images[0], alt=d.get("title", ""), labels=SexpExpr(labels_sexp) if labels_sexp else None, brand=brand, ) # Prev/next buttons nav_buttons = "" if len(images) > 1: nav_buttons = sexp_call("market-detail-nav-buttons") gallery_sexp = sexp_call( "market-detail-gallery", inner=SexpExpr(gallery_inner), nav=SexpExpr(nav_buttons) if nav_buttons else None, ) # Thumbnails gallery_parts = [gallery_sexp] if len(images) > 1: thumb_parts = [] for i, u in enumerate(images): thumb_parts.append(sexp_call( "market-detail-thumb", title=f"Image {i+1}", src=u, alt=f"thumb {i+1}", )) thumbs_sexp = "(<> " + " ".join(thumb_parts) + ")" gallery_parts.append(sexp_call("market-detail-thumbs", thumbs=SexpExpr(thumbs_sexp))) gallery_final = "(<> " + " ".join(gallery_parts) + ")" else: like_sexp = "" if user: like_sexp = _like_button_sexp(slug, liked_by_current_user, csrf, ctx) gallery_final = sexp_call("market-detail-no-image", like=SexpExpr(like_sexp) if like_sexp else None) # Stickers below gallery stickers_sexp = "" if stickers and callable(asset_url_fn): sticker_parts = [] for s in stickers: sticker_parts.append(sexp_call( "market-detail-sticker", src=asset_url_fn("stickers/" + s + ".svg"), name=s, )) sticker_items_sexp = "(<> " + " ".join(sticker_parts) + ")" stickers_sexp = sexp_call("market-detail-stickers", items=SexpExpr(sticker_items_sexp)) # 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(sexp_call( "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(sexp_call("market-detail-case-size", size=d["case_size_raw"])) if extra_parts: extras_sexp = "(<> " + " ".join(extra_parts) + ")" detail_parts.append(sexp_call("market-detail-extras", inner=SexpExpr(extras_sexp))) # 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(sexp_call("market-detail-desc-short", text=desc_short)) if desc_html_val: desc_parts.append(sexp_call("market-detail-desc-html", html=desc_html_val)) desc_inner = "(<> " + " ".join(desc_parts) + ")" detail_parts.append(sexp_call("market-detail-desc-wrapper", inner=SexpExpr(desc_inner))) # Sections (expandable) sections = d.get("sections", []) if sections: sec_parts = [] for sec in sections: sec_parts.append(sexp_call( "market-detail-section", title=sec.get("title", ""), html=sec.get("html", ""), )) sec_items_sexp = "(<> " + " ".join(sec_parts) + ")" detail_parts.append(sexp_call("market-detail-sections", items=SexpExpr(sec_items_sexp))) details_inner_sexp = "(<> " + " ".join(detail_parts) + ")" if detail_parts else "(<>)" details_sexp = sexp_call("market-detail-right-col", inner=SexpExpr(details_inner_sexp)) return sexp_call( "market-detail-layout", gallery=SexpExpr(gallery_final), stickers=SexpExpr(stickers_sexp) if stickers_sexp else None, details=SexpExpr(details_sexp), ) # --------------------------------------------------------------------------- # Product meta (OpenGraph, JSON-LD) # --------------------------------------------------------------------------- def _product_meta_sexp(d: dict, ctx: dict) -> str: """Build product meta tags as sexp (auto-hoisted to by sexp.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 = [sexp_call("market-meta-title", title=title)] parts.append(sexp_call("market-meta-description", description=description)) if canonical: parts.append(sexp_call("market-meta-canonical", href=canonical)) # OpenGraph site_title = ctx.get("base_title", "") parts.append(sexp_call("market-meta-og", property="og:site_name", content=site_title)) parts.append(sexp_call("market-meta-og", property="og:type", content="product")) parts.append(sexp_call("market-meta-og", property="og:title", content=title)) parts.append(sexp_call("market-meta-og", property="og:description", content=description)) if canonical: parts.append(sexp_call("market-meta-og", property="og:url", content=canonical)) if image_url: parts.append(sexp_call("market-meta-og", property="og:image", content=image_url)) if price and price_currency: parts.append(sexp_call("market-meta-og", property="product:price:amount", content=f"{price:.2f}")) parts.append(sexp_call("market-meta-og", property="product:price:currency", content=price_currency)) if brand: parts.append(sexp_call("market-meta-og", property="product:brand", content=brand)) # Twitter card_type = "summary_large_image" if image_url else "summary" parts.append(sexp_call("market-meta-twitter", name="twitter:card", content=card_type)) parts.append(sexp_call("market-meta-twitter", name="twitter:title", content=title)) parts.append(sexp_call("market-meta-twitter", name="twitter:description", content=description)) if image_url: parts.append(sexp_call("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(sexp_call("market-meta-jsonld", json=json.dumps(jsonld))) return "(<> " + " ".join(parts) + ")" # --------------------------------------------------------------------------- # Market cards (all markets / page markets) # --------------------------------------------------------------------------- def _market_card_sexp(market: Any, page_info: dict, *, show_page_badge: bool = True, post_slug: str = "") -> str: """Build a single market card as sexp.""" 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_sexp = "" if market_href: title_sexp = sexp_call("market-market-card-title-link", href=market_href, name=name) else: title_sexp = sexp_call("market-market-card-title", name=name) desc_sexp = "" if description: desc_sexp = sexp_call("market-market-card-desc", description=description) badge_sexp = "" if show_page_badge and p_title: badge_href = market_url(f"/{p_slug}/") badge_sexp = sexp_call("market-market-card-badge", href=badge_href, title=p_title) return sexp_call( "market-market-card", title_content=SexpExpr(title_sexp) if title_sexp else None, desc_content=SexpExpr(desc_sexp) if desc_sexp else None, badge_content=SexpExpr(badge_sexp) if badge_sexp else None, ) def _market_cards_sexp(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 sexp.""" parts = [_market_card_sexp(m, page_info, show_page_badge=show_page_badge, post_slug=post_slug) for m in markets] if has_more: parts.append(sexp_call( "market-market-sentinel", id=f"sentinel-{page}", next_url=next_url, )) return "(<> " + " ".join(parts) + ")" # --------------------------------------------------------------------------- # OOB header helpers — _oob_header_sexp imported from shared # --------------------------------------------------------------------------- # =========================================================================== # PUBLIC API # =========================================================================== # --------------------------------------------------------------------------- # All markets # --------------------------------------------------------------------------- def _markets_grid(cards_sexp: str) -> str: """Wrap market cards in a grid as sexp.""" return sexp_call("market-markets-grid", cards=SexpExpr(cards_sexp)) def _no_markets_sexp(message: str = "No markets available") -> str: """Empty state for markets as sexp.""" return sexp_call("market-no-markets", message=message) async def render_all_markets_page(ctx: dict, markets: list, has_more: bool, page_info: dict, page: int) -> str: """Full page: all markets listing.""" 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) if markets: cards = _market_cards_sexp(markets, page_info, page, has_more, next_url) content = _markets_grid(cards) else: content = _no_markets_sexp() content = "(<> " + content + " " + sexp_call("market-bottom-spacer") + ")" hdr = root_header_sexp(ctx) return full_page_sexp(ctx, header_rows=hdr, content=content) async def render_all_markets_oob(ctx: dict, markets: list, has_more: bool, page_info: dict, page: int) -> str: """OOB response: all markets listing.""" 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) if markets: cards = _market_cards_sexp(markets, page_info, page, has_more, next_url) content = _markets_grid(cards) else: content = _no_markets_sexp() content = "(<> " + content + " " + sexp_call("market-bottom-spacer") + ")" oobs = root_header_sexp(ctx, oob=True) return oob_page_sexp(oobs=oobs, content=content) 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 _market_cards_sexp(markets, page_info, page, has_more, next_url) # --------------------------------------------------------------------------- # Page markets # --------------------------------------------------------------------------- async def render_page_markets_page(ctx: dict, markets: list, has_more: bool, page: int) -> str: """Full page: page-scoped markets listing.""" from quart import url_for from shared.utils import route_prefix prefix = route_prefix() post = ctx.get("post", {}) post_slug = post.get("slug", "") next_url = prefix + url_for("page_markets.markets_fragment", page=page + 1) if markets: cards = _market_cards_sexp(markets, {}, page, has_more, next_url, show_page_badge=False, post_slug=post_slug) content = _markets_grid(cards) else: content = _no_markets_sexp("No markets for this page") content = "(<> " + content + " " + sexp_call("market-bottom-spacer") + ")" hdr = root_header_sexp(ctx) hdr = "(<> " + hdr + " " + header_child_sexp(_post_header_sexp(ctx)) + ")" return full_page_sexp(ctx, header_rows=hdr, content=content) async def render_page_markets_oob(ctx: dict, markets: list, has_more: bool, page: int) -> str: """OOB response: page-scoped markets.""" from quart import url_for from shared.utils import route_prefix prefix = route_prefix() post = ctx.get("post", {}) post_slug = post.get("slug", "") next_url = prefix + url_for("page_markets.markets_fragment", page=page + 1) if markets: cards = _market_cards_sexp(markets, {}, page, has_more, next_url, show_page_badge=False, post_slug=post_slug) content = _markets_grid(cards) else: content = _no_markets_sexp("No markets for this page") content = "(<> " + content + " " + sexp_call("market-bottom-spacer") + ")" oobs = _oob_header_sexp("post-header-child", "market-header-child", "") oobs = "(<> " + oobs + " " + _post_header_sexp(ctx, oob=True) + ")" return oob_page_sexp(oobs=oobs, content=content) 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 _market_cards_sexp(markets, {}, page, has_more, next_url, show_page_badge=False, post_slug=post_slug) # --------------------------------------------------------------------------- # Market landing page # --------------------------------------------------------------------------- async def render_market_home_page(ctx: dict) -> str: """Full page: market landing page (post content).""" post = ctx.get("post") or {} content = _market_landing_content_sexp(post) hdr = root_header_sexp(ctx) child = "(<> " + _post_header_sexp(ctx) + " " + _market_header_sexp(ctx) + ")" hdr = "(<> " + hdr + " " + header_child_sexp(child) + ")" menu = _mobile_nav_panel_sexp(ctx) return full_page_sexp(ctx, header_rows=hdr, content=content, menu=menu) async def render_market_home_oob(ctx: dict) -> str: """OOB response: market landing page.""" post = ctx.get("post") or {} content = _market_landing_content_sexp(post) oobs = _oob_header_sexp("post-header-child", "market-header-child", _market_header_sexp(ctx)) oobs = "(<> " + oobs + " " + _post_header_sexp(ctx, oob=True) + " " oobs += _clear_deeper_oob("post-row", "post-header-child", "market-row", "market-header-child") + ")" menu = _mobile_nav_panel_sexp(ctx) return oob_page_sexp(oobs=oobs, content=content, menu=menu) def _market_landing_content_sexp(post: dict) -> str: """Build market landing page content as sexp.""" parts: list[str] = [] if post.get("custom_excerpt"): parts.append(sexp_call("market-landing-excerpt", text=post["custom_excerpt"])) if post.get("feature_image"): parts.append(sexp_call("market-landing-image", src=post["feature_image"])) if post.get("html"): parts.append(sexp_call("market-landing-html", html=post["html"])) inner = "(<> " + " ".join(parts) + ")" if parts else "(<>)" return sexp_call("market-landing-content", inner=SexpExpr(inner)) # --------------------------------------------------------------------------- # Browse page # --------------------------------------------------------------------------- def _product_grid(cards_sexp: str) -> str: """Wrap product cards in a grid as sexp.""" return sexp_call("market-product-grid", cards=SexpExpr(cards_sexp)) async def render_browse_page(ctx: dict) -> str: """Full page: product browse with filters.""" cards = _product_cards_sexp(ctx) content = _product_grid(cards) hdr = root_header_sexp(ctx) child = "(<> " + _post_header_sexp(ctx) + " " + _market_header_sexp(ctx) + ")" hdr = "(<> " + hdr + " " + header_child_sexp(child) + ")" menu = _mobile_nav_panel_sexp(ctx) filter_sexp = _mobile_filter_summary_sexp(ctx) aside_sexp = _desktop_filter_sexp(ctx) return full_page_sexp(ctx, header_rows=hdr, content=content, menu=menu, filter=filter_sexp, aside=aside_sexp) async def render_browse_oob(ctx: dict) -> str: """OOB response: product browse.""" cards = _product_cards_sexp(ctx) content = _product_grid(cards) oobs = _oob_header_sexp("post-header-child", "market-header-child", _market_header_sexp(ctx)) oobs = "(<> " + oobs + " " + _post_header_sexp(ctx, oob=True) + " " oobs += _clear_deeper_oob("post-row", "post-header-child", "market-row", "market-header-child") + ")" menu = _mobile_nav_panel_sexp(ctx) filter_sexp = _mobile_filter_summary_sexp(ctx) aside_sexp = _desktop_filter_sexp(ctx) return oob_page_sexp(oobs=oobs, content=content, menu=menu, filter=filter_sexp, aside=aside_sexp) async def render_browse_cards(ctx: dict) -> str: """Pagination fragment: product cards — sexp wire format.""" return _product_cards_sexp(ctx) # --------------------------------------------------------------------------- # Product detail # --------------------------------------------------------------------------- async def render_product_page(ctx: dict, d: dict) -> str: """Full page: product detail.""" content = _product_detail_sexp(d, ctx) meta = _product_meta_sexp(d, ctx) hdr = root_header_sexp(ctx) child = "(<> " + _post_header_sexp(ctx) + " " + _market_header_sexp(ctx) + " " + _product_header_sexp(ctx, d) + ")" hdr = "(<> " + hdr + " " + header_child_sexp(child) + ")" return full_page_sexp(ctx, header_rows=hdr, content=content, meta=meta) async def render_product_oob(ctx: dict, d: dict) -> str: """OOB response: product detail.""" content = _product_detail_sexp(d, ctx) oobs = "(<> " + _market_header_sexp(ctx, oob=True) + " " oobs += _oob_header_sexp("market-header-child", "product-header-child", _product_header_sexp(ctx, d)) + " " oobs += _clear_deeper_oob("post-row", "post-header-child", "market-row", "market-header-child", "product-row", "product-header-child") + ")" menu = _mobile_nav_panel_sexp(ctx) return oob_page_sexp(oobs=oobs, content=content, menu=menu) # --------------------------------------------------------------------------- # Product admin # --------------------------------------------------------------------------- async def render_product_admin_page(ctx: dict, d: dict) -> str: """Full page: product admin.""" content = _product_detail_sexp(d, ctx) hdr = root_header_sexp(ctx) child = "(<> " + _post_header_sexp(ctx) + " " + _market_header_sexp(ctx) child += " " + _product_header_sexp(ctx, d) + " " + _product_admin_header_sexp(ctx, d) + ")" hdr = "(<> " + hdr + " " + header_child_sexp(child) + ")" return full_page_sexp(ctx, header_rows=hdr, content=content) async def render_product_admin_oob(ctx: dict, d: dict) -> str: """OOB response: product admin.""" content = _product_detail_sexp(d, ctx) oobs = "(<> " + _product_header_sexp(ctx, d, oob=True) + " " oobs += _oob_header_sexp("product-header-child", "product-admin-header-child", _product_admin_header_sexp(ctx, d)) + " " 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 oob_page_sexp(oobs=oobs, content=content) def _product_admin_header_sexp(ctx: dict, d: dict, *, oob: bool = False) -> str: """Build product admin header row as sexp.""" from quart import url_for slug = d.get("slug", "") link_href = url_for("market.browse.product.admin", product_slug=slug) return sexp_call( "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 render_market_admin_page(ctx: dict) -> str: """Full page: market admin.""" content = '"market admin"' hdr = root_header_sexp(ctx) child = "(<> " + _post_header_sexp(ctx) + " " + _market_header_sexp(ctx) + " " child += _market_admin_header_sexp(ctx, selected="markets") + ")" hdr = "(<> " + hdr + " " + header_child_sexp(child) + ")" return full_page_sexp(ctx, header_rows=hdr, content=content) async def render_market_admin_oob(ctx: dict) -> str: """OOB response: market admin.""" content = '"market admin"' oobs = "(<> " + _market_header_sexp(ctx, oob=True) + " " oobs += _oob_header_sexp("market-header-child", "market-admin-header-child", _market_admin_header_sexp(ctx, selected="markets")) + " " oobs += _clear_deeper_oob("post-row", "post-header-child", "market-row", "market-header-child", "market-admin-row", "market-admin-header-child") + ")" return oob_page_sexp(oobs=oobs, content=content) def _market_admin_header_sexp(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 post_admin_header_sexp(ctx, slug, oob=oob, selected=selected) # --------------------------------------------------------------------------- # Page admin (//admin/) — post-level admin for markets # --------------------------------------------------------------------------- async def render_page_admin_page(ctx: dict) -> str: """Full page: page-level market admin.""" slug = (ctx.get("post") or {}).get("slug", "") admin_hdr = post_admin_header_sexp(ctx, slug, selected="markets") hdr = root_header_sexp(ctx) child = "(<> " + _post_header_sexp(ctx) + " " + admin_hdr + ")" hdr = "(<> " + hdr + " " + header_child_sexp(child) + ")" content = '(div :id "main-panel" (div :class "p-4 text-stone-500" "Market admin"))' return full_page_sexp(ctx, header_rows=hdr, content=content) async def render_page_admin_oob(ctx: dict) -> str: """OOB response: page-level market admin.""" slug = (ctx.get("post") or {}).get("slug", "") oobs = "(<> " + post_admin_header_sexp(ctx, slug, oob=True, selected="markets") + " " oobs += _clear_deeper_oob("post-row", "post-header-child", "post-admin-row", "post-admin-header-child") + ")" content = '(div :id "main-panel" (div :class "p-4 text-stone-500" "Market admin"))' return oob_page_sexp(oobs=oobs, content=content) # --------------------------------------------------------------------------- # Public API: POST handler fragment renderers # --------------------------------------------------------------------------- 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 sexp_call( "market-like-toggle-button", colour=colour, action=like_url, hx_headers=f'{{"X-CSRFToken": "{csrf}"}}', label=label, icon_cls=icon, ) 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 = sexp_call("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 = sexp_call("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 = sexp_call( "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 = sexp_call( "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_sexp = sexp_call( "market-cart-add-oob", id=f"cart-add-{slug}", inner=SexpExpr(cart_add), ) return "(<> " + cart_mini + " " + add_sexp + ")"