All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m15s
Migrate ~52 GET route handlers across all 7 services from Jinja render_template() to s-expression component rendering. Each service gets a sexp_components.py with page/oob/cards render functions. - Add per-service sexp_components.py (account, blog, cart, events, federation, market, orders) with full page, OOB, and pagination card rendering - Add shared/sexp/helpers.py with call_url, root_header_html, full_page, oob_page utilities - Update all GET routes to use get_template_context() + render fns - Fix get_template_context() to inject Jinja globals (URL helpers) - Add qs_filter to base_context for sexp filter URL building - Mount sexp_components.py in docker-compose.dev.yml for all services - Import sexp_components in app.py for Hypercorn --reload watching - Fix route_prefix import (shared.utils not shared.infrastructure.urls) - Fix federation choose-username missing actor in context - Fix market page_markets missing post in context Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1585 lines
70 KiB
Python
1585 lines
70 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
|
|
|
|
from typing import Any
|
|
from markupsafe import escape
|
|
|
|
from shared.sexp.jinja_bridge import sexp
|
|
from shared.sexp.helpers import (
|
|
call_url, get_asset_url, root_header_html,
|
|
search_mobile_html, search_desktop_html,
|
|
full_page, oob_page,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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"])
|
|
parts = ['<div class="mt-1 flex items-baseline gap-2 justify-center">']
|
|
if pr["sp_val"]:
|
|
parts.append(f'<div class="text-lg font-semibold text-emerald-700">{sp_str}</div>')
|
|
if pr["rp_val"]:
|
|
parts.append(f'<div class="text-sm line-through text-stone-500">{rp_str}</div>')
|
|
elif pr["rp_val"]:
|
|
parts.append(f'<div class="mt-1 text-lg font-semibold">{rp_str}</div>')
|
|
parts.append("</div>")
|
|
return "".join(parts)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Header helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _post_header_html(ctx: dict, *, oob: bool = False) -> str:
|
|
"""Build the post-level header row (feature image + title + page cart count)."""
|
|
post = ctx.get("post") or {}
|
|
slug = post.get("slug", "")
|
|
title = (post.get("title") or "")[:160]
|
|
feature_image = post.get("feature_image")
|
|
|
|
label_parts = []
|
|
if feature_image:
|
|
label_parts.append(
|
|
f'<img src="{feature_image}" class="h-8 w-8 rounded-full object-cover border border-stone-300 flex-shrink-0">'
|
|
)
|
|
label_parts.append(f"<span>{escape(title)}</span>")
|
|
label_html = "".join(label_parts)
|
|
|
|
nav_parts = []
|
|
page_cart_count = ctx.get("page_cart_count", 0)
|
|
if page_cart_count and page_cart_count > 0:
|
|
cart_href = call_url(ctx, "cart_url", f"/{slug}/")
|
|
nav_parts.append(
|
|
f'<a href="{cart_href}" class="relative inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-full'
|
|
f' border border-emerald-300 bg-emerald-50 text-emerald-800 hover:bg-emerald-100 transition">'
|
|
f'<i class="fa fa-shopping-cart" aria-hidden="true"></i>'
|
|
f'<span>{page_cart_count}</span></a>'
|
|
)
|
|
|
|
# Container nav
|
|
container_nav = ctx.get("container_nav_html", "")
|
|
if container_nav:
|
|
nav_parts.append(container_nav)
|
|
|
|
nav_html = "".join(nav_parts)
|
|
link_href = call_url(ctx, "blog_url", f"/{slug}/")
|
|
|
|
return sexp(
|
|
'(~menu-row :id "post-row" :level 1'
|
|
' :link-href lh :link-label-html llh'
|
|
' :nav-html nh :child-id "post-header-child" :oob oob)',
|
|
lh=link_href,
|
|
llh=label_html,
|
|
nh=nav_html,
|
|
oob=oob,
|
|
)
|
|
|
|
|
|
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")
|
|
|
|
label_parts = [
|
|
'<div class="font-bold text-xl flex-shrink-0 flex gap-2 items-center">',
|
|
f'<div><i class="fa fa-shop"></i> {escape(market_title)}</div>',
|
|
'<div class="flex flex-col md:flex-row md:gap-2 text-xs">',
|
|
f"<div>{escape(top_slug or '')}</div>",
|
|
]
|
|
if sub_slug:
|
|
label_parts.append(f"<div>{escape(sub_slug)}</div>")
|
|
label_parts.append("</div></div>")
|
|
label_html = "".join(label_parts)
|
|
|
|
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 sexp(
|
|
'(~menu-row :id "market-row" :level 2'
|
|
' :link-href lh :link-label-html llh'
|
|
' :nav-html nh :child-id "market-header-child" :oob oob)',
|
|
lh=link_href,
|
|
llh=label_html,
|
|
nh=nav_html,
|
|
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", {})
|
|
|
|
parts = ['<nav class="hidden md:flex gap-4 text-sm ml-2 w-full justify-end items-center">']
|
|
|
|
all_href = prefix + url_for("market.browse.browse_all") + qs
|
|
all_active = (category_label == "All Products")
|
|
parts.append(
|
|
f'<div class="relative nav-group">'
|
|
f'<a href="{all_href}" hx-get="{all_href}" hx-target="#main-panel"'
|
|
f' hx-select="{hx_select}" hx-swap="outerHTML" hx-push-url="true"'
|
|
f' aria-selected="{"true" if all_active else "false"}"'
|
|
f' class="block px-2 py-1 rounded text-center whitespace-normal break-words leading-snug bg-stone-200 text-black {select_colours}">All</a></div>'
|
|
)
|
|
|
|
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)
|
|
parts.append(
|
|
f'<div class="relative nav-group">'
|
|
f'<a href="{cat_href}" hx-get="{cat_href}" hx-target="#main-panel"'
|
|
f' hx-select="{hx_select}" hx-swap="outerHTML" hx-push-url="true"'
|
|
f' aria-selected="{"true" if cat_active else "false"}"'
|
|
f' class="block px-2 py-1 rounded text-center whitespace-normal break-words leading-snug bg-stone-200 text-black {select_colours}">'
|
|
f'{escape(cat)}</a></div>'
|
|
)
|
|
|
|
# Admin link
|
|
if rights and rights.get("admin"):
|
|
admin_href = prefix + url_for("market.admin.admin")
|
|
parts.append(
|
|
f'<a href="{admin_href}" hx-get="{admin_href}" hx-target="#main-panel"'
|
|
f' hx-select="{hx_select}" hx-swap="outerHTML" hx-push-url="true"'
|
|
f' class="px-2 py-1 text-stone-500 hover:text-stone-700">'
|
|
f'<i class="fa fa-cog" aria-hidden="true"></i></a>'
|
|
)
|
|
|
|
parts.append("</nav>")
|
|
return "".join(parts)
|
|
|
|
|
|
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 = f'<i class="fa fa-shopping-bag" aria-hidden="true"></i><div>{escape(title)}</div>'
|
|
|
|
# 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 = (
|
|
f'<a href="{admin_href}" hx-get="{admin_href}" hx-target="#main-panel"'
|
|
f' hx-select="{hx_select_search}" hx-swap="outerHTML" hx-push-url="true"'
|
|
f' class="px-2 py-1 text-stone-500 hover:text-stone-700">'
|
|
f'<i class="fa fa-cog" aria-hidden="true"></i></a>'
|
|
)
|
|
nav_html = prices_nav + admin_html
|
|
|
|
return sexp(
|
|
'(~menu-row :id "product-row" :level 3'
|
|
' :link-href lh :link-label-html llh'
|
|
' :nav-html nh :child-id "product-header-child" :oob oob)',
|
|
lh=link_href,
|
|
llh=label_html,
|
|
nh=nav_html,
|
|
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)
|
|
|
|
parts = ['<div class="flex flex-row items-center justify-between md:gap-2 md:px-2">']
|
|
parts.append(add_html)
|
|
|
|
sp_val, rp_val = pr.get("sp_val"), pr.get("rp_val")
|
|
if sp_val:
|
|
parts.append(f'<div class="text-md font-bold text-emerald-700">Special price</div>')
|
|
parts.append(f'<div class="text-xl font-semibold text-emerald-700">{_price_str(sp_val, pr["sp_raw"], pr["sp_cur"])}</div>')
|
|
if rp_val:
|
|
parts.append(f'<div class="text-base text-md line-through text-stone-500">{_price_str(rp_val, pr["rp_raw"], pr["rp_cur"])}</div>')
|
|
elif rp_val:
|
|
parts.append(f'<div class="hidden md:block text-xl font-bold">Our price</div>')
|
|
parts.append(f'<div class="text-xl font-semibold">{_price_str(rp_val, pr["rp_raw"], pr["rp_cur"])}</div>')
|
|
|
|
# RRP
|
|
rrp_raw = d.get("rrp_raw")
|
|
rrp_val = d.get("rrp")
|
|
case_size = d.get("case_size_count") or 1
|
|
if rrp_raw and rrp_val:
|
|
rrp_str = f"{rrp_raw[0]}{rrp_val * case_size:.2f}"
|
|
parts.append(f'<div class="text-base text-stone-400"><span>rrp:</span> <span>{rrp_str}</span></div>')
|
|
|
|
parts.append("</div>")
|
|
return "".join(parts)
|
|
|
|
|
|
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 (
|
|
f'<div id="cart-{slug}">'
|
|
f'<form action="{action}" method="post" hx-post="{action}" hx-target="#cart-mini" hx-swap="outerHTML" class="rounded flex items-center">'
|
|
f'<input type="hidden" name="csrf_token" value="{csrf}">'
|
|
f'<input type="hidden" name="count" value="1">'
|
|
f'<button type="submit" class="relative inline-flex items-center justify-center text-sm font-medium text-stone-500 hover:bg-emerald-50">'
|
|
f'<span class="relative inline-flex items-center justify-center"><i class="fa fa-cart-plus text-4xl" aria-hidden="true"></i></span>'
|
|
f'</button></form></div>'
|
|
)
|
|
|
|
cart_href = cart_url_fn("/") if callable(cart_url_fn) else "/"
|
|
return (
|
|
f'<div id="cart-{slug}"><div class="rounded flex items-center gap-2">'
|
|
f'<form action="{action}" method="post" hx-post="{action}" hx-target="#cart-mini" hx-swap="outerHTML">'
|
|
f'<input type="hidden" name="csrf_token" value="{csrf}">'
|
|
f'<input type="hidden" name="count" value="{quantity - 1}">'
|
|
f'<button type="submit" class="inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl">-</button></form>'
|
|
f'<a class="relative inline-flex items-center justify-center text-emerald-700" href="{cart_href}">'
|
|
f'<span class="relative inline-flex items-center justify-center"><i class="fa-solid fa-shopping-cart text-2xl" aria-hidden="true"></i>'
|
|
f'<span class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none">'
|
|
f'<span class="flex items-center justify-center bg-black text-white rounded-full w-4 h-4 text-xs font-bold">{quantity}</span></span></span></a>'
|
|
f'<form action="{action}" method="post" hx-post="{action}" hx-target="#cart-mini" hx-swap="outerHTML">'
|
|
f'<input type="hidden" name="csrf_token" value="{csrf}">'
|
|
f'<input type="hidden" name="count" value="{quantity + 1}">'
|
|
f'<button type="submit" class="inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl">+</button></form>'
|
|
f'</div></div>'
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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", "")
|
|
|
|
parts = ['<div class="px-4 py-2"><div class="divide-y">']
|
|
|
|
all_href = prefix + url_for("market.browse.browse_all") + qs
|
|
all_active = (category_label == "All Products")
|
|
parts.append(
|
|
f'<a role="option" href="{all_href}" hx-get="{all_href}" hx-target="#main-panel"'
|
|
f' hx-select="{hx_select}" hx-swap="outerHTML" hx-push-url="true"'
|
|
f' aria-selected="{"true" if all_active else "false"}"'
|
|
f' class="block rounded-lg px-3 py-3 text-base hover:bg-stone-50 {select_colours}">'
|
|
f'<div class="prose prose-stone max-w-none">All</div></a>'
|
|
)
|
|
|
|
for cat, data in categories.items():
|
|
cat_slug = data.get("slug", "")
|
|
cat_active = (top_slug == cat_slug.lower() if top_slug else False)
|
|
open_attr = " open" if cat_active else ""
|
|
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 ""
|
|
|
|
parts.append(f'<details class="group/cat py-1"{open_attr}>')
|
|
parts.append(
|
|
f'<summary class="flex items-center justify-between cursor-pointer select-none block rounded-lg px-3 py-3 text-base hover:bg-stone-50{bg_cls}">'
|
|
f'<a href="{cat_href}" hx-get="{cat_href}" hx-target="#main-panel"'
|
|
f' hx-select="{hx_select}" hx-swap="outerHTML" hx-push-url="true"'
|
|
f' class="font-medium {select_colours} flex flex-row gap-2">'
|
|
f'<div>{escape(cat)}</div>'
|
|
f'<div aria-label="{data.get("count", 0)} products">{data.get("count", 0)}</div></a>'
|
|
f'<svg class="w-4 h-4 shrink-0 transition-transform group-open/cat:rotate-180" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd"/></svg>'
|
|
f'</summary>'
|
|
)
|
|
|
|
subs = data.get("subs", [])
|
|
if subs:
|
|
parts.append('<div class="pb-3 pl-2"><div data-peek-viewport data-peek-size-px="18" data-peek-edge="bottom" data-peek-mask="true" class="m-2 bg-stone-100">')
|
|
parts.append('<div data-peek-inner class="grid grid-cols-1 gap-1 snap-y snap-mandatory pr-1" aria-label="Subcategories">')
|
|
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"))
|
|
parts.append(
|
|
f'<a class="snap-start px-2 py-3 rounded {select_colours} flex flex-row gap-2"'
|
|
f' aria-selected="{"true" if sub_active else "false"}"'
|
|
f' href="{sub_href}" hx-get="{sub_href}" hx-target="#main-panel"'
|
|
f' hx-select="{hx_select}" hx-swap="outerHTML" hx-push-url="true">'
|
|
f'<div>{escape(sub.get("html_label") or sub.get("name", ""))}</div>'
|
|
f'<div aria-label="{sub.get("count", 0)} products">{sub.get("count", 0)}</div></a>'
|
|
)
|
|
parts.append("</div></div></div>")
|
|
else:
|
|
view_href = prefix + url_for("market.browse.browse_top", top_slug=cat_slug) + qs
|
|
parts.append(
|
|
f'<div class="pb-3 pl-2"><a class="px-2 py-1 rounded hover:bg-stone-100 block"'
|
|
f' href="{view_href}" hx-get="{view_href}" hx-target="#main-panel"'
|
|
f' hx-select="{hx_select}" hx-swap="outerHTML" hx-push-url="true">View all</a></div>'
|
|
)
|
|
parts.append("</details>")
|
|
|
|
parts.append("</div></div>")
|
|
return "".join(parts)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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 = "".join(
|
|
f'<img src="{asset_url_fn("labels/" + l + ".svg")}" alt=""'
|
|
f' class="pointer-events-none absolute inset-0 w-full h-full object-contain object-top"/>'
|
|
for l in labels
|
|
) if callable(asset_url_fn) else ""
|
|
img_html = (
|
|
f'<div class="w-full aspect-square bg-stone-100 relative">'
|
|
f'<figure class="inline-block w-full h-full"><div class="relative w-full h-full">'
|
|
f'<img src="{image}" alt="no image" class="absolute inset-0 w-full h-full object-contain object-top" loading="lazy" decoding="async" fetchpriority="low"/>'
|
|
f'{labels_html}</div>'
|
|
f'<figcaption class="mt-2 text-sm text-center{brand_highlight} text-stone-600">{escape(brand)}</figcaption>'
|
|
f'</figure></div>'
|
|
)
|
|
else:
|
|
labels_list = "".join(f"<li>{l}</li>" for l in labels)
|
|
img_html = (
|
|
f'<div class="w-full aspect-square bg-stone-100 relative">'
|
|
f'<div class="p-2 flex flex-col items-center justify-center gap-2 text-red-500 h-full relative">'
|
|
f'<div class="text-stone-400 text-xs">No image</div>'
|
|
f'<ul class="flex flex-row gap-1">{labels_list}</ul>'
|
|
f'<div class="text-stone-900 text-center line-clamp-3 break-words [overflow-wrap:anywhere]">{escape(brand)}</div>'
|
|
f'</div></div>'
|
|
)
|
|
|
|
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_parts = []
|
|
for s in stickers:
|
|
found = s in selected_stickers
|
|
src = asset_url_fn(f"stickers/{s}.svg")
|
|
sticker_parts.append(
|
|
f'<img src="{src}" alt="{escape(s)}" class="w-6 h-6'
|
|
f'{" ring-2 ring-emerald-500 rounded" if found else ""}" />'
|
|
)
|
|
stickers_html = '<div class="flex flex-row justify-center gap-2 p-2">' + "".join(sticker_parts) + "</div>"
|
|
|
|
# Title with search highlight
|
|
title = p.get("title", "")
|
|
if search and search.lower() in title.lower():
|
|
idx = title.lower().index(search.lower())
|
|
highlighted = f"{escape(title[:idx])}<mark>{escape(title[idx:idx+len(search)])}</mark>{escape(title[idx+len(search):])}"
|
|
else:
|
|
highlighted = escape(title)
|
|
|
|
return (
|
|
f'<div class="flex flex-col rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden relative">'
|
|
f'{like_html}'
|
|
f'<a href="{item_href}" hx-get="{item_href}" hx-target="#main-panel"'
|
|
f' hx-select="{hx_select}" hx-swap="outerHTML" hx-push-url="true">'
|
|
f'{img_html}{price_html}</a>'
|
|
f'<div class="flex justify-center">{add_html}</div>'
|
|
f'<a href="{item_href}" hx-get="{item_href}" hx-target="#main-panel"'
|
|
f' hx-select="{hx_select}" hx-swap="outerHTML" hx-push-url="true">'
|
|
f'{stickers_html}'
|
|
f'<div class="text-sm font-medium text-stone-800 text-center line-clamp-3 break-words [overflow-wrap:anywhere]">{highlighted}</div>'
|
|
f'</a></div>'
|
|
)
|
|
|
|
|
|
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 (
|
|
f'<div class="absolute top-2 right-2 z-10 text-6xl md:text-xl">'
|
|
f'<form id="like-{slug}" action="{action}" method="post"'
|
|
f' hx-post="{action}" hx-target="#like-{slug}" hx-swap="outerHTML">'
|
|
f'<input type="hidden" name="csrf_token" value="{csrf}">'
|
|
f'<button type="submit" class="cursor-pointer">'
|
|
f'<i class="{icon_cls}" aria-hidden="true"></i></button></form></div>'
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Product cards (pagination fragment)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
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(
|
|
f'<div id="sentinel-{page}-m"'
|
|
f' class="block md:hidden h-[60vh] opacity-0 pointer-events-none js-mobile-sentinel"'
|
|
f' hx-get="{next_url}" hx-trigger="intersect once delay:250ms, sentinelmobile:retry"'
|
|
f' hx-swap="outerHTML"'
|
|
f' _="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\non resize from window\n if window.matchMedia(\'(min-width: 768px)\').matches then set @hx-disabled to \'\' else remove @hx-disabled end\non 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\ndef 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\nend\non htmx:sendError call backoff()\non htmx:responseError call backoff()\non htmx:timeout call backoff()"'
|
|
f' role="status" aria-live="polite" aria-hidden="true">'
|
|
f'<div class="js-loading text-center text-xs text-stone-400">loading...</div>'
|
|
f'<div class="js-neterr hidden text-center text-xs text-stone-400">Retrying...</div></div>'
|
|
)
|
|
|
|
# Desktop sentinel
|
|
parts.append(
|
|
f'<div id="sentinel-{page}-d"'
|
|
f' class="hidden md:block h-4 opacity-0 pointer-events-none"'
|
|
f' hx-get="{next_url}" hx-trigger="intersect once delay:250ms, sentinel:retry"'
|
|
f' hx-swap="outerHTML"'
|
|
f' _="init\n if not me.dataset.retryMs then set me.dataset.retryMs to 1000 end\non 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\ndef 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\nend\non htmx:sendError call backoff()\non htmx:responseError call backoff()\non htmx:timeout call backoff()"'
|
|
f' role="status" aria-live="polite" aria-hidden="true">'
|
|
f'<div class="js-loading text-center text-xs text-stone-400">loading...</div>'
|
|
f'<div class="js-neterr hidden text-center text-xs text-stone-400">Retrying...</div></div>'
|
|
)
|
|
else:
|
|
parts.append('<div class="col-span-full mt-4 text-center text-xs text-stone-400">End of results</div>')
|
|
|
|
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", "")
|
|
search = ctx.get("search", "")
|
|
search_count = ctx.get("search_count", "")
|
|
current_local_href = ctx.get("current_local_href", "/")
|
|
hx_select = ctx.get("hx_select", "#main-panel")
|
|
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", "")
|
|
asset_url_fn = ctx.get("asset_url")
|
|
|
|
# Search
|
|
search_html = search_desktop_html(ctx)
|
|
|
|
# Category summary + sort + like + labels + stickers
|
|
parts = [search_html]
|
|
parts.append(f'<div id="category-summary-desktop" hxx-swap-oob="outerHTML">')
|
|
parts.append(f'<div class="mb-4"><div class="text-2xl uppercase tracking-wide text-black-500">{escape(category_label)}</div></div>')
|
|
|
|
# Sort stickers
|
|
if sort_options:
|
|
parts.append(_sort_stickers_html(sort_options, sort, ctx))
|
|
|
|
# Like + labels row
|
|
parts.append('<nav aria-label="like" class="flex flex-row justify-center w-full p-0 m-0 border bg-white shadow-sm rounded-xl gap-1">')
|
|
parts.append(_like_filter_html(liked, liked_count, ctx))
|
|
if labels:
|
|
parts.append(_labels_filter_html(labels, selected_labels, ctx, prefix="nav-labels"))
|
|
parts.append("</nav>")
|
|
|
|
# Stickers
|
|
if stickers:
|
|
parts.append(_stickers_filter_html(stickers, selected_stickers, ctx))
|
|
|
|
# Subcategory selector
|
|
if subs_local and top_local_href:
|
|
parts.append(_subcategory_selector_html(subs_local, top_local_href, sub_slug, ctx))
|
|
|
|
parts.append("</div>")
|
|
|
|
# Brand filter
|
|
parts.append(f'<div id="filter-summary-desktop" hxx-swap-oob="outerHTML">')
|
|
if brands:
|
|
parts.append(_brand_filter_html(brands, selected_brands, ctx))
|
|
parts.append("</div>")
|
|
|
|
return "".join(parts)
|
|
|
|
|
|
def _mobile_filter_summary_html(ctx: dict) -> str:
|
|
"""Build mobile filter summary (collapsible bar showing active filters)."""
|
|
# Simplified version — just the filter details/summary wrapper
|
|
asset_url_fn = ctx.get("asset_url")
|
|
search = ctx.get("search", "")
|
|
search_count = ctx.get("search_count", "")
|
|
current_local_href = ctx.get("current_local_href", "/")
|
|
hx_select = ctx.get("hx_select", "#main-panel")
|
|
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
|
|
chip_parts = ['<div class="flex flex-row items-start gap-2">']
|
|
|
|
if sort and sort_options:
|
|
for k, l, i in sort_options:
|
|
if k == sort and callable(asset_url_fn):
|
|
chip_parts.append(f'<ul class="relative inline-flex items-center justify-center gap-2">'
|
|
f'<li role="listitem"><img src="{asset_url_fn(i)}" alt="{escape(l)}" class="w-10 h-10"/></li></ul>')
|
|
if liked:
|
|
chip_parts.append('<div class="flex flex-col items-center gap-1 pb-1">'
|
|
f'<i aria-hidden="true" class="fa-solid fa-heart text-red-500 text-[40px] leading-none"></i>')
|
|
if liked_count is not None:
|
|
cls = "text-[10px] text-stone-500" if liked_count != 0 else "text-md text-red-500 font-bold"
|
|
chip_parts.append(f'<div class="{cls} mt-1 leading-none tabular-nums">{liked_count}</div>')
|
|
chip_parts.append("</div>")
|
|
|
|
# Selected labels
|
|
if selected_labels:
|
|
chip_parts.append('<ul class="relative inline-flex items-center justify-center gap-2">')
|
|
for sl in selected_labels:
|
|
for lb in labels:
|
|
if lb.get("name") == sl and callable(asset_url_fn):
|
|
chip_parts.append(f'<li role="listitem" class="flex flex-col items-center gap-1 pb-1">'
|
|
f'<img src="{asset_url_fn("nav-labels/" + sl + ".svg")}" alt="{escape(sl)}" class="w-10 h-10"/>')
|
|
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"
|
|
chip_parts.append(f'<div class="{cls} mt-1 leading-none tabular-nums">{lb["count"]}</div>')
|
|
chip_parts.append("</li>")
|
|
chip_parts.append("</ul>")
|
|
|
|
# Selected stickers
|
|
if selected_stickers:
|
|
chip_parts.append('<ul class="relative inline-flex items-center justify-center gap-2">')
|
|
for ss in selected_stickers:
|
|
for st in stickers:
|
|
if st.get("name") == ss and callable(asset_url_fn):
|
|
chip_parts.append(f'<li role="listitem" class="flex flex-col items-center gap-1 pb-1">'
|
|
f'<img src="{asset_url_fn("stickers/" + ss + ".svg")}" alt="{escape(ss)}" class="w-10 h-10"/>')
|
|
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"
|
|
chip_parts.append(f'<div class="{cls} mt-1 leading-none tabular-nums">{st["count"]}</div>')
|
|
chip_parts.append("</li>")
|
|
chip_parts.append("</ul>")
|
|
|
|
# Selected brands
|
|
if selected_brands:
|
|
chip_parts.append('<ul>')
|
|
for b in selected_brands:
|
|
count = 0
|
|
for br in brands:
|
|
if br.get("name") == b:
|
|
count = br.get("count", 0)
|
|
if count:
|
|
chip_parts.append(f'<li role="listitem" class="flex flex-row items-center gap-2"><div class="text-md">{escape(b)}</div><div class="text-md">{count}</div></li>')
|
|
else:
|
|
chip_parts.append(f'<li role="listitem" class="flex flex-row items-center gap-2"><div class="text-md text-red-500">{escape(b)}</div><div class="text-xl text-red-500">0</div></li>')
|
|
chip_parts.append("</ul>")
|
|
|
|
chip_parts.append("</div>")
|
|
chips_html = "".join(chip_parts)
|
|
|
|
# Full mobile filter details
|
|
from shared.utils import route_prefix
|
|
prefix = route_prefix()
|
|
mobile_filter = _mobile_filter_content_html(ctx, prefix)
|
|
|
|
return (
|
|
f'<details class="md:hidden group" id="/filter">'
|
|
f'<summary class="cursor-pointer select-none" id="filter-summary-mobile">'
|
|
f'{search_bar}'
|
|
f'<div class="col-span-12 min-w-0 grid grid-cols-1 gap-1 bg-gray-100 px-2" role="list">'
|
|
f'{chips_html}'
|
|
f'</div></summary>'
|
|
f'<div id="filter-details-mobile" style="display:contents">'
|
|
f'{mobile_filter}'
|
|
f'</div></details>'
|
|
)
|
|
|
|
|
|
def _mobile_filter_content_html(ctx: dict, prefix: str) -> str:
|
|
"""Build the expanded mobile filter panel contents."""
|
|
from shared.utils import route_prefix
|
|
|
|
search = ctx.get("search", "")
|
|
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", [])
|
|
asset_url_fn = ctx.get("asset_url")
|
|
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(
|
|
f'<div class="flex flex-row justify-center">'
|
|
f'<a href="{clear_url}" hx-get="{clear_url}" hx-target="#main-panel"'
|
|
f' hx-select="{hx_select}" hx-swap="outerHTML" hx-push-url="true"'
|
|
f' role="button" title="clear filters" aria-label="clear filters"'
|
|
f' class="flex flex-col items-center justify-start p-1 rounded bg-stone-200 text-black cursor-pointer">'
|
|
f'<span class="mt-1 leading-none tabular-nums">clear filters</span></a></div>'
|
|
)
|
|
|
|
# Like + labels row
|
|
parts.append('<div class="flex flex-row gap-2 justify-center items-center">')
|
|
parts.append(_like_filter_html(liked, liked_count, ctx, mobile=True))
|
|
if labels:
|
|
parts.append(_labels_filter_html(labels, selected_labels, ctx, prefix="nav-labels", mobile=True))
|
|
parts.append("</div>")
|
|
|
|
# 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()
|
|
|
|
parts = ['<div class="flex flex-row gap-2 justify-center p-1">']
|
|
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
|
|
parts.append(
|
|
f'<a href="{href}" hx-get="{href}" hx-target="#main-panel"'
|
|
f' hx-select="{hx_select}" hx-swap="outerHTML" hx-push-url="true"'
|
|
f' class="flex flex-col items-center gap-1 p-1 cursor-pointer{ring}">'
|
|
f'<img src="{src}" alt="{escape(label)}" class="w-10 h-10"/>'
|
|
f'<span class="text-xs">{escape(label)}</span></a>'
|
|
)
|
|
parts.append("</div>")
|
|
return "".join(parts)
|
|
|
|
|
|
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 (
|
|
f'<a href="{href}" hx-get="{href}" hx-target="#main-panel"'
|
|
f' hx-select="{hx_select}" hx-swap="outerHTML" hx-push-url="true"'
|
|
f' class="flex flex-col items-center gap-1 p-1 cursor-pointer">'
|
|
f'<i aria-hidden="true" class="{icon_cls} {size} leading-none"></i></a>'
|
|
)
|
|
|
|
|
|
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()
|
|
|
|
parts = []
|
|
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 ""
|
|
parts.append(
|
|
f'<a href="{href}" hx-get="{href}" hx-target="#main-panel"'
|
|
f' hx-select="{hx_select}" hx-swap="outerHTML" hx-push-url="true"'
|
|
f' class="flex flex-col items-center gap-1 p-1 cursor-pointer{ring}">'
|
|
f'<img src="{src}" alt="{escape(name)}" class="w-10 h-10"/></a>'
|
|
)
|
|
return "".join(parts)
|
|
|
|
|
|
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()
|
|
|
|
parts = ['<div class="flex flex-wrap gap-2 justify-center p-1">']
|
|
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"
|
|
parts.append(
|
|
f'<a href="{href}" hx-get="{href}" hx-target="#main-panel"'
|
|
f' hx-select="{hx_select}" hx-swap="outerHTML" hx-push-url="true"'
|
|
f' class="flex flex-col items-center gap-1 p-1 cursor-pointer{ring}">'
|
|
f'<img src="{src}" alt="{escape(name)}" class="w-6 h-6"/>'
|
|
f'<span class="{cls}">{count}</span></a>'
|
|
)
|
|
parts.append("</div>")
|
|
return "".join(parts)
|
|
|
|
|
|
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()
|
|
|
|
parts = ['<div class="space-y-1 p-2">']
|
|
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"
|
|
parts.append(
|
|
f'<a href="{href}" hx-get="{href}" hx-target="#main-panel"'
|
|
f' hx-select="{hx_select}" hx-swap="outerHTML" hx-push-url="true"'
|
|
f' class="flex flex-row items-center gap-2 px-2 py-1 rounded hover:bg-stone-100{bg}">'
|
|
f'<div class="{cls}">{escape(name)}</div>'
|
|
f'<div class="{cls}">{count}</div></a>'
|
|
)
|
|
parts.append("</div>")
|
|
return "".join(parts)
|
|
|
|
|
|
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()
|
|
|
|
parts = ['<div class="mt-4 space-y-1">']
|
|
# "All" link
|
|
parts.append(
|
|
f'<a href="{rp}{top_href}" hx-get="{rp}{top_href}" hx-target="#main-panel"'
|
|
f' hx-select="{hx_select}" hx-swap="outerHTML" hx-push-url="true"'
|
|
f' class="block px-2 py-1 rounded hover:bg-stone-100'
|
|
f'{" bg-stone-200 font-medium" if not current_sub else ""}">All</a>'
|
|
)
|
|
for sub in subs:
|
|
slug = sub.get("slug", "")
|
|
name = sub.get("name", "")
|
|
href = sub.get("href", "")
|
|
active = (slug == current_sub)
|
|
parts.append(
|
|
f'<a href="{rp}{href}" hx-get="{rp}{href}" hx-target="#main-panel"'
|
|
f' hx-select="{hx_select}" hx-swap="outerHTML" hx-push-url="true"'
|
|
f' class="block px-2 py-1 rounded hover:bg-stone-100'
|
|
f'{" bg-stone-200 font-medium" if active else ""}">{escape(name)}</a>'
|
|
)
|
|
parts.append("</div>")
|
|
return "".join(parts)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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 = "".join(
|
|
f'<img src="{asset_url_fn("labels/" + l + ".svg")}" alt=""'
|
|
f' class="pointer-events-none absolute inset-0 w-full h-full object-contain object-top"/>'
|
|
for l in labels
|
|
) if callable(asset_url_fn) else ""
|
|
|
|
gallery_html = (
|
|
f'<div class="relative rounded-xl overflow-hidden bg-stone-100">'
|
|
f'{like_html}'
|
|
f'<figure class="inline-block"><div class="relative w-full aspect-square">'
|
|
f'<img data-main-img src="{images[0]}" alt="{escape(d.get("title", ""))}"'
|
|
f' class="w-full h-full object-contain object-top" loading="eager" decoding="async"/>'
|
|
f'{labels_overlay}</div>'
|
|
f'<figcaption class="mt-2 text-sm text-stone-600 text-center">{escape(brand)}</figcaption></figure>'
|
|
)
|
|
|
|
# Prev/next buttons
|
|
if len(images) > 1:
|
|
gallery_html += (
|
|
'<button type="button" data-prev class="absolute left-2 top-1/2 -translate-y-1/2 z-10 grid place-items-center w-12 h-12 md:w-14 md:h-14 rounded-full bg-white/90 hover:bg-white shadow-lg text-3xl md:text-4xl" title="Previous">‹</button>'
|
|
'<button type="button" data-next class="absolute right-2 top-1/2 -translate-y-1/2 z-10 grid place-items-center w-12 h-12 md:w-14 md:h-14 rounded-full bg-white/90 hover:bg-white shadow-lg text-3xl md:text-4xl" title="Next">›</button>'
|
|
)
|
|
|
|
gallery_html += "</div>"
|
|
|
|
# Thumbnails
|
|
if len(images) > 1:
|
|
thumbs = "".join(
|
|
f'<button type="button" data-thumb class="shrink-0 rounded-lg overflow-hidden bg-stone-100 hover:opacity-90 ring-offset-2" title="Image {i+1}">'
|
|
f'<img src="{u}" class="h-16 w-16 object-contain" alt="thumb {i+1}" loading="lazy" decoding="async"></button>'
|
|
f'<span data-image-src="{u}" class="hidden"></span>'
|
|
for i, u in enumerate(images)
|
|
)
|
|
gallery_html += f'<div class="flex flex-row justify-center"><div class="mt-3 flex gap-2 overflow-x-auto no-scrollbar">{thumbs}</div></div>'
|
|
else:
|
|
like_html = ""
|
|
if user:
|
|
like_html = _like_button_html(slug, liked_by_current_user, csrf, ctx)
|
|
gallery_html = (
|
|
f'<div class="relative aspect-square bg-stone-100 rounded-xl flex items-center justify-center text-stone-400">'
|
|
f'{like_html}No image</div>'
|
|
)
|
|
|
|
# Stickers below gallery
|
|
stickers_html = ""
|
|
if stickers and callable(asset_url_fn):
|
|
sticker_parts = "".join(
|
|
f'<img src="{asset_url_fn("stickers/" + s + ".svg")}" alt="{escape(s)}" class="w-10 h-10"/>'
|
|
for s in stickers
|
|
)
|
|
stickers_html = f'<div class="p-2 flex flex-row justify-center gap-2">{sticker_parts}</div>'
|
|
|
|
# Right column: prices, description, sections
|
|
pr = _set_prices(d)
|
|
details_parts = ['<div class="md:col-span-3">']
|
|
|
|
# Unit price / case size extras
|
|
extras = []
|
|
ppu = d.get("price_per_unit") or d.get("price_per_unit_raw")
|
|
if ppu:
|
|
extras.append(f'<div>Unit price: {_price_str(d.get("price_per_unit"), d.get("price_per_unit_raw"), d.get("price_per_unit_currency"))}</div>')
|
|
if d.get("case_size_raw"):
|
|
extras.append(f'<div>Case size: {d["case_size_raw"]}</div>')
|
|
if extras:
|
|
details_parts.append('<div class="mt-2 space-y-1 text-sm text-stone-600">' + "".join(extras) + "</div>")
|
|
|
|
# Description
|
|
desc_short = d.get("description_short")
|
|
desc_html = d.get("description_html")
|
|
if desc_short or desc_html:
|
|
details_parts.append('<div class="mt-4 text-stone-800 space-y-3">')
|
|
if desc_short:
|
|
details_parts.append(f'<p class="leading-relaxed text-lg">{escape(desc_short)}</p>')
|
|
if desc_html:
|
|
details_parts.append(f'<div class="max-w-none text-sm leading-relaxed">{desc_html}</div>')
|
|
details_parts.append("</div>")
|
|
|
|
# Sections (expandable)
|
|
sections = d.get("sections", [])
|
|
if sections:
|
|
details_parts.append('<div class="mt-8 space-y-3">')
|
|
for sec in sections:
|
|
details_parts.append(
|
|
f'<details class="group rounded-xl border bg-white shadow-sm open:shadow p-0">'
|
|
f'<summary class="cursor-pointer select-none px-4 py-3 flex items-center justify-between">'
|
|
f'<span class="font-medium">{escape(sec.get("title", ""))}</span>'
|
|
f'<span class="ml-2 text-xl transition-transform group-open:rotate-180">⌄</span></summary>'
|
|
f'<div class="px-4 pb-4 max-w-none text-sm leading-relaxed">{sec.get("html", "")}</div></details>'
|
|
)
|
|
details_parts.append("</div>")
|
|
|
|
details_parts.append("</div>")
|
|
|
|
return (
|
|
f'<div class="mt-3 grid grid-cols-1 md:grid-cols-5 gap-6" data-gallery-root>'
|
|
f'<div class="md:col-span-2">{gallery_html}{stickers_html}</div>'
|
|
f'{"".join(details_parts)}</div><div class="pb-8"></div>'
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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"):
|
|
# Strip HTML tags (simple approach)
|
|
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 = [f"<title>{escape(title)}</title>"]
|
|
parts.append(f'<meta name="description" content="{escape(description)}">')
|
|
if canonical:
|
|
parts.append(f'<link rel="canonical" href="{canonical}">')
|
|
|
|
# OpenGraph
|
|
site_title = ctx.get("base_title", "")
|
|
parts.append(f'<meta property="og:site_name" content="{escape(site_title)}">')
|
|
parts.append('<meta property="og:type" content="product">')
|
|
parts.append(f'<meta property="og:title" content="{escape(title)}">')
|
|
parts.append(f'<meta property="og:description" content="{escape(description)}">')
|
|
if canonical:
|
|
parts.append(f'<meta property="og:url" content="{canonical}">')
|
|
if image_url:
|
|
parts.append(f'<meta property="og:image" content="{image_url}">')
|
|
if price and price_currency:
|
|
parts.append(f'<meta property="product:price:amount" content="{price:.2f}">')
|
|
parts.append(f'<meta property="product:price:currency" content="{price_currency}">')
|
|
if brand:
|
|
parts.append(f'<meta property="product:brand" content="{escape(brand)}">')
|
|
|
|
# Twitter
|
|
card_type = "summary_large_image" if image_url else "summary"
|
|
parts.append(f'<meta name="twitter:card" content="{card_type}">')
|
|
parts.append(f'<meta name="twitter:title" content="{escape(title)}">')
|
|
parts.append(f'<meta name="twitter:description" content="{escape(description)}">')
|
|
if image_url:
|
|
parts.append(f'<meta name="twitter:image" content="{image_url}">')
|
|
|
|
# JSON-LD
|
|
jsonld = {
|
|
"@context": "https://schema.org",
|
|
"@type": "Product",
|
|
"name": title,
|
|
"image": image_url,
|
|
"description": description,
|
|
"sku": sku,
|
|
"url": canonical,
|
|
}
|
|
if brand:
|
|
jsonld["brand"] = {"@type": "Brand", "name": brand}
|
|
if price and price_currency:
|
|
jsonld["offers"] = {
|
|
"@type": "Offer",
|
|
"price": price,
|
|
"priceCurrency": price_currency,
|
|
"url": canonical,
|
|
"availability": "https://schema.org/InStock",
|
|
}
|
|
parts.append(f'<script type="application/ld+json">{json.dumps(jsonld)}</script>')
|
|
|
|
return "\n".join(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 ""
|
|
|
|
parts = ['<article class="rounded-xl bg-white shadow-sm border border-stone-200 p-5 flex flex-col justify-between hover:border-stone-400 transition-colors">']
|
|
parts.append("<div>")
|
|
if market_href:
|
|
parts.append(f'<a href="{market_href}" class="hover:text-emerald-700"><h2 class="text-lg font-semibold text-stone-900">{escape(name)}</h2></a>')
|
|
else:
|
|
parts.append(f'<h2 class="text-lg font-semibold text-stone-900">{escape(name)}</h2>')
|
|
if description:
|
|
parts.append(f'<p class="text-sm text-stone-600 mt-1 line-clamp-2">{escape(description)}</p>')
|
|
parts.append("</div>")
|
|
|
|
if show_page_badge and p_title:
|
|
badge_href = market_url(f"/{p_slug}/")
|
|
parts.append(
|
|
f'<div class="flex flex-wrap items-center gap-1.5 mt-3">'
|
|
f'<a href="{badge_href}" class="inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 hover:bg-amber-200">'
|
|
f'{escape(p_title)}</a></div>'
|
|
)
|
|
|
|
parts.append("</article>")
|
|
return "".join(parts)
|
|
|
|
|
|
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(
|
|
f'<div id="sentinel-{page}" class="h-4 opacity-0 pointer-events-none"'
|
|
f' hx-get="{next_url}" hx-trigger="intersect once delay:250ms"'
|
|
f' hx-swap="outerHTML" role="status" aria-hidden="true">'
|
|
f'<div class="text-center text-xs text-stone-400">loading...</div></div>'
|
|
)
|
|
return "".join(parts)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# OOB header helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _oob_header_html(parent_id: str, child_id: str, row_html: str) -> str:
|
|
"""Wrap a header row in OOB div with child placeholder."""
|
|
return (
|
|
f'<div id="{parent_id}" hx-swap-oob="outerHTML" class="w-full">'
|
|
f'<div class="w-full">{row_html}'
|
|
f'<div id="{child_id}"></div></div></div>'
|
|
)
|
|
|
|
|
|
# ===========================================================================
|
|
# PUBLIC API
|
|
# ===========================================================================
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# All markets
|
|
# ---------------------------------------------------------------------------
|
|
|
|
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 = f'<div class="max-w-full px-3 py-3 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">{cards}</div>'
|
|
else:
|
|
content = ('<div class="px-3 py-12 text-center text-stone-400">'
|
|
'<i class="fa fa-store text-4xl mb-3" aria-hidden="true"></i>'
|
|
'<p class="text-lg">No markets available</p></div>')
|
|
content += '<div class="pb-8"></div>'
|
|
|
|
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 = f'<div class="max-w-full px-3 py-3 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">{cards}</div>'
|
|
else:
|
|
content = ('<div class="px-3 py-12 text-center text-stone-400">'
|
|
'<i class="fa fa-store text-4xl mb-3" aria-hidden="true"></i>'
|
|
'<p class="text-lg">No markets available</p></div>')
|
|
content += '<div class="pb-8"></div>'
|
|
|
|
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 = f'<div class="max-w-full px-3 py-3 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">{cards}</div>'
|
|
else:
|
|
content = ('<div class="px-3 py-12 text-center text-stone-400">'
|
|
'<i class="fa fa-store text-4xl mb-3" aria-hidden="true"></i>'
|
|
'<p class="text-lg">No markets for this page</p></div>')
|
|
content += '<div class="pb-8"></div>'
|
|
|
|
hdr = root_header_html(ctx)
|
|
hdr += sexp(
|
|
'(div :id "root-header-child" :class "w-full" (raw! ph))',
|
|
ph=_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 = f'<div class="max-w-full px-3 py-3 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">{cards}</div>'
|
|
else:
|
|
content = ('<div class="px-3 py-12 text-center text-stone-400">'
|
|
'<i class="fa fa-store text-4xl mb-3" aria-hidden="true"></i>'
|
|
'<p class="text-lg">No markets for this page</p></div>')
|
|
content += '<div class="pb-8"></div>'
|
|
|
|
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)
|
|
hdr += sexp(
|
|
'(div :id "root-header-child" :class "w-full" (raw! ph (raw! mh)))',
|
|
ph=_post_header_html(ctx),
|
|
mh=_market_header_html(ctx),
|
|
)
|
|
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)."""
|
|
parts = ['<article class="relative w-full">']
|
|
if post.get("custom_excerpt"):
|
|
parts.append(f'<div class="w-full text-center italic text-3xl p-2">{post["custom_excerpt"]}</div>')
|
|
if post.get("feature_image"):
|
|
parts.append(
|
|
f'<div class="mb-3 flex justify-center">'
|
|
f'<img src="{post["feature_image"]}" alt="" class="rounded-lg w-full md:w-3/4 object-cover"></div>'
|
|
)
|
|
if post.get("html"):
|
|
parts.append(f'<div class="blog-content p-2">{post["html"]}</div>')
|
|
parts.append('</article><div class="pb-8"></div>')
|
|
return "".join(parts)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Browse page
|
|
# ---------------------------------------------------------------------------
|
|
|
|
async def render_browse_page(ctx: dict) -> str:
|
|
"""Full page: product browse with filters."""
|
|
cards_html = _product_cards_html(ctx)
|
|
content = f'<div class="grid grid-cols-1 sm:grid-cols-3 md:grid-cols-6 gap-3">{cards_html}</div><div class="pb-8"></div>'
|
|
|
|
hdr = root_header_html(ctx)
|
|
hdr += sexp(
|
|
'(div :id "root-header-child" :class "w-full" (raw! ph (raw! mh)))',
|
|
ph=_post_header_html(ctx),
|
|
mh=_market_header_html(ctx),
|
|
)
|
|
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 = f'<div class="grid grid-cols-1 sm:grid-cols-3 md:grid-cols-6 gap-3">{cards_html}</div><div class="pb-8"></div>'
|
|
|
|
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)
|
|
hdr += sexp(
|
|
'(div :id "root-header-child" :class "w-full" (raw! ph (raw! mh (raw! prh))))',
|
|
ph=_post_header_html(ctx),
|
|
mh=_market_header_html(ctx),
|
|
prh=_product_header_html(ctx, d),
|
|
)
|
|
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)
|
|
hdr += sexp(
|
|
'(div :id "root-header-child" :class "w-full" (raw! ph (raw! mh (raw! prh (raw! pah)))))',
|
|
ph=_post_header_html(ctx),
|
|
mh=_market_header_html(ctx),
|
|
prh=_product_header_html(ctx, d),
|
|
pah=_product_admin_header_html(ctx, d),
|
|
)
|
|
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 sexp(
|
|
'(~menu-row :id "product-admin-row" :level 4'
|
|
' :link-href lh :link-label "admin!!" :icon "fa fa-cog"'
|
|
' :child-id "product-admin-header-child" :oob oob)',
|
|
lh=link_href,
|
|
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)
|
|
hdr += sexp(
|
|
'(div :id "root-header-child" :class "w-full" (raw! ph (raw! mh (raw! mah))))',
|
|
ph=_post_header_html(ctx),
|
|
mh=_market_header_html(ctx),
|
|
mah=_market_admin_header_html(ctx),
|
|
)
|
|
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 sexp(
|
|
'(~menu-row :id "market-admin-row" :level 3'
|
|
' :link-href lh :link-label "admin" :icon "fa fa-cog"'
|
|
' :child-id "market-admin-header-child" :oob oob)',
|
|
lh=link_href,
|
|
oob=oob,
|
|
)
|