All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m29s
Replace duplicated _post_header_html, _oob_header_html, and header-child components across blog/events/market/errors with shared sexpr components (~post-label, ~page-cart-badge, ~oob-header, ~header-child, ~error-content) and shared Python helpers (post_header_html, oob_header_html, header_child_html, error_content_html). App-specific logic (blog container-nav wrapping, admin cog, events calendar links) preserved via thin wrappers. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1520 lines
57 KiB
Python
1520 lines
57 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,
|
|
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__)))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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)
|
|
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)
|
|
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))
|
|
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))
|
|
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)
|
|
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))
|
|
return oob_page(ctx, oobs_html=oobs, content_html=content)
|
|
|
|
|
|
def _market_admin_header_html(ctx: dict, *, oob: bool = False) -> str:
|
|
"""Build market admin header row."""
|
|
from quart import url_for
|
|
|
|
link_href = url_for("market.admin.admin")
|
|
return render(
|
|
"menu-row",
|
|
id="market-admin-row", level=3,
|
|
link_href=link_href, link_label="admin", icon="fa fa-cog",
|
|
child_id="market-admin-header-child", oob=oob,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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
|