All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m39s
When navigating from a deeper page (e.g. day) to a shallower one (e.g. calendar) via HTMX, orphaned header rows from the deeper page persisted in the DOM because OOB swaps only replaced specific child divs, not siblings. Fix by sending empty OOB swaps to clear all header row IDs not present at the current depth. Applied to events (calendars/calendar/day/entry/admin/slots) and market (market_home/browse/product/admin). Also restore app_label in root header. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1572 lines
60 KiB
Python
1572 lines
60 KiB
Python
"""
|
|
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 markupsafe import escape
|
|
|
|
from shared.sexp.jinja_bridge import render, load_service_components
|
|
from shared.sexp.helpers import (
|
|
call_url, get_asset_url, root_header_html,
|
|
post_header_html as _post_header_html,
|
|
post_admin_header_html,
|
|
oob_header_html as _oob_header_html,
|
|
search_mobile_html, search_desktop_html,
|
|
full_page, oob_page,
|
|
)
|
|
|
|
# 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}" hx-swap-oob="outerHTML"></div>' 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_html(p: dict) -> str:
|
|
"""Render price line for product card (mirrors prices.html card_price macro)."""
|
|
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"])
|
|
inner = ""
|
|
if pr["sp_val"]:
|
|
inner += render("market-price-special", price=sp_str)
|
|
if pr["rp_val"]:
|
|
inner += render("market-price-regular-strike", price=rp_str)
|
|
elif pr["rp_val"]:
|
|
inner += render("market-price-regular", price=rp_str)
|
|
return render("market-price-line", inner_html=inner)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Header helpers — _post_header_html and _oob_header_html imported from shared
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _market_header_html(ctx: dict, *, oob: bool = False) -> str:
|
|
"""Build the market-level header row (shop icon + market title + category slugs + nav)."""
|
|
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 = render("market-sub-slug", sub=sub_slug) if sub_slug else ""
|
|
label_html = render(
|
|
"market-shop-label",
|
|
title=market_title, top_slug=top_slug or "", sub_div_html=sub_div,
|
|
)
|
|
|
|
link_href = url_for("market.browse.home")
|
|
|
|
# Build desktop nav from categories
|
|
categories = ctx.get("categories", {})
|
|
qs = ctx.get("qs", "")
|
|
nav_html = _desktop_category_nav_html(ctx, categories, qs, hx_select_search)
|
|
|
|
return render(
|
|
"menu-row",
|
|
id="market-row", level=2,
|
|
link_href=link_href, link_label_html=label_html,
|
|
nav_html=nav_html, child_id="market-header-child", oob=oob,
|
|
)
|
|
|
|
|
|
def _desktop_category_nav_html(ctx: dict, categories: dict, qs: str,
|
|
hx_select: str) -> str:
|
|
"""Build desktop category navigation links."""
|
|
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")
|
|
links = render(
|
|
"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)
|
|
links += render(
|
|
"market-category-link",
|
|
href=cat_href, hx_select=hx_select, active=cat_active,
|
|
select_colours=select_colours, label=cat,
|
|
)
|
|
|
|
admin_link = ""
|
|
if rights and rights.get("admin"):
|
|
admin_href = prefix + url_for("market.admin.admin")
|
|
admin_link = render("market-admin-link", href=admin_href, hx_select=hx_select)
|
|
|
|
return render("market-desktop-category-nav", links_html=links, admin_html=admin_link)
|
|
|
|
|
|
def _product_header_html(ctx: dict, d: dict, *, oob: bool = False) -> str:
|
|
"""Build the product-level header row (bag icon + title + prices + admin)."""
|
|
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_html = render("market-product-label", title=title)
|
|
|
|
# Prices in nav area
|
|
pr = _set_prices(d)
|
|
cart = ctx.get("cart", [])
|
|
prices_nav = _prices_header_html(d, pr, cart, slug, ctx)
|
|
|
|
rights = ctx.get("rights", {})
|
|
admin_html = ""
|
|
if rights and rights.get("admin"):
|
|
admin_href = url_for("market.browse.product.admin", product_slug=slug)
|
|
admin_html = render("market-admin-link", href=admin_href, hx_select=hx_select_search)
|
|
nav_html = prices_nav + admin_html
|
|
|
|
return render(
|
|
"menu-row",
|
|
id="product-row", level=3,
|
|
link_href=link_href, link_label_html=label_html,
|
|
nav_html=nav_html, child_id="product-header-child", oob=oob,
|
|
)
|
|
|
|
|
|
def _prices_header_html(d: dict, pr: dict, cart: list, slug: str, ctx: dict) -> str:
|
|
"""Build prices + add-to-cart for product header row."""
|
|
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_html = _cart_add_html(slug, quantity, cart_action, csrf, cart_url_fn)
|
|
|
|
inner = add_html
|
|
sp_val, rp_val = pr.get("sp_val"), pr.get("rp_val")
|
|
if sp_val:
|
|
inner += render("market-header-price-special-label")
|
|
inner += render("market-header-price-special",
|
|
price=_price_str(sp_val, pr["sp_raw"], pr["sp_cur"]))
|
|
if rp_val:
|
|
inner += render("market-header-price-strike",
|
|
price=_price_str(rp_val, pr["rp_raw"], pr["rp_cur"]))
|
|
elif rp_val:
|
|
inner += render("market-header-price-regular-label")
|
|
inner += render("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}"
|
|
inner += render("market-header-rrp", rrp=rrp_str)
|
|
|
|
return render("market-prices-row", inner_html=inner)
|
|
|
|
|
|
def _cart_add_html(slug: str, quantity: int, action: str, csrf: str,
|
|
cart_url_fn: Any = None) -> str:
|
|
"""Render add-to-cart button or quantity controls."""
|
|
if not quantity:
|
|
return render(
|
|
"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 render(
|
|
"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_html(ctx: dict) -> str:
|
|
"""Build mobile nav panel with category accordion."""
|
|
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")
|
|
items = render(
|
|
"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 = render("market-mobile-chevron")
|
|
|
|
cat_count = data.get("count", 0)
|
|
summary_html = render(
|
|
"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_html=chevron,
|
|
)
|
|
|
|
subs = data.get("subs", [])
|
|
subs_html = ""
|
|
if subs:
|
|
sub_links = ""
|
|
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_links += render(
|
|
"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),
|
|
)
|
|
subs_html = render("market-mobile-subs-panel", links_html=sub_links)
|
|
else:
|
|
view_href = prefix + url_for("market.browse.browse_top", top_slug=cat_slug) + qs
|
|
subs_html = render("market-mobile-view-all", href=view_href, hx_select=hx_select)
|
|
|
|
items += render(
|
|
"market-mobile-cat-details",
|
|
open=cat_active or None, summary_html=summary_html, subs_html=subs_html,
|
|
)
|
|
|
|
return render("market-mobile-nav-wrapper", items_html=items)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Product card (browse grid item)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _product_card_html(p: dict, ctx: dict) -> str:
|
|
"""Render a single product card for browse grid."""
|
|
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()
|
|
cart_action = url_for("market.browse.product.cart", product_slug=slug)
|
|
|
|
# Like button overlay
|
|
like_html = ""
|
|
if user:
|
|
liked = p.get("is_liked", False)
|
|
like_html = _like_button_html(slug, liked, csrf, ctx)
|
|
|
|
# Image
|
|
image = p.get("image")
|
|
labels = p.get("labels", [])
|
|
brand = p.get("brand", "")
|
|
brand_highlight = " bg-yellow-200" if brand in selected_brands else ""
|
|
|
|
if image:
|
|
labels_html = ""
|
|
if callable(asset_url_fn):
|
|
for l in labels:
|
|
labels_html += render(
|
|
"market-label-overlay",
|
|
src=asset_url_fn("labels/" + l + ".svg"),
|
|
)
|
|
img_html = render(
|
|
"market-card-image",
|
|
image=image, labels_html=labels_html,
|
|
brand_highlight=brand_highlight, brand=brand,
|
|
)
|
|
else:
|
|
labels_list = ""
|
|
for l in labels:
|
|
labels_list += render("market-card-label-item", label=l)
|
|
img_html = render(
|
|
"market-card-no-image",
|
|
labels_html=labels_list, brand=brand,
|
|
)
|
|
|
|
price_html = _card_price_html(p)
|
|
|
|
# Cart button
|
|
quantity = sum(ci.quantity for ci in cart if ci.product.slug == slug) if cart else 0
|
|
cart_url_fn = ctx.get("cart_url")
|
|
add_html = _cart_add_html(slug, quantity, cart_action, csrf, cart_url_fn)
|
|
|
|
# Stickers
|
|
stickers = p.get("stickers", [])
|
|
stickers_html = ""
|
|
if stickers and callable(asset_url_fn):
|
|
sticker_items = ""
|
|
for s in stickers:
|
|
found = s in selected_stickers
|
|
src = asset_url_fn(f"stickers/{s}.svg")
|
|
ring = " ring-2 ring-emerald-500 rounded" if found else ""
|
|
sticker_items += render("market-card-sticker", src=src, name=s, ring_cls=ring)
|
|
stickers_html = render("market-card-stickers", items_html=sticker_items)
|
|
|
|
# Title with search highlight
|
|
title = p.get("title", "")
|
|
if search and search.lower() in title.lower():
|
|
idx = title.lower().index(search.lower())
|
|
highlighted = render(
|
|
"market-card-highlight",
|
|
pre=title[:idx], mid=title[idx:idx+len(search)], post=title[idx+len(search):],
|
|
)
|
|
else:
|
|
highlighted = render("market-card-text", text=title)
|
|
|
|
return render(
|
|
"market-product-card",
|
|
like_html=like_html, href=item_href, hx_select=hx_select,
|
|
image_html=img_html, price_html=price_html, add_html=add_html,
|
|
stickers_html=stickers_html, title_html=highlighted,
|
|
)
|
|
|
|
|
|
def _like_button_html(slug: str, liked: bool, csrf: str, ctx: dict) -> str:
|
|
"""Render the like/unlike heart button overlay."""
|
|
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 render(
|
|
"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()"
|
|
)
|
|
|
|
|
|
def _product_cards_html(ctx: dict) -> str:
|
|
"""Render product cards with infinite scroll sentinels."""
|
|
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_html(p, ctx) for p in products]
|
|
|
|
if page < total_pages:
|
|
# Build next page URL
|
|
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
|
|
|
|
# Mobile sentinel
|
|
parts.append(render(
|
|
"market-sentinel-mobile",
|
|
id=f"sentinel-{page}-m", next_url=next_url, hyperscript=_MOBILE_SENTINEL_HS,
|
|
))
|
|
|
|
# Desktop sentinel
|
|
parts.append(render(
|
|
"market-sentinel-desktop",
|
|
id=f"sentinel-{page}-d", next_url=next_url, hyperscript=_DESKTOP_SENTINEL_HS,
|
|
))
|
|
else:
|
|
parts.append(render("market-sentinel-end"))
|
|
|
|
return "".join(parts)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Browse filter panels (mobile + desktop)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _desktop_filter_html(ctx: dict) -> str:
|
|
"""Build the desktop aside filter panel (search, category, sort, like, labels, stickers, brands)."""
|
|
from quart import url_for
|
|
from shared.utils import route_prefix
|
|
|
|
prefix = route_prefix()
|
|
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_html = search_desktop_html(ctx)
|
|
|
|
# Category summary + sort + like + labels + stickers
|
|
cat_inner = render("market-filter-category-label", label=category_label)
|
|
|
|
if sort_options:
|
|
cat_inner += _sort_stickers_html(sort_options, sort, ctx)
|
|
|
|
like_labels = _like_filter_html(liked, liked_count, ctx)
|
|
if labels:
|
|
like_labels += _labels_filter_html(labels, selected_labels, ctx, prefix="nav-labels")
|
|
cat_inner += render("market-filter-like-labels-nav", inner_html=like_labels)
|
|
|
|
if stickers:
|
|
cat_inner += _stickers_filter_html(stickers, selected_stickers, ctx)
|
|
|
|
if subs_local and top_local_href:
|
|
cat_inner += _subcategory_selector_html(subs_local, top_local_href, sub_slug, ctx)
|
|
|
|
cat_summary = render("market-desktop-category-summary", inner_html=cat_inner)
|
|
|
|
# Brand filter
|
|
brand_inner = ""
|
|
if brands:
|
|
brand_inner = _brand_filter_html(brands, selected_brands, ctx)
|
|
brand_summary = render("market-desktop-brand-summary", inner_html=brand_inner)
|
|
|
|
return search_html + cat_summary + brand_summary
|
|
|
|
|
|
def _mobile_filter_summary_html(ctx: dict) -> str:
|
|
"""Build mobile filter summary (collapsible bar showing active filters)."""
|
|
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_html(ctx)
|
|
|
|
# Summary chips showing active filters
|
|
chips = ""
|
|
|
|
if sort and sort_options:
|
|
for k, l, i in sort_options:
|
|
if k == sort and callable(asset_url_fn):
|
|
chips += render("market-mobile-chip-sort", src=asset_url_fn(i), label=l)
|
|
if liked:
|
|
liked_inner = render("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_inner += render("market-mobile-chip-count", cls=cls, count=str(liked_count))
|
|
chips += render("market-mobile-chip-liked", inner_html=liked_inner)
|
|
|
|
# Selected labels
|
|
if selected_labels:
|
|
label_items = ""
|
|
for sl in selected_labels:
|
|
for lb in labels:
|
|
if lb.get("name") == sl and callable(asset_url_fn):
|
|
li_inner = render(
|
|
"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_inner += render("market-mobile-chip-count", cls=cls, count=str(lb["count"]))
|
|
label_items += render("market-mobile-chip-item", inner_html=li_inner)
|
|
chips += render("market-mobile-chip-list", items_html=label_items)
|
|
|
|
# Selected stickers
|
|
if selected_stickers:
|
|
sticker_items = ""
|
|
for ss in selected_stickers:
|
|
for st in stickers:
|
|
if st.get("name") == ss and callable(asset_url_fn):
|
|
si_inner = render(
|
|
"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_inner += render("market-mobile-chip-count", cls=cls, count=str(st["count"]))
|
|
sticker_items += render("market-mobile-chip-item", inner_html=si_inner)
|
|
chips += render("market-mobile-chip-list", items_html=sticker_items)
|
|
|
|
# Selected brands
|
|
if selected_brands:
|
|
brand_items = ""
|
|
for b in selected_brands:
|
|
count = 0
|
|
for br in brands:
|
|
if br.get("name") == b:
|
|
count = br.get("count", 0)
|
|
if count:
|
|
brand_items += render("market-mobile-chip-brand", name=b, count=str(count))
|
|
else:
|
|
brand_items += render("market-mobile-chip-brand-zero", name=b)
|
|
chips += render("market-mobile-chip-brand-list", items_html=brand_items)
|
|
|
|
chips_html = render("market-mobile-chips-row", inner_html=chips)
|
|
|
|
# Full mobile filter details
|
|
from shared.utils import route_prefix
|
|
prefix = route_prefix()
|
|
mobile_filter = _mobile_filter_content_html(ctx, prefix)
|
|
|
|
return render(
|
|
"market-mobile-filter-summary",
|
|
search_bar=search_bar, chips_html=chips_html, filter_html=mobile_filter,
|
|
)
|
|
|
|
|
|
def _mobile_filter_content_html(ctx: dict, prefix: str) -> str:
|
|
"""Build the expanded mobile filter panel contents."""
|
|
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 = []
|
|
|
|
# Sort options
|
|
if sort_options:
|
|
parts.append(_sort_stickers_html(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(render("market-mobile-clear-filters", href=clear_url, hx_select=hx_select))
|
|
|
|
# Like + labels row
|
|
like_labels = _like_filter_html(liked, liked_count, ctx, mobile=True)
|
|
if labels:
|
|
like_labels += _labels_filter_html(labels, selected_labels, ctx, prefix="nav-labels", mobile=True)
|
|
parts.append(render("market-mobile-like-labels-row", inner_html=like_labels))
|
|
|
|
# Stickers
|
|
if stickers:
|
|
parts.append(_stickers_filter_html(stickers, selected_stickers, ctx, mobile=True))
|
|
|
|
# Brands
|
|
if brands:
|
|
parts.append(_brand_filter_html(brands, selected_brands, ctx, mobile=True))
|
|
|
|
return "".join(parts)
|
|
|
|
|
|
def _sort_stickers_html(sort_options: list, current_sort: str, ctx: dict, mobile: bool = False) -> str:
|
|
"""Render sort option stickers."""
|
|
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()
|
|
|
|
items = ""
|
|
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
|
|
items += render(
|
|
"market-filter-sort-item",
|
|
href=href, hx_select=hx_select, ring_cls=ring, src=src, label=label,
|
|
)
|
|
return render("market-filter-sort-row", items_html=items)
|
|
|
|
|
|
def _like_filter_html(liked: bool, liked_count: int, ctx: dict, mobile: bool = False) -> str:
|
|
"""Render the like filter toggle."""
|
|
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 render(
|
|
"market-filter-like",
|
|
href=href, hx_select=hx_select, icon_cls=icon_cls, size_cls=size,
|
|
)
|
|
|
|
|
|
def _labels_filter_html(labels: list, selected: list, ctx: dict, *,
|
|
prefix: str = "nav-labels", mobile: bool = False) -> str:
|
|
"""Render label filter buttons."""
|
|
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()
|
|
|
|
items = ""
|
|
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 ""
|
|
items += render(
|
|
"market-filter-label-item",
|
|
href=href, hx_select=hx_select, ring_cls=ring, src=src, name=name,
|
|
)
|
|
return items
|
|
|
|
|
|
def _stickers_filter_html(stickers: list, selected: list, ctx: dict, mobile: bool = False) -> str:
|
|
"""Render sticker filter grid."""
|
|
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()
|
|
|
|
items = ""
|
|
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"
|
|
items += render(
|
|
"market-filter-sticker-item",
|
|
href=href, hx_select=hx_select, ring_cls=ring,
|
|
src=src, name=name, count_cls=cls, count=str(count),
|
|
)
|
|
return render("market-filter-stickers-row", items_html=items)
|
|
|
|
|
|
def _brand_filter_html(brands: list, selected: list, ctx: dict, mobile: bool = False) -> str:
|
|
"""Render brand filter checkboxes."""
|
|
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()
|
|
|
|
items = ""
|
|
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"
|
|
items += render(
|
|
"market-filter-brand-item",
|
|
href=href, hx_select=hx_select, bg_cls=bg,
|
|
name_cls=cls, name=name, count=str(count),
|
|
)
|
|
return render("market-filter-brands-panel", items_html=items)
|
|
|
|
|
|
def _subcategory_selector_html(subs: list, top_href: str, current_sub: str, ctx: dict) -> str:
|
|
"""Render subcategory vertical nav."""
|
|
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
|
|
items = render(
|
|
"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
|
|
items += render(
|
|
"market-filter-subcategory-item",
|
|
href=full_href, hx_select=hx_select, active_cls=active_cls, name=name,
|
|
)
|
|
return render("market-filter-subcategory-panel", items_html=items)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Product detail page content
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _product_detail_html(d: dict, ctx: dict) -> str:
|
|
"""Build product detail main panel content."""
|
|
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_html = ""
|
|
if user:
|
|
like_html = _like_button_html(slug, liked_by_current_user, csrf, ctx)
|
|
|
|
# Main image + labels
|
|
labels_overlay = ""
|
|
if callable(asset_url_fn):
|
|
for l in labels:
|
|
labels_overlay += render(
|
|
"market-label-overlay",
|
|
src=asset_url_fn("labels/" + l + ".svg"),
|
|
)
|
|
|
|
gallery_inner = render(
|
|
"market-detail-gallery-inner",
|
|
like_html=like_html, image=images[0], alt=d.get("title", ""),
|
|
labels_html=labels_overlay, brand=brand,
|
|
)
|
|
|
|
# Prev/next buttons
|
|
nav_buttons = ""
|
|
if len(images) > 1:
|
|
nav_buttons = render("market-detail-nav-buttons")
|
|
|
|
gallery_html = render(
|
|
"market-detail-gallery",
|
|
inner_html=gallery_inner, nav_html=nav_buttons,
|
|
)
|
|
|
|
# Thumbnails
|
|
if len(images) > 1:
|
|
thumbs = ""
|
|
for i, u in enumerate(images):
|
|
thumbs += render(
|
|
"market-detail-thumb",
|
|
title=f"Image {i+1}", src=u, alt=f"thumb {i+1}",
|
|
)
|
|
gallery_html += render("market-detail-thumbs", thumbs_html=thumbs)
|
|
else:
|
|
like_html = ""
|
|
if user:
|
|
like_html = _like_button_html(slug, liked_by_current_user, csrf, ctx)
|
|
gallery_html = render("market-detail-no-image", like_html=like_html)
|
|
|
|
# Stickers below gallery
|
|
stickers_html = ""
|
|
if stickers and callable(asset_url_fn):
|
|
sticker_items = ""
|
|
for s in stickers:
|
|
sticker_items += render(
|
|
"market-detail-sticker",
|
|
src=asset_url_fn("stickers/" + s + ".svg"), name=s,
|
|
)
|
|
stickers_html = render("market-detail-stickers", items_html=sticker_items)
|
|
|
|
# Right column: prices, description, sections
|
|
pr = _set_prices(d)
|
|
details_inner = ""
|
|
|
|
# Unit price / case size extras
|
|
extras = ""
|
|
ppu = d.get("price_per_unit") or d.get("price_per_unit_raw")
|
|
if ppu:
|
|
extras += render(
|
|
"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"):
|
|
extras += render("market-detail-case-size", size=d["case_size_raw"])
|
|
if extras:
|
|
details_inner += render("market-detail-extras", inner_html=extras)
|
|
|
|
# Description
|
|
desc_short = d.get("description_short")
|
|
desc_html = d.get("description_html")
|
|
if desc_short or desc_html:
|
|
desc_inner = ""
|
|
if desc_short:
|
|
desc_inner += render("market-detail-desc-short", text=desc_short)
|
|
if desc_html:
|
|
desc_inner += render("market-detail-desc-html", html=desc_html)
|
|
details_inner += render("market-detail-desc-wrapper", inner_html=desc_inner)
|
|
|
|
# Sections (expandable)
|
|
sections = d.get("sections", [])
|
|
if sections:
|
|
sec_items = ""
|
|
for sec in sections:
|
|
sec_items += render(
|
|
"market-detail-section",
|
|
title=sec.get("title", ""), html=sec.get("html", ""),
|
|
)
|
|
details_inner += render("market-detail-sections", items_html=sec_items)
|
|
|
|
details_html = render("market-detail-right-col", inner_html=details_inner)
|
|
|
|
return render(
|
|
"market-detail-layout",
|
|
gallery_html=gallery_html, stickers_html=stickers_html, details_html=details_html,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Product meta (OpenGraph, JSON-LD)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _product_meta_html(d: dict, ctx: dict) -> str:
|
|
"""Build product meta tags for <head>."""
|
|
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 = render("market-meta-title", title=title)
|
|
parts += render("market-meta-description", description=description)
|
|
if canonical:
|
|
parts += render("market-meta-canonical", href=canonical)
|
|
|
|
# OpenGraph
|
|
site_title = ctx.get("base_title", "")
|
|
parts += render("market-meta-og", property="og:site_name", content=site_title)
|
|
parts += render("market-meta-og", property="og:type", content="product")
|
|
parts += render("market-meta-og", property="og:title", content=title)
|
|
parts += render("market-meta-og", property="og:description", content=description)
|
|
if canonical:
|
|
parts += render("market-meta-og", property="og:url", content=canonical)
|
|
if image_url:
|
|
parts += render("market-meta-og", property="og:image", content=image_url)
|
|
if price and price_currency:
|
|
parts += render("market-meta-og", property="product:price:amount", content=f"{price:.2f}")
|
|
parts += render("market-meta-og", property="product:price:currency", content=price_currency)
|
|
if brand:
|
|
parts += render("market-meta-og", property="product:brand", content=brand)
|
|
|
|
# Twitter
|
|
card_type = "summary_large_image" if image_url else "summary"
|
|
parts += render("market-meta-twitter", name="twitter:card", content=card_type)
|
|
parts += render("market-meta-twitter", name="twitter:title", content=title)
|
|
parts += render("market-meta-twitter", name="twitter:description", content=description)
|
|
if image_url:
|
|
parts += render("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 += render("market-meta-jsonld", json=json.dumps(jsonld))
|
|
|
|
return parts
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Market cards (all markets / page markets)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _market_card_html(market: Any, page_info: dict, *, show_page_badge: bool = True,
|
|
post_slug: str = "") -> str:
|
|
"""Render a single market card."""
|
|
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_html = ""
|
|
if market_href:
|
|
title_html = render("market-market-card-title-link", href=market_href, name=name)
|
|
else:
|
|
title_html = render("market-market-card-title", name=name)
|
|
|
|
desc_html = ""
|
|
if description:
|
|
desc_html = render("market-market-card-desc", description=description)
|
|
|
|
badge_html = ""
|
|
if show_page_badge and p_title:
|
|
badge_href = market_url(f"/{p_slug}/")
|
|
badge_html = render("market-market-card-badge", href=badge_href, title=p_title)
|
|
|
|
return render(
|
|
"market-market-card",
|
|
title_html=title_html, desc_html=desc_html, badge_html=badge_html,
|
|
)
|
|
|
|
|
|
def _market_cards_html(markets: list, page_info: dict, page: int, has_more: bool,
|
|
next_url: str, *, show_page_badge: bool = True,
|
|
post_slug: str = "") -> str:
|
|
"""Render market cards with infinite scroll sentinel."""
|
|
parts = [_market_card_html(m, page_info, show_page_badge=show_page_badge,
|
|
post_slug=post_slug) for m in markets]
|
|
if has_more:
|
|
parts.append(render(
|
|
"market-market-sentinel",
|
|
id=f"sentinel-{page}", next_url=next_url,
|
|
))
|
|
return "".join(parts)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# OOB header helpers — _oob_header_html imported from shared
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# ===========================================================================
|
|
# PUBLIC API
|
|
# ===========================================================================
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# All markets
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _markets_grid(cards: str) -> str:
|
|
"""Wrap market cards in a grid."""
|
|
return render("market-markets-grid", cards_html=cards)
|
|
|
|
|
|
def _no_markets_html(message: str = "No markets available") -> str:
|
|
"""Empty state for markets."""
|
|
return render("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_html(markets, page_info, page, has_more, next_url)
|
|
content = _markets_grid(cards)
|
|
else:
|
|
content = _no_markets_html()
|
|
content += render("market-bottom-spacer")
|
|
|
|
hdr = root_header_html(ctx)
|
|
return full_page(ctx, header_rows_html=hdr, content_html=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_html(markets, page_info, page, has_more, next_url)
|
|
content = _markets_grid(cards)
|
|
else:
|
|
content = _no_markets_html()
|
|
content += render("market-bottom-spacer")
|
|
|
|
oobs = root_header_html(ctx, oob=True)
|
|
return oob_page(ctx, oobs_html=oobs, content_html=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_html(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_html(markets, {}, page, has_more, next_url,
|
|
show_page_badge=False, post_slug=post_slug)
|
|
content = _markets_grid(cards)
|
|
else:
|
|
content = _no_markets_html("No markets for this page")
|
|
content += render("market-bottom-spacer")
|
|
|
|
hdr = root_header_html(ctx)
|
|
hdr += render("header-child", inner_html=_post_header_html(ctx))
|
|
return full_page(ctx, header_rows_html=hdr, content_html=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_html(markets, {}, page, has_more, next_url,
|
|
show_page_badge=False, post_slug=post_slug)
|
|
content = _markets_grid(cards)
|
|
else:
|
|
content = _no_markets_html("No markets for this page")
|
|
content += render("market-bottom-spacer")
|
|
|
|
oobs = _oob_header_html("post-header-child", "market-header-child", "")
|
|
oobs += _post_header_html(ctx, oob=True)
|
|
return oob_page(ctx, oobs_html=oobs, content_html=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_html(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(post)
|
|
|
|
hdr = root_header_html(ctx)
|
|
child = _post_header_html(ctx) + _market_header_html(ctx)
|
|
hdr += render("header-child", inner_html=child)
|
|
menu = _mobile_nav_panel_html(ctx)
|
|
return full_page(ctx, header_rows_html=hdr, content_html=content, menu_html=menu)
|
|
|
|
|
|
async def render_market_home_oob(ctx: dict) -> str:
|
|
"""OOB response: market landing page."""
|
|
post = ctx.get("post") or {}
|
|
content = _market_landing_content(post)
|
|
|
|
oobs = _oob_header_html("post-header-child", "market-header-child",
|
|
_market_header_html(ctx))
|
|
oobs += _post_header_html(ctx, oob=True)
|
|
oobs += _clear_deeper_oob("post-row", "post-header-child",
|
|
"market-row", "market-header-child")
|
|
menu = _mobile_nav_panel_html(ctx)
|
|
return oob_page(ctx, oobs_html=oobs, content_html=content, menu_html=menu)
|
|
|
|
|
|
def _market_landing_content(post: dict) -> str:
|
|
"""Build market landing page content (excerpt + feature image + html)."""
|
|
inner = ""
|
|
if post.get("custom_excerpt"):
|
|
inner += render("market-landing-excerpt", text=post["custom_excerpt"])
|
|
if post.get("feature_image"):
|
|
inner += render("market-landing-image", src=post["feature_image"])
|
|
if post.get("html"):
|
|
inner += render("market-landing-html", html=post["html"])
|
|
return render("market-landing-content", inner_html=inner)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Browse page
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _product_grid(cards_html: str) -> str:
|
|
"""Wrap product cards in a grid."""
|
|
return render("market-product-grid", cards_html=cards_html)
|
|
|
|
|
|
async def render_browse_page(ctx: dict) -> str:
|
|
"""Full page: product browse with filters."""
|
|
cards_html = _product_cards_html(ctx)
|
|
content = _product_grid(cards_html)
|
|
|
|
hdr = root_header_html(ctx)
|
|
child = _post_header_html(ctx) + _market_header_html(ctx)
|
|
hdr += render("header-child", inner_html=child)
|
|
menu = _mobile_nav_panel_html(ctx)
|
|
filter_html = _mobile_filter_summary_html(ctx)
|
|
aside_html = _desktop_filter_html(ctx)
|
|
|
|
return full_page(ctx, header_rows_html=hdr, content_html=content,
|
|
menu_html=menu, filter_html=filter_html, aside_html=aside_html)
|
|
|
|
|
|
async def render_browse_oob(ctx: dict) -> str:
|
|
"""OOB response: product browse."""
|
|
cards_html = _product_cards_html(ctx)
|
|
content = _product_grid(cards_html)
|
|
|
|
oobs = _oob_header_html("post-header-child", "market-header-child",
|
|
_market_header_html(ctx))
|
|
oobs += _post_header_html(ctx, oob=True)
|
|
oobs += _clear_deeper_oob("post-row", "post-header-child",
|
|
"market-row", "market-header-child")
|
|
menu = _mobile_nav_panel_html(ctx)
|
|
filter_html = _mobile_filter_summary_html(ctx)
|
|
aside_html = _desktop_filter_html(ctx)
|
|
|
|
return oob_page(ctx, oobs_html=oobs, content_html=content,
|
|
menu_html=menu, filter_html=filter_html, aside_html=aside_html)
|
|
|
|
|
|
async def render_browse_cards(ctx: dict) -> str:
|
|
"""Pagination fragment: product cards only."""
|
|
return _product_cards_html(ctx)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Product detail
|
|
# ---------------------------------------------------------------------------
|
|
|
|
async def render_product_page(ctx: dict, d: dict) -> str:
|
|
"""Full page: product detail."""
|
|
content = _product_detail_html(d, ctx)
|
|
meta = _product_meta_html(d, ctx)
|
|
|
|
hdr = root_header_html(ctx)
|
|
child = _post_header_html(ctx) + _market_header_html(ctx) + _product_header_html(ctx, d)
|
|
hdr += render("header-child", inner_html=child)
|
|
return full_page(ctx, header_rows_html=hdr, content_html=content, meta_html=meta)
|
|
|
|
|
|
async def render_product_oob(ctx: dict, d: dict) -> str:
|
|
"""OOB response: product detail."""
|
|
content = _product_detail_html(d, ctx)
|
|
|
|
oobs = _market_header_html(ctx, oob=True)
|
|
oobs += _oob_header_html("market-header-child", "product-header-child",
|
|
_product_header_html(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_html(ctx)
|
|
return oob_page(ctx, oobs_html=oobs, content_html=content, menu_html=menu)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Product admin
|
|
# ---------------------------------------------------------------------------
|
|
|
|
async def render_product_admin_page(ctx: dict, d: dict) -> str:
|
|
"""Full page: product admin."""
|
|
content = _product_detail_html(d, ctx)
|
|
|
|
hdr = root_header_html(ctx)
|
|
child = (_post_header_html(ctx) + _market_header_html(ctx)
|
|
+ _product_header_html(ctx, d) + _product_admin_header_html(ctx, d))
|
|
hdr += render("header-child", inner_html=child)
|
|
return full_page(ctx, header_rows_html=hdr, content_html=content)
|
|
|
|
|
|
async def render_product_admin_oob(ctx: dict, d: dict) -> str:
|
|
"""OOB response: product admin."""
|
|
content = _product_detail_html(d, ctx)
|
|
|
|
oobs = _product_header_html(ctx, d, oob=True)
|
|
oobs += _oob_header_html("product-header-child", "product-admin-header-child",
|
|
_product_admin_header_html(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(ctx, oobs_html=oobs, content_html=content)
|
|
|
|
|
|
def _product_admin_header_html(ctx: dict, d: dict, *, oob: bool = False) -> str:
|
|
"""Build product admin header row."""
|
|
from quart import url_for
|
|
|
|
slug = d.get("slug", "")
|
|
link_href = url_for("market.browse.product.admin", product_slug=slug)
|
|
return render(
|
|
"menu-row",
|
|
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_html(ctx)
|
|
child = _post_header_html(ctx) + _market_header_html(ctx) + _market_admin_header_html(ctx, selected="markets")
|
|
hdr += render("header-child", inner_html=child)
|
|
return full_page(ctx, header_rows_html=hdr, content_html=content)
|
|
|
|
|
|
async def render_market_admin_oob(ctx: dict) -> str:
|
|
"""OOB response: market admin."""
|
|
content = "market admin"
|
|
|
|
oobs = _market_header_html(ctx, oob=True)
|
|
oobs += _oob_header_html("market-header-child", "market-admin-header-child",
|
|
_market_admin_header_html(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(ctx, oobs_html=oobs, content_html=content)
|
|
|
|
|
|
def _market_admin_header_html(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_html(ctx, slug, oob=oob, selected=selected)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Page admin (/<slug>/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_html(ctx, slug, selected="markets")
|
|
hdr = root_header_html(ctx)
|
|
child = _post_header_html(ctx) + admin_hdr
|
|
hdr += render("header-child", inner_html=child)
|
|
content = '<div id="main-panel"><div class="p-4 text-stone-500">Market admin</div></div>'
|
|
return full_page(ctx, header_rows_html=hdr, content_html=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_html(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</div></div>'
|
|
return oob_page(ctx, oobs_html=oobs, content_html=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 render(
|
|
"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 = render("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 = render("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
|
|
add_html = render(
|
|
"market-cart-add-oob",
|
|
id=f"cart-add-{slug}",
|
|
inner_html=_cart_add_html(slug, quantity, action, csrf, cart_url_fn=_cart_url),
|
|
)
|
|
|
|
return cart_mini + add_html
|