diff --git a/market/sexp/sexp_components.py b/market/sexp/sexp_components.py
index a60eb8c..e174983 100644
--- a/market/sexp/sexp_components.py
+++ b/market/sexp/sexp_components.py
@@ -51,15 +51,17 @@ def _card_price_html(p: dict) -> str:
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 = ['
']
+ inner = ""
if pr["sp_val"]:
- parts.append(f'
{sp_str}
')
+ inner += sexp('(div :class "text-lg font-semibold text-emerald-700" sp)', sp=sp_str)
if pr["rp_val"]:
- parts.append(f'
{rp_str}
')
+ inner += sexp('(div :class "text-sm line-through text-stone-500" rp)', rp=rp_str)
elif pr["rp_val"]:
- parts.append(f'
{rp_str}
')
- parts.append("
")
- return "".join(parts)
+ inner += sexp('(div :class "mt-1 text-lg font-semibold" rp)', rp=rp_str)
+ return sexp(
+ '(div :class "mt-1 flex items-baseline gap-2 justify-center" (raw! inner))',
+ inner=inner,
+ )
# ---------------------------------------------------------------------------
@@ -73,31 +75,31 @@ def _post_header_html(ctx: dict, *, oob: bool = False) -> str:
title = (post.get("title") or "")[:160]
feature_image = post.get("feature_image")
- label_parts = []
+ label_html = ""
if feature_image:
- label_parts.append(
- f' '
+ label_html += sexp(
+ '(img :src fi :class "h-8 w-8 rounded-full object-cover border border-stone-300 flex-shrink-0")',
+ fi=feature_image,
)
- label_parts.append(f"{escape(title)} ")
- label_html = "".join(label_parts)
+ label_html += sexp('(span t)', t=title)
- nav_parts = []
+ nav_html = ""
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''
- f' '
- f'{page_cart_count} '
+ nav_html += sexp(
+ '(a :href ch :class "relative inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-full'
+ ' border border-emerald-300 bg-emerald-50 text-emerald-800 hover:bg-emerald-100 transition"'
+ ' (i :class "fa fa-shopping-cart" :aria-hidden "true")'
+ ' (span pcc))',
+ ch=cart_href, pcc=str(page_cart_count),
)
# Container nav
container_nav = ctx.get("container_nav_html", "")
if container_nav:
- nav_parts.append(container_nav)
+ nav_html += container_nav
- nav_html = "".join(nav_parts)
link_href = call_url(ctx, "blog_url", f"/{slug}/")
return sexp(
@@ -120,16 +122,14 @@ def _market_header_html(ctx: dict, *, oob: bool = False) -> str:
sub_slug = ctx.get("sub_slug", "")
hx_select_search = ctx.get("hx_select_search", "#main-panel")
- label_parts = [
- '',
- f'
{escape(market_title)}
',
- '
',
- f"
{escape(top_slug or '')}
",
- ]
- if sub_slug:
- label_parts.append(f"
{escape(sub_slug)}
")
- label_parts.append("
")
- label_html = "".join(label_parts)
+ sub_div = sexp('(div sub)', sub=sub_slug) if sub_slug else ""
+ label_html = sexp(
+ '(div :class "font-bold text-xl flex-shrink-0 flex gap-2 items-center"'
+ ' (div (i :class "fa fa-shop") " " mt)'
+ ' (div :class "flex flex-col md:flex-row md:gap-2 text-xs"'
+ ' (div ts) (raw! sd)))',
+ mt=market_title, ts=top_slug or "", sd=sub_div,
+ )
link_href = url_for("market.browse.home")
@@ -160,42 +160,47 @@ def _desktop_category_nav_html(ctx: dict, categories: dict, qs: str,
select_colours = ctx.get("select_colours", "")
rights = ctx.get("rights", {})
- parts = ['']
-
all_href = prefix + url_for("market.browse.browse_all") + qs
all_active = (category_label == "All Products")
- parts.append(
- f''
+ links = sexp(
+ '(div :class "relative nav-group"'
+ ' (a :href ah :hx-get ah :hx-target "#main-panel"'
+ ' :hx-select hs :hx-swap "outerHTML" :hx-push-url "true"'
+ ' :aria-selected (if aa "true" "false")'
+ ' :class (str "block px-2 py-1 rounded text-center whitespace-normal break-words leading-snug bg-stone-200 text-black " sc)'
+ ' "All"))',
+ ah=all_href, hs=hx_select, aa=all_active, sc=select_colours,
)
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''
+ links += sexp(
+ '(div :class "relative nav-group"'
+ ' (a :href ch :hx-get ch :hx-target "#main-panel"'
+ ' :hx-select hs :hx-swap "outerHTML" :hx-push-url "true"'
+ ' :aria-selected (if ca "true" "false")'
+ ' :class (str "block px-2 py-1 rounded text-center whitespace-normal break-words leading-snug bg-stone-200 text-black " sc)'
+ ' cn))',
+ ch=cat_href, hs=hx_select, ca=cat_active, sc=select_colours, cn=cat,
)
- # Admin link
+ admin_link = ""
if rights and rights.get("admin"):
admin_href = prefix + url_for("market.admin.admin")
- parts.append(
- f''
- f' '
+ admin_link = sexp(
+ '(a :href ah :hx-get ah :hx-target "#main-panel"'
+ ' :hx-select hs :hx-swap "outerHTML" :hx-push-url "true"'
+ ' :class "px-2 py-1 text-stone-500 hover:text-stone-700"'
+ ' (i :class "fa fa-cog" :aria-hidden "true"))',
+ ah=admin_href, hs=hx_select,
)
- parts.append(" ")
- return "".join(parts)
+ return sexp(
+ '(nav :class "hidden md:flex gap-4 text-sm ml-2 w-full justify-end items-center"'
+ ' (raw! links) (raw! al))',
+ links=links, al=admin_link,
+ )
def _product_header_html(ctx: dict, d: dict, *, oob: bool = False) -> str:
@@ -208,7 +213,10 @@ def _product_header_html(ctx: dict, d: dict, *, oob: bool = False) -> str:
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'{escape(title)}
'
+ label_html = sexp(
+ '(<> (i :class "fa fa-shopping-bag" :aria-hidden "true") (div t))',
+ t=title,
+ )
# Prices in nav area
pr = _set_prices(d)
@@ -219,11 +227,12 @@ def _product_header_html(ctx: dict, d: dict, *, oob: bool = False) -> str:
admin_html = ""
if rights and rights.get("admin"):
admin_href = url_for("market.browse.product.admin", product_slug=slug)
- admin_html = (
- f''
- f' '
+ admin_html = sexp(
+ '(a :href ah :hx-get ah :hx-target "#main-panel"'
+ ' :hx-select hs :hx-swap "outerHTML" :hx-push-url "true"'
+ ' :class "px-2 py-1 text-stone-500 hover:text-stone-700"'
+ ' (i :class "fa fa-cog" :aria-hidden "true"))',
+ ah=admin_href, hs=hx_select_search,
)
nav_html = prices_nav + admin_html
@@ -251,18 +260,25 @@ def _prices_header_html(d: dict, pr: dict, cart: list, slug: str, ctx: dict) ->
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 = ['']
- parts.append(add_html)
-
+ inner = add_html
sp_val, rp_val = pr.get("sp_val"), pr.get("rp_val")
if sp_val:
- parts.append(f'
Special price
')
- parts.append(f'
{_price_str(sp_val, pr["sp_raw"], pr["sp_cur"])}
')
+ inner += sexp('(div :class "text-md font-bold text-emerald-700" "Special price")')
+ inner += sexp(
+ '(div :class "text-xl font-semibold text-emerald-700" ps)',
+ ps=_price_str(sp_val, pr["sp_raw"], pr["sp_cur"]),
+ )
if rp_val:
- parts.append(f'
{_price_str(rp_val, pr["rp_raw"], pr["rp_cur"])}
')
+ inner += sexp(
+ '(div :class "text-base text-md line-through text-stone-500" ps)',
+ ps=_price_str(rp_val, pr["rp_raw"], pr["rp_cur"]),
+ )
elif rp_val:
- parts.append(f'
Our price
')
- parts.append(f'
{_price_str(rp_val, pr["rp_raw"], pr["rp_cur"])}
')
+ inner += sexp('(div :class "hidden md:block text-xl font-bold" "Our price")')
+ inner += sexp(
+ '(div :class "text-xl font-semibold" ps)',
+ ps=_price_str(rp_val, pr["rp_raw"], pr["rp_cur"]),
+ )
# RRP
rrp_raw = d.get("rrp_raw")
@@ -270,42 +286,51 @@ def _prices_header_html(d: dict, pr: dict, cart: list, slug: str, ctx: dict) ->
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'
rrp: {rrp_str}
')
+ inner += sexp(
+ '(div :class "text-base text-stone-400" (span "rrp:") " " (span rs))',
+ rs=rrp_str,
+ )
- parts.append("
")
- return "".join(parts)
+ return sexp(
+ '(div :class "flex flex-row items-center justify-between md:gap-2 md:px-2" (raw! inner))',
+ inner=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 (
- f''
- f'
'
+ return sexp(
+ '(div :id cid'
+ ' (form :action act :method "post" :hx-post act :hx-target "#cart-mini" :hx-swap "outerHTML" :class "rounded flex items-center"'
+ ' (input :type "hidden" :name "csrf_token" :value csrf)'
+ ' (input :type "hidden" :name "count" :value "1")'
+ ' (button :type "submit" :class "relative inline-flex items-center justify-center text-sm font-medium text-stone-500 hover:bg-emerald-50"'
+ ' (span :class "relative inline-flex items-center justify-center"'
+ ' (i :class "fa fa-cart-plus text-4xl" :aria-hidden "true")))))',
+ cid=f"cart-{slug}", act=action, csrf=csrf,
)
cart_href = cart_url_fn("/") if callable(cart_url_fn) else "/"
- return (
- f''
+ return sexp(
+ '(div :id cid'
+ ' (div :class "rounded flex items-center gap-2"'
+ ' (form :action act :method "post" :hx-post act :hx-target "#cart-mini" :hx-swap "outerHTML"'
+ ' (input :type "hidden" :name "csrf_token" :value csrf)'
+ ' (input :type "hidden" :name "count" :value minus)'
+ ' (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" "-"))'
+ ' (a :class "relative inline-flex items-center justify-center text-emerald-700" :href ch'
+ ' (span :class "relative inline-flex items-center justify-center"'
+ ' (i :class "fa-solid fa-shopping-cart text-2xl" :aria-hidden "true")'
+ ' (span :class "absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none"'
+ ' (span :class "flex items-center justify-center bg-black text-white rounded-full w-4 h-4 text-xs font-bold" qty))))'
+ ' (form :action act :method "post" :hx-post act :hx-target "#cart-mini" :hx-swap "outerHTML"'
+ ' (input :type "hidden" :name "csrf_token" :value csrf)'
+ ' (input :type "hidden" :name "count" :value plus)'
+ ' (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" "+"))))',
+ cid=f"cart-{slug}", act=action, csrf=csrf, ch=cart_href,
+ minus=str(quantity - 1), plus=str(quantity + 1), qty=str(quantity),
)
@@ -327,64 +352,90 @@ def _mobile_nav_panel_html(ctx: dict) -> str:
hx_select = ctx.get("hx_select_search", "#main-panel")
select_colours = ctx.get("select_colours", "")
- parts = ['']
-
all_href = prefix + url_for("market.browse.browse_all") + qs
all_active = (category_label == "All Products")
- parts.append(
- f'
'
- f'All
'
+ items = sexp(
+ '(a :role "option" :href ah :hx-get ah :hx-target "#main-panel"'
+ ' :hx-select hs :hx-swap "outerHTML" :hx-push-url "true"'
+ ' :aria-selected (if aa "true" "false")'
+ ' :class (str "block rounded-lg px-3 py-3 text-base hover:bg-stone-50 " sc)'
+ ' (div :class "prose prose-stone max-w-none" "All"))',
+ ah=all_href, hs=hx_select, aa=all_active, sc=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)
- 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'
')
- parts.append(
- f''
- f''
- f'{escape(cat)}
'
- f'{data.get("count", 0)}
'
- f' '
- f' '
+ chevron = sexp(
+ '(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" :clip-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"))',
+ )
+
+ cat_count = data.get("count", 0)
+ summary_html = sexp(
+ '(summary :class (str "flex items-center justify-between cursor-pointer select-none block rounded-lg px-3 py-3 text-base hover:bg-stone-50" bg)'
+ ' (a :href ch :hx-get ch :hx-target "#main-panel"'
+ ' :hx-select hs :hx-swap "outerHTML" :hx-push-url "true"'
+ ' :class (str "font-medium " sc " flex flex-row gap-2")'
+ ' (div cn)'
+ ' (div :aria-label cl cn2))'
+ ' (raw! chev))',
+ bg=bg_cls, ch=cat_href, hs=hx_select, sc=select_colours,
+ cn=cat, cl=f"{cat_count} products", cn2=str(cat_count), chev=chevron,
)
subs = data.get("subs", [])
+ subs_html = ""
if subs:
- parts.append('')
- parts.append('
')
+ 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"))
- parts.append(
- f'
'
- f'{escape(sub.get("html_label") or sub.get("name", ""))}
'
- f'{sub.get("count", 0)}
'
+ sub_label = sub.get("html_label") or sub.get("name", "")
+ sub_count = sub.get("count", 0)
+ sub_links += sexp(
+ '(a :class (str "snap-start px-2 py-3 rounded " sc " flex flex-row gap-2")'
+ ' :aria-selected (if sa "true" "false")'
+ ' :href sh :hx-get sh :hx-target "#main-panel"'
+ ' :hx-select hs :hx-swap "outerHTML" :hx-push-url "true"'
+ ' (div sl)'
+ ' (div :aria-label scl sct))',
+ sc=select_colours, sa=sub_active, sh=sub_href, hs=hx_select,
+ sl=sub_label, scl=f"{sub_count} products", sct=str(sub_count),
)
- parts.append("
")
+ subs_html = sexp(
+ '(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"'
+ ' (div :data-peek-inner "" :class "grid grid-cols-1 gap-1 snap-y snap-mandatory pr-1" :aria-label "Subcategories"'
+ ' (raw! sl))))',
+ sl=sub_links,
+ )
else:
view_href = prefix + url_for("market.browse.browse_top", top_slug=cat_slug) + qs
- parts.append(
- f''
+ subs_html = sexp(
+ '(div :class "pb-3 pl-2"'
+ ' (a :class "px-2 py-1 rounded hover:bg-stone-100 block"'
+ ' :href vh :hx-get vh :hx-target "#main-panel"'
+ ' :hx-select hs :hx-swap "outerHTML" :hx-push-url "true"'
+ ' "View all"))',
+ vh=view_href, hs=hx_select,
)
- parts.append(" ")
- parts.append("
")
- return "".join(parts)
+ items += sexp(
+ '(details :class "group/cat py-1" :open op'
+ ' (raw! sh) (raw! subh))',
+ op=cat_active or None, sh=summary_html, subh=subs_html,
+ )
+
+ return sexp(
+ '(div :class "px-4 py-2" (div :class "divide-y" (raw! items)))',
+ items=items,
+ )
# ---------------------------------------------------------------------------
@@ -423,28 +474,34 @@ def _product_card_html(p: dict, ctx: dict) -> str:
brand_highlight = " bg-yellow-200" if brand in selected_brands else ""
if image:
- labels_html = "".join(
- f' '
- for l in labels
- ) if callable(asset_url_fn) else ""
- img_html = (
- f''
- f'
'
- f'
'
- f'{labels_html}
'
- f'{escape(brand)} '
- f' '
+ labels_html = ""
+ if callable(asset_url_fn):
+ for l in labels:
+ labels_html += sexp(
+ '(img :src src :alt ""'
+ ' :class "pointer-events-none absolute inset-0 w-full h-full object-contain object-top")',
+ src=asset_url_fn("labels/" + l + ".svg"),
+ )
+ img_html = sexp(
+ '(div :class "w-full aspect-square bg-stone-100 relative"'
+ ' (figure :class "inline-block w-full h-full"'
+ ' (div :class "relative w-full h-full"'
+ ' (img :src im :alt "no image" :class "absolute inset-0 w-full h-full object-contain object-top" :loading "lazy" :decoding "async" :fetchpriority "low")'
+ ' (raw! lh))'
+ ' (figcaption :class (str "mt-2 text-sm text-center" bh " text-stone-600") br)))',
+ im=image, lh=labels_html, bh=brand_highlight, br=brand,
)
else:
- labels_list = "".join(f"{l} " for l in labels)
- img_html = (
- f''
- f'
'
- f'
No image
'
- f'
'
- f'
{escape(brand)}
'
- f'
'
+ labels_list = ""
+ for l in labels:
+ labels_list += sexp('(li l)', l=l)
+ img_html = sexp(
+ '(div :class "w-full aspect-square bg-stone-100 relative"'
+ ' (div :class "p-2 flex flex-col items-center justify-center gap-2 text-red-500 h-full relative"'
+ ' (div :class "text-stone-400 text-xs" "No image")'
+ ' (ul :class "flex flex-row gap-1" (raw! ll))'
+ ' (div :class "text-stone-900 text-center line-clamp-3 break-words [overflow-wrap:anywhere]" br)))',
+ ll=labels_list, br=brand,
)
price_html = _card_price_html(p)
@@ -458,36 +515,46 @@ def _product_card_html(p: dict, ctx: dict) -> str:
stickers = p.get("stickers", [])
stickers_html = ""
if stickers and callable(asset_url_fn):
- sticker_parts = []
+ sticker_items = ""
for s in stickers:
found = s in selected_stickers
src = asset_url_fn(f"stickers/{s}.svg")
- sticker_parts.append(
- f' '
+ ring = " ring-2 ring-emerald-500 rounded" if found else ""
+ sticker_items += sexp(
+ '(img :src src :alt sn :class (str "w-6 h-6" ring))',
+ src=src, sn=s, ring=ring,
)
- stickers_html = '' + "".join(sticker_parts) + "
"
+ stickers_html = sexp(
+ '(div :class "flex flex-row justify-center gap-2 p-2" (raw! si))',
+ si=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 = f"{escape(title[:idx])}{escape(title[idx:idx+len(search)])} {escape(title[idx+len(search):])}"
+ highlighted = sexp(
+ '(<> pre (mark mid) post)',
+ pre=title[:idx], mid=title[idx:idx+len(search)], post=title[idx+len(search):],
+ )
else:
- highlighted = escape(title)
+ highlighted = sexp('(<> t)', t=title)
- return (
- f''
+ return sexp(
+ '(div :class "flex flex-col rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden relative"'
+ ' (raw! lk)'
+ ' (a :href ih :hx-get ih :hx-target "#main-panel"'
+ ' :hx-select hs :hx-swap "outerHTML" :hx-push-url "true"'
+ ' (raw! imh) (raw! ph))'
+ ' (div :class "flex justify-center" (raw! ah))'
+ ' (a :href ih :hx-get ih :hx-target "#main-panel"'
+ ' :hx-select hs :hx-swap "outerHTML" :hx-push-url "true"'
+ ' (raw! sth)'
+ ' (div :class "text-sm font-medium text-stone-800 text-center line-clamp-3 break-words [overflow-wrap:anywhere]"'
+ ' (raw! hl))))',
+ lk=like_html, ih=item_href, hs=hx_select,
+ imh=img_html, ph=price_html, ah=add_html,
+ sth=stickers_html, hl=highlighted,
)
@@ -497,13 +564,14 @@ def _like_button_html(slug: str, liked: bool, csrf: str, ctx: dict) -> str:
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''
- f'
'
+ return sexp(
+ '(div :class "absolute top-2 right-2 z-10 text-6xl md:text-xl"'
+ ' (form :id fid :action act :method "post"'
+ ' :hx-post act :hx-target (str "#like-" slug) :hx-swap "outerHTML"'
+ ' (input :type "hidden" :name "csrf_token" :value csrf)'
+ ' (button :type "submit" :class "cursor-pointer"'
+ ' (i :class ic :aria-hidden "true"))))',
+ fid=f"like-{slug}", act=action, slug=slug, csrf=csrf, ic=icon_cls,
)
@@ -511,6 +579,70 @@ def _like_button_html(slug: str, liked: bool, csrf: str, ctx: dict) -> str:
# 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
@@ -533,30 +665,34 @@ def _product_cards_html(ctx: dict) -> str:
next_url = prefix + current_local_href + next_qs
# Mobile sentinel
- parts.append(
- f''
- f'
loading...
'
- f'
Retrying...
'
- )
+ parts.append(sexp(
+ '(div :id mid'
+ ' :class "block md:hidden h-[60vh] opacity-0 pointer-events-none js-mobile-sentinel"'
+ ' :hx-get nu :hx-trigger "intersect once delay:250ms, sentinelmobile:retry"'
+ ' :hx-swap "outerHTML"'
+ ' :_ mhs'
+ ' :role "status" :aria-live "polite" :aria-hidden "true"'
+ ' (div :class "js-loading text-center text-xs text-stone-400" "loading...")'
+ ' (div :class "js-neterr hidden text-center text-xs text-stone-400" "Retrying..."))',
+ mid=f"sentinel-{page}-m", nu=next_url, mhs=_MOBILE_SENTINEL_HS,
+ ))
# Desktop sentinel
- parts.append(
- f''
- f'
loading...
'
- f'
Retrying...
'
- )
+ parts.append(sexp(
+ '(div :id did'
+ ' :class "hidden md:block h-4 opacity-0 pointer-events-none"'
+ ' :hx-get nu :hx-trigger "intersect once delay:250ms, sentinel:retry"'
+ ' :hx-swap "outerHTML"'
+ ' :_ dhs'
+ ' :role "status" :aria-live "polite" :aria-hidden "true"'
+ ' (div :class "js-loading text-center text-xs text-stone-400" "loading...")'
+ ' (div :class "js-neterr hidden text-center text-xs text-stone-400" "Retrying..."))',
+ did=f"sentinel-{page}-d", nu=next_url, dhs=_DESKTOP_SENTINEL_HS,
+ ))
else:
- parts.append('End of results
')
+ parts.append(sexp(
+ '(div :class "col-span-full mt-4 text-center text-xs text-stone-400" "End of results")',
+ ))
return "".join(parts)
@@ -572,10 +708,6 @@ def _desktop_filter_html(ctx: dict) -> str:
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", [])
@@ -589,54 +721,54 @@ def _desktop_filter_html(ctx: dict) -> str:
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'')
- parts.append(f'
')
+ cat_inner = sexp(
+ '(div :class "mb-4" (div :class "text-2xl uppercase tracking-wide text-black-500" cl))',
+ cl=category_label,
+ )
- # Sort stickers
if sort_options:
- parts.append(_sort_stickers_html(sort_options, sort, ctx))
+ cat_inner += _sort_stickers_html(sort_options, sort, ctx)
- # Like + labels row
- parts.append('
')
- parts.append(_like_filter_html(liked, liked_count, ctx))
+ like_labels = _like_filter_html(liked, liked_count, ctx)
if labels:
- parts.append(_labels_filter_html(labels, selected_labels, ctx, prefix="nav-labels"))
- parts.append(" ")
+ like_labels += _labels_filter_html(labels, selected_labels, ctx, prefix="nav-labels")
+ cat_inner += sexp(
+ '(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"'
+ ' (raw! ll))',
+ ll=like_labels,
+ )
- # Stickers
if stickers:
- parts.append(_stickers_filter_html(stickers, selected_stickers, ctx))
+ cat_inner += _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))
+ cat_inner += _subcategory_selector_html(subs_local, top_local_href, sub_slug, ctx)
- parts.append("
")
+ cat_summary = sexp(
+ '(div :id "category-summary-desktop" :hxx-swap-oob "outerHTML" (raw! ci))',
+ ci=cat_inner,
+ )
# Brand filter
- parts.append(f'')
+ brand_inner = ""
if brands:
- parts.append(_brand_filter_html(brands, selected_brands, ctx))
- parts.append("
")
+ brand_inner = _brand_filter_html(brands, selected_brands, ctx)
+ brand_summary = sexp(
+ '(div :id "filter-summary-desktop" :hxx-swap-oob "outerHTML" (raw! bi))',
+ bi=brand_inner,
+ )
- return "".join(parts)
+ return search_html + cat_summary + brand_summary
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)
@@ -652,89 +784,127 @@ def _mobile_filter_summary_html(ctx: dict) -> str:
search_bar = search_mobile_html(ctx)
# Summary chips showing active filters
- chip_parts = ['']
+ chips = ""
if sort and sort_options:
for k, l, i in sort_options:
if k == sort and callable(asset_url_fn):
- chip_parts.append(f'
'
- f' ')
+ chips += sexp(
+ '(ul :class "relative inline-flex items-center justify-center gap-2"'
+ ' (li :role "listitem" (img :src src :alt lb :class "w-10 h-10")))',
+ src=asset_url_fn(i), lb=l,
+ )
if liked:
- chip_parts.append('
'
- f'
')
+ liked_inner = sexp(
+ '(i :aria-hidden "true" :class "fa-solid fa-heart text-red-500 text-[40px] leading-none")',
+ )
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'
{liked_count}
')
- chip_parts.append("
")
+ liked_inner += sexp(
+ '(div :class (str cls " mt-1 leading-none tabular-nums") lc)',
+ cls=cls, lc=str(liked_count),
+ )
+ chips += sexp(
+ '(div :class "flex flex-col items-center gap-1 pb-1" (raw! li))',
+ li=liked_inner,
+ )
# Selected labels
if selected_labels:
- chip_parts.append('
')
+ label_items = ""
for sl in selected_labels:
for lb in labels:
if lb.get("name") == sl and callable(asset_url_fn):
- chip_parts.append(f''
- f' ')
+ li_inner = sexp(
+ '(img :src src :alt sn :class "w-10 h-10")',
+ src=asset_url_fn("nav-labels/" + sl + ".svg"), sn=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"
- chip_parts.append(f'{lb["count"]}
')
- chip_parts.append(" ")
- chip_parts.append(" ")
+ li_inner += sexp(
+ '(div :class (str cls " mt-1 leading-none tabular-nums") ct)',
+ cls=cls, ct=str(lb["count"]),
+ )
+ label_items += sexp(
+ '(li :role "listitem" :class "flex flex-col items-center gap-1 pb-1" (raw! li))',
+ li=li_inner,
+ )
+ chips += sexp(
+ '(ul :class "relative inline-flex items-center justify-center gap-2" (raw! li))',
+ li=label_items,
+ )
# Selected stickers
if selected_stickers:
- chip_parts.append('
')
+ sticker_items = ""
for ss in selected_stickers:
for st in stickers:
if st.get("name") == ss and callable(asset_url_fn):
- chip_parts.append(f''
- f' ')
+ si_inner = sexp(
+ '(img :src src :alt sn :class "w-10 h-10")',
+ src=asset_url_fn("stickers/" + ss + ".svg"), sn=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"
- chip_parts.append(f'{st["count"]}
')
- chip_parts.append(" ")
- chip_parts.append(" ")
+ si_inner += sexp(
+ '(div :class (str cls " mt-1 leading-none tabular-nums") ct)',
+ cls=cls, ct=str(st["count"]),
+ )
+ sticker_items += sexp(
+ '(li :role "listitem" :class "flex flex-col items-center gap-1 pb-1" (raw! si))',
+ si=si_inner,
+ )
+ chips += sexp(
+ '(ul :class "relative inline-flex items-center justify-center gap-2" (raw! si))',
+ si=sticker_items,
+ )
# Selected brands
if selected_brands:
- chip_parts.append('
')
+ 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:
- chip_parts.append(f'{escape(b)}
{count}
')
+ brand_items += sexp(
+ '(li :role "listitem" :class "flex flex-row items-center gap-2"'
+ ' (div :class "text-md" bn) (div :class "text-md" ct))',
+ bn=b, ct=str(count),
+ )
else:
- chip_parts.append(f'{escape(b)}
0
')
- chip_parts.append(" ")
+ brand_items += sexp(
+ '(li :role "listitem" :class "flex flex-row items-center gap-2"'
+ ' (div :class "text-md text-red-500" bn) (div :class "text-xl text-red-500" "0"))',
+ bn=b,
+ )
+ chips += sexp('(ul (raw! bi))', bi=brand_items)
- chip_parts.append("
")
- chips_html = "".join(chip_parts)
+ chips_html = sexp(
+ '(div :class "flex flex-row items-start gap-2" (raw! ch))',
+ ch=chips,
+ )
# Full mobile filter details
from shared.utils import route_prefix
prefix = route_prefix()
mobile_filter = _mobile_filter_content_html(ctx, prefix)
- return (
- f''
- f''
- f'{search_bar}'
- f''
- f'{chips_html}'
- f'
'
- f''
- f'{mobile_filter}'
- f'
'
+ return sexp(
+ '(details :class "md:hidden group" :id "/filter"'
+ ' (summary :class "cursor-pointer select-none" :id "filter-summary-mobile"'
+ ' (raw! sb)'
+ ' (div :class "col-span-12 min-w-0 grid grid-cols-1 gap-1 bg-gray-100 px-2" :role "list"'
+ ' (raw! ch)))'
+ ' (div :id "filter-details-mobile" :style "display:contents"'
+ ' (raw! mf)))',
+ sb=search_bar, ch=chips_html, mf=mobile_filter,
)
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", [])
@@ -747,7 +917,7 @@ def _mobile_filter_content_html(ctx: dict, prefix: str) -> str:
labels = ctx.get("labels", [])
stickers = ctx.get("stickers", [])
brands = ctx.get("brands", [])
- asset_url_fn = ctx.get("asset_url")
+ search = ctx.get("search", "")
qs_fn = ctx.get("qs_filter")
parts = []
@@ -760,21 +930,24 @@ def _mobile_filter_content_html(ctx: dict, prefix: str) -> str:
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''
- )
+ parts.append(sexp(
+ '(div :class "flex flex-row justify-center"'
+ ' (a :href cu :hx-get cu :hx-target "#main-panel"'
+ ' :hx-select hs :hx-swap "outerHTML" :hx-push-url "true"'
+ ' :role "button" :title "clear filters" :aria-label "clear filters"'
+ ' :class "flex flex-col items-center justify-start p-1 rounded bg-stone-200 text-black cursor-pointer"'
+ ' (span :class "mt-1 leading-none tabular-nums" "clear filters")))',
+ cu=clear_url, hs=hx_select,
+ ))
# Like + labels row
- parts.append('')
- parts.append(_like_filter_html(liked, liked_count, ctx, mobile=True))
+ like_labels = _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("
")
+ like_labels += _labels_filter_html(labels, selected_labels, ctx, prefix="nav-labels", mobile=True)
+ parts.append(sexp(
+ '(div :class "flex flex-row gap-2 justify-center items-center" (raw! ll))',
+ ll=like_labels,
+ ))
# Stickers
if stickers:
@@ -796,7 +969,7 @@ def _sort_stickers_html(sort_options: list, current_sort: str, ctx: dict, mobile
from shared.utils import route_prefix
prefix = route_prefix()
- parts = ['']
+ items = ""
for k, label, icon in sort_options:
if callable(qs_fn):
href = prefix + current_local_href + qs_fn({"sort": k})
@@ -805,15 +978,18 @@ def _sort_stickers_html(sort_options: list, current_sort: str, ctx: dict, mobile
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'
'
- f' '
- f'{escape(label)} '
+ items += sexp(
+ '(a :href h :hx-get h :hx-target "#main-panel"'
+ ' :hx-select hs :hx-swap "outerHTML" :hx-push-url "true"'
+ ' :class (str "flex flex-col items-center gap-1 p-1 cursor-pointer" ring)'
+ ' (img :src src :alt lb :class "w-10 h-10")'
+ ' (span :class "text-xs" lb))',
+ h=href, hs=hx_select, ring=ring, src=src, lb=label,
)
- parts.append("
")
- return "".join(parts)
+ return sexp(
+ '(div :class "flex flex-row gap-2 justify-center p-1" (raw! items))',
+ items=items,
+ )
def _like_filter_html(liked: bool, liked_count: int, ctx: dict, mobile: bool = False) -> str:
@@ -831,11 +1007,12 @@ def _like_filter_html(liked: bool, liked_count: int, ctx: dict, mobile: bool = F
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''
- f' '
+ return sexp(
+ '(a :href h :hx-get h :hx-target "#main-panel"'
+ ' :hx-select hs :hx-swap "outerHTML" :hx-push-url "true"'
+ ' :class "flex flex-col items-center gap-1 p-1 cursor-pointer"'
+ ' (i :aria-hidden "true" :class (str ic " " sz " leading-none")))',
+ h=href, hs=hx_select, ic=icon_cls, sz=size,
)
@@ -849,7 +1026,7 @@ def _labels_filter_html(labels: list, selected: list, ctx: dict, *,
from shared.utils import route_prefix
rp = route_prefix()
- parts = []
+ items = ""
for lb in labels:
name = lb.get("name", "")
is_sel = name in selected
@@ -860,13 +1037,14 @@ def _labels_filter_html(labels: list, selected: list, ctx: dict, *,
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''
- f' '
+ items += sexp(
+ '(a :href h :hx-get h :hx-target "#main-panel"'
+ ' :hx-select hs :hx-swap "outerHTML" :hx-push-url "true"'
+ ' :class (str "flex flex-col items-center gap-1 p-1 cursor-pointer" ring)'
+ ' (img :src src :alt nm :class "w-10 h-10"))',
+ h=href, hs=hx_select, ring=ring, src=src, nm=name,
)
- return "".join(parts)
+ return items
def _stickers_filter_html(stickers: list, selected: list, ctx: dict, mobile: bool = False) -> str:
@@ -878,7 +1056,7 @@ def _stickers_filter_html(stickers: list, selected: list, ctx: dict, mobile: boo
from shared.utils import route_prefix
rp = route_prefix()
- parts = ['']
+ items = ""
for st in stickers:
name = st.get("name", "")
count = st.get("count", 0)
@@ -891,15 +1069,18 @@ def _stickers_filter_html(stickers: list, selected: list, ctx: dict, mobile: boo
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'
'
- f' '
- f'{count} '
+ items += sexp(
+ '(a :href h :hx-get h :hx-target "#main-panel"'
+ ' :hx-select hs :hx-swap "outerHTML" :hx-push-url "true"'
+ ' :class (str "flex flex-col items-center gap-1 p-1 cursor-pointer" ring)'
+ ' (img :src src :alt nm :class "w-6 h-6")'
+ ' (span :class cls ct))',
+ h=href, hs=hx_select, ring=ring, src=src, nm=name, cls=cls, ct=str(count),
)
- parts.append("
")
- return "".join(parts)
+ return sexp(
+ '(div :class "flex flex-wrap gap-2 justify-center p-1" (raw! items))',
+ items=items,
+ )
def _brand_filter_html(brands: list, selected: list, ctx: dict, mobile: bool = False) -> str:
@@ -910,7 +1091,7 @@ def _brand_filter_html(brands: list, selected: list, ctx: dict, mobile: bool = F
from shared.utils import route_prefix
rp = route_prefix()
- parts = ['']
+ items = ""
for br in brands:
name = br.get("name", "")
count = br.get("count", 0)
@@ -922,15 +1103,17 @@ def _brand_filter_html(brands: list, selected: list, ctx: dict, mobile: bool = F
href = "#"
bg = " bg-yellow-200" if is_sel else ""
cls = "text-md" if count else "text-md text-red-500"
- parts.append(
- f'
'
- f'{escape(name)}
'
- f'{count}
'
+ items += sexp(
+ '(a :href h :hx-get h :hx-target "#main-panel"'
+ ' :hx-select hs :hx-swap "outerHTML" :hx-push-url "true"'
+ ' :class (str "flex flex-row items-center gap-2 px-2 py-1 rounded hover:bg-stone-100" bg)'
+ ' (div :class cls nm) (div :class cls ct))',
+ h=href, hs=hx_select, bg=bg, cls=cls, nm=name, ct=str(count),
)
- parts.append("
")
- return "".join(parts)
+ return sexp(
+ '(div :class "space-y-1 p-2" (raw! items))',
+ items=items,
+ )
def _subcategory_selector_html(subs: list, top_href: str, current_sub: str, ctx: dict) -> str:
@@ -939,27 +1122,33 @@ def _subcategory_selector_html(subs: list, top_href: str, current_sub: str, ctx:
from shared.utils import route_prefix
rp = route_prefix()
- parts = ['']
- # "All" link
- parts.append(
- f'
All '
+ all_cls = " bg-stone-200 font-medium" if not current_sub else ""
+ all_full_href = rp + top_href
+ items = sexp(
+ '(a :href ah :hx-get ah :hx-target "#main-panel"'
+ ' :hx-select hs :hx-swap "outerHTML" :hx-push-url "true"'
+ ' :class (str "block px-2 py-1 rounded hover:bg-stone-100" ac)'
+ ' "All")',
+ ah=all_full_href, hs=hx_select, ac=all_cls,
)
for sub in subs:
slug = sub.get("slug", "")
name = sub.get("name", "")
href = sub.get("href", "")
active = (slug == current_sub)
- parts.append(
- f'
{escape(name)} '
+ active_cls = " bg-stone-200 font-medium" if active else ""
+ full_href = rp + href
+ items += sexp(
+ '(a :href fh :hx-get fh :hx-target "#main-panel"'
+ ' :hx-select hs :hx-swap "outerHTML" :hx-push-url "true"'
+ ' :class (str "block px-2 py-1 rounded hover:bg-stone-100" ac)'
+ ' nm)',
+ fh=full_href, hs=hx_select, ac=active_cls, nm=name,
)
- parts.append("
")
- return "".join(parts)
+ return sexp(
+ '(div :class "mt-4 space-y-1" (raw! items))',
+ items=items,
+ )
# ---------------------------------------------------------------------------
@@ -990,103 +1179,150 @@ def _product_detail_html(d: dict, ctx: dict) -> str:
like_html = _like_button_html(slug, liked_by_current_user, csrf, ctx)
# Main image + labels
- labels_overlay = "".join(
- f' '
- for l in labels
- ) if callable(asset_url_fn) else ""
+ labels_overlay = ""
+ if callable(asset_url_fn):
+ for l in labels:
+ labels_overlay += sexp(
+ '(img :src src :alt ""'
+ ' :class "pointer-events-none absolute inset-0 w-full h-full object-contain object-top")',
+ src=asset_url_fn("labels/" + l + ".svg"),
+ )
- gallery_html = (
- f''
- f'{like_html}'
- f'
'
- f'
'
- f'{labels_overlay}
'
- f'{escape(brand)} '
+ gallery_inner = sexp(
+ '(<> (raw! lk)'
+ ' (figure :class "inline-block"'
+ ' (div :class "relative w-full aspect-square"'
+ ' (img :data-main-img "" :src im :alt alt'
+ ' :class "w-full h-full object-contain object-top" :loading "eager" :decoding "async")'
+ ' (raw! lo))'
+ ' (figcaption :class "mt-2 text-sm text-stone-600 text-center" br)))',
+ lk=like_html, im=images[0], alt=d.get("title", ""),
+ lo=labels_overlay, br=brand,
)
# Prev/next buttons
+ nav_buttons = ""
if len(images) > 1:
- gallery_html += (
- '
‹ '
- '
› '
+ nav_buttons = sexp(
+ '(<>'
+ ' (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" "\u2039")'
+ ' (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" "\u203a"))',
)
- gallery_html += "
"
+ gallery_html = sexp(
+ '(div :class "relative rounded-xl overflow-hidden bg-stone-100"'
+ ' (raw! gi) (raw! nb))',
+ gi=gallery_inner, nb=nav_buttons,
+ )
# Thumbnails
if len(images) > 1:
- thumbs = "".join(
- f''
- f' '
- f' '
- for i, u in enumerate(images)
+ thumbs = ""
+ for i, u in enumerate(images):
+ thumbs += sexp(
+ '(<> (button :type "button" :data-thumb ""'
+ ' :class "shrink-0 rounded-lg overflow-hidden bg-stone-100 hover:opacity-90 ring-offset-2"'
+ ' :title ti'
+ ' (img :src u :class "h-16 w-16 object-contain" :alt ai :loading "lazy" :decoding "async"))'
+ ' (span :data-image-src u :class "hidden"))',
+ ti=f"Image {i+1}", u=u, ai=f"thumb {i+1}",
+ )
+ gallery_html += sexp(
+ '(div :class "flex flex-row justify-center"'
+ ' (div :class "mt-3 flex gap-2 overflow-x-auto no-scrollbar" (raw! th)))',
+ th=thumbs,
)
- gallery_html += f''
else:
like_html = ""
if user:
like_html = _like_button_html(slug, liked_by_current_user, csrf, ctx)
- gallery_html = (
- f''
- f'{like_html}No image
'
+ gallery_html = sexp(
+ '(div :class "relative aspect-square bg-stone-100 rounded-xl flex items-center justify-center text-stone-400"'
+ ' (raw! lk) "No image")',
+ lk=like_html,
)
# Stickers below gallery
stickers_html = ""
if stickers and callable(asset_url_fn):
- sticker_parts = "".join(
- f' '
- for s in stickers
+ sticker_items = ""
+ for s in stickers:
+ sticker_items += sexp(
+ '(img :src src :alt sn :class "w-10 h-10")',
+ src=asset_url_fn("stickers/" + s + ".svg"), sn=s,
+ )
+ stickers_html = sexp(
+ '(div :class "p-2 flex flex-row justify-center gap-2" (raw! si))',
+ si=sticker_items,
)
- stickers_html = f'{sticker_parts}
'
# Right column: prices, description, sections
pr = _set_prices(d)
- details_parts = ['']
+ details_inner = ""
# Unit price / case size extras
- extras = []
+ extras = ""
ppu = d.get("price_per_unit") or d.get("price_per_unit_raw")
if ppu:
- extras.append(f'
Unit price: {_price_str(d.get("price_per_unit"), d.get("price_per_unit_raw"), d.get("price_per_unit_currency"))}
')
+ extras += sexp(
+ '(div (str "Unit price: " ps))',
+ ps=_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.append(f'
Case size: {d["case_size_raw"]}
')
+ extras += sexp('(div (str "Case size: " cs))', cs=d["case_size_raw"])
if extras:
- details_parts.append('
' + "".join(extras) + "
")
+ details_inner += sexp(
+ '(div :class "mt-2 space-y-1 text-sm text-stone-600" (raw! ex))',
+ ex=extras,
+ )
# Description
desc_short = d.get("description_short")
desc_html = d.get("description_html")
if desc_short or desc_html:
- details_parts.append('
')
+ desc_inner = ""
if desc_short:
- details_parts.append(f'
{escape(desc_short)}
')
+ desc_inner += sexp('(p :class "leading-relaxed text-lg" ds)', ds=desc_short)
if desc_html:
- details_parts.append(f'
{desc_html}
')
- details_parts.append("
")
+ desc_inner += sexp(
+ '(div :class "max-w-none text-sm leading-relaxed" (raw! dh))',
+ dh=desc_html,
+ )
+ details_inner += sexp(
+ '(div :class "mt-4 text-stone-800 space-y-3" (raw! di))',
+ di=desc_inner,
+ )
# Sections (expandable)
sections = d.get("sections", [])
if sections:
- details_parts.append('
')
+ sec_items = ""
for sec in sections:
- details_parts.append(
- f'
'
- f''
- f'{escape(sec.get("title", ""))} '
- f'⌄ '
- f'{sec.get("html", "")}
'
+ sec_items += sexp(
+ '(details :class "group rounded-xl border bg-white shadow-sm open:shadow p-0"'
+ ' (summary :class "cursor-pointer select-none px-4 py-3 flex items-center justify-between"'
+ ' (span :class "font-medium" st)'
+ ' (span :class "ml-2 text-xl transition-transform group-open:rotate-180" "\u2304"))'
+ ' (div :class "px-4 pb-4 max-w-none text-sm leading-relaxed" (raw! sh)))',
+ st=sec.get("title", ""), sh=sec.get("html", ""),
)
- details_parts.append("
")
+ details_inner += sexp(
+ '(div :class "mt-8 space-y-3" (raw! si))',
+ si=sec_items,
+ )
- details_parts.append("
")
+ details_html = sexp('(div :class "md:col-span-3" (raw! di))', di=details_inner)
- return (
- f''
- f'
{gallery_html}{stickers_html}
'
- f'{"".join(details_parts)}
'
+ return sexp(
+ '(<> (div :class "mt-3 grid grid-cols-1 md:grid-cols-5 gap-6" :data-gallery-root ""'
+ ' (div :class "md:col-span-2" (raw! gh) (raw! sh))'
+ ' (raw! dh))'
+ ' (div :class "pb-8"))',
+ gh=gallery_html, sh=stickers_html, dh=details_html,
)
@@ -1102,7 +1338,6 @@ def _product_meta_html(d: dict, ctx: dict) -> str:
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]
@@ -1113,34 +1348,34 @@ def _product_meta_html(d: dict, ctx: dict) -> str:
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"{escape(title)} "]
- parts.append(f' ')
+ parts = sexp('(title t)', t=title)
+ parts += sexp('(meta :name "description" :content desc)', desc=description)
if canonical:
- parts.append(f' ')
+ parts += sexp('(link :rel "canonical" :href can)', can=canonical)
# OpenGraph
site_title = ctx.get("base_title", "")
- parts.append(f' ')
- parts.append(' ')
- parts.append(f' ')
- parts.append(f' ')
+ parts += sexp('(meta :property "og:site_name" :content st)', st=site_title)
+ parts += sexp('(meta :property "og:type" :content "product")')
+ parts += sexp('(meta :property "og:title" :content t)', t=title)
+ parts += sexp('(meta :property "og:description" :content desc)', desc=description)
if canonical:
- parts.append(f' ')
+ parts += sexp('(meta :property "og:url" :content can)', can=canonical)
if image_url:
- parts.append(f' ')
+ parts += sexp('(meta :property "og:image" :content iu)', iu=image_url)
if price and price_currency:
- parts.append(f' ')
- parts.append(f' ')
+ parts += sexp('(meta :property "product:price:amount" :content pa)', pa=f"{price:.2f}")
+ parts += sexp('(meta :property "product:price:currency" :content pc)', pc=price_currency)
if brand:
- parts.append(f' ')
+ parts += sexp('(meta :property "product:brand" :content br)', br=brand)
# Twitter
card_type = "summary_large_image" if image_url else "summary"
- parts.append(f' ')
- parts.append(f' ')
- parts.append(f' ')
+ parts += sexp('(meta :name "twitter:card" :content ct)', ct=card_type)
+ parts += sexp('(meta :name "twitter:title" :content t)', t=title)
+ parts += sexp('(meta :name "twitter:description" :content desc)', desc=description)
if image_url:
- parts.append(f' ')
+ parts += sexp('(meta :name "twitter:image" :content iu)', iu=image_url)
# JSON-LD
jsonld = {
@@ -1162,9 +1397,12 @@ def _product_meta_html(d: dict, ctx: dict) -> str:
"url": canonical,
"availability": "https://schema.org/InStock",
}
- parts.append(f'')
+ parts += sexp(
+ '(script :type "application/ld+json" (raw! jl))',
+ jl=json.dumps(jsonld),
+ )
- return "\n".join(parts)
+ return parts
# ---------------------------------------------------------------------------
@@ -1191,26 +1429,42 @@ def _market_card_html(market: Any, page_info: dict, *, show_page_badge: bool = T
p_title = ""
market_href = market_url(f"/{post_slug}/{slug}/") if post_slug else ""
- parts = ['']
- parts.append("")
+ title_html = ""
if market_href:
- parts.append(f'
{escape(name)} ')
+ title_html = sexp(
+ '(a :href mh :class "hover:text-emerald-700"'
+ ' (h2 :class "text-lg font-semibold text-stone-900" nm))',
+ mh=market_href, nm=name,
+ )
else:
- parts.append(f'
{escape(name)} ')
- if description:
- parts.append(f'
{escape(description)}
')
- parts.append("
")
-
- if show_page_badge and p_title:
- badge_href = market_url(f"/{p_slug}/")
- parts.append(
- f''
+ title_html = sexp(
+ '(h2 :class "text-lg font-semibold text-stone-900" nm)',
+ nm=name,
)
- parts.append(" ")
- return "".join(parts)
+ desc_html = ""
+ if description:
+ desc_html = sexp(
+ '(p :class "text-sm text-stone-600 mt-1 line-clamp-2" d)',
+ d=description,
+ )
+
+ badge_html = ""
+ if show_page_badge and p_title:
+ badge_href = market_url(f"/{p_slug}/")
+ badge_html = sexp(
+ '(div :class "flex flex-wrap items-center gap-1.5 mt-3"'
+ ' (a :href bh :class "inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 hover:bg-amber-200"'
+ ' pt))',
+ bh=badge_href, pt=p_title,
+ )
+
+ return sexp(
+ '(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"'
+ ' (div (raw! th) (raw! dh))'
+ ' (raw! bh))',
+ th=title_html, dh=desc_html, bh=badge_html,
+ )
def _market_cards_html(markets: list, page_info: dict, page: int, has_more: bool,
@@ -1220,12 +1474,13 @@ def _market_cards_html(markets: list, page_info: dict, page: int, has_more: bool
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''
- )
+ parts.append(sexp(
+ '(div :id sid :class "h-4 opacity-0 pointer-events-none"'
+ ' :hx-get nu :hx-trigger "intersect once delay:250ms"'
+ ' :hx-swap "outerHTML" :role "status" :aria-hidden "true"'
+ ' (div :class "text-center text-xs text-stone-400" "loading..."))',
+ sid=f"sentinel-{page}", nu=next_url,
+ ))
return "".join(parts)
@@ -1235,10 +1490,11 @@ def _market_cards_html(markets: list, page_info: dict, page: int, has_more: bool
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''
+ return sexp(
+ '(div :id pid :hx-swap-oob "outerHTML" :class "w-full"'
+ ' (div :class "w-full" (raw! rh)'
+ ' (div :id cid)))',
+ pid=parent_id, cid=child_id, rh=row_html,
)
@@ -1251,6 +1507,24 @@ def _oob_header_html(parent_id: str, child_id: str, row_html: str) -> str:
# All markets
# ---------------------------------------------------------------------------
+def _markets_grid(cards: str) -> str:
+ """Wrap market cards in a grid."""
+ return sexp(
+ '(div :class "max-w-full px-3 py-3 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4" (raw! c))',
+ c=cards,
+ )
+
+
+def _no_markets_html(message: str = "No markets available") -> str:
+ """Empty state for markets."""
+ return sexp(
+ '(div :class "px-3 py-12 text-center text-stone-400"'
+ ' (i :class "fa fa-store text-4xl mb-3" :aria-hidden "true")'
+ ' (p :class "text-lg" msg))',
+ msg=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."""
@@ -1262,12 +1536,10 @@ async def render_all_markets_page(ctx: dict, markets: list, has_more: bool,
if markets:
cards = _market_cards_html(markets, page_info, page, has_more, next_url)
- content = f'{cards}
'
+ content = _markets_grid(cards)
else:
- content = (''
- '
'
- '
No markets available
')
- content += '
'
+ content = _no_markets_html()
+ content += sexp('(div :class "pb-8")')
hdr = root_header_html(ctx)
return full_page(ctx, header_rows_html=hdr, content_html=content)
@@ -1284,12 +1556,10 @@ async def render_all_markets_oob(ctx: dict, markets: list, has_more: bool,
if markets:
cards = _market_cards_html(markets, page_info, page, has_more, next_url)
- content = f'{cards}
'
+ content = _markets_grid(cards)
else:
- content = (''
- '
'
- '
No markets available
')
- content += '
'
+ content = _no_markets_html()
+ content += sexp('(div :class "pb-8")')
oobs = root_header_html(ctx, oob=True)
return oob_page(ctx, oobs_html=oobs, content_html=content)
@@ -1324,12 +1594,10 @@ async def render_page_markets_page(ctx: dict, markets: list, has_more: bool,
if markets:
cards = _market_cards_html(markets, {}, page, has_more, next_url,
show_page_badge=False, post_slug=post_slug)
- content = f'{cards}
'
+ content = _markets_grid(cards)
else:
- content = (''
- '
'
- '
No markets for this page
')
- content += '
'
+ content = _no_markets_html("No markets for this page")
+ content += sexp('(div :class "pb-8")')
hdr = root_header_html(ctx)
hdr += sexp(
@@ -1353,12 +1621,10 @@ async def render_page_markets_oob(ctx: dict, markets: list, has_more: bool,
if markets:
cards = _market_cards_html(markets, {}, page, has_more, next_url,
show_page_badge=False, post_slug=post_slug)
- content = f'{cards}
'
+ content = _markets_grid(cards)
else:
- content = (''
- '
'
- '
No markets for this page
')
- content += '
'
+ content = _no_markets_html("No markets for this page")
+ content += sexp('(div :class "pb-8")')
oobs = _oob_header_html("post-header-child", "market-header-child", "")
oobs += _post_header_html(ctx, oob=True)
@@ -1410,28 +1676,45 @@ async def render_market_home_oob(ctx: dict) -> str:
def _market_landing_content(post: dict) -> str:
"""Build market landing page content (excerpt + feature image + html)."""
- parts = ['']
+ inner = ""
if post.get("custom_excerpt"):
- parts.append(f'{post["custom_excerpt"]}
')
+ inner += sexp(
+ '(div :class "w-full text-center italic text-3xl p-2" ce)',
+ ce=post["custom_excerpt"],
+ )
if post.get("feature_image"):
- parts.append(
- f''
- f'
'
+ inner += sexp(
+ '(div :class "mb-3 flex justify-center"'
+ ' (img :src fi :alt "" :class "rounded-lg w-full md:w-3/4 object-cover"))',
+ fi=post["feature_image"],
)
if post.get("html"):
- parts.append(f'{post["html"]}
')
- parts.append('
')
- return "".join(parts)
+ inner += sexp(
+ '(div :class "blog-content p-2" (raw! h))',
+ h=post["html"],
+ )
+ return sexp(
+ '(<> (article :class "relative w-full" (raw! inner)) (div :class "pb-8"))',
+ inner=inner,
+ )
# ---------------------------------------------------------------------------
# Browse page
# ---------------------------------------------------------------------------
+def _product_grid(cards_html: str) -> str:
+ """Wrap product cards in a grid."""
+ return sexp(
+ '(<> (div :class "grid grid-cols-1 sm:grid-cols-3 md:grid-cols-6 gap-3" (raw! c)) (div :class "pb-8"))',
+ c=cards_html,
+ )
+
+
async def render_browse_page(ctx: dict) -> str:
"""Full page: product browse with filters."""
cards_html = _product_cards_html(ctx)
- content = f'{cards_html}
'
+ content = _product_grid(cards_html)
hdr = root_header_html(ctx)
child = _post_header_html(ctx) + _market_header_html(ctx)
@@ -1450,7 +1733,7 @@ async def render_browse_page(ctx: dict) -> str:
async def render_browse_oob(ctx: dict) -> str:
"""OOB response: product browse."""
cards_html = _product_cards_html(ctx)
- content = f'{cards_html}
'
+ content = _product_grid(cards_html)
oobs = _oob_header_html("post-header-child", "market-header-child",
_market_header_html(ctx))
@@ -1609,12 +1892,15 @@ def render_like_toggle_button(slug: str, liked: bool, *,
icon = "fa-regular fa-heart"
label = f"Like this {item_type}"
- return (
- f''
- f' '
+ return sexp(
+ '(button :class (str "flex items-center gap-1 " colour " hover:text-red-600 transition-colors w-[1em] h-[1em]")'
+ ' :hx-post lu :hx-target "this" :hx-swap "outerHTML" :hx-push-url "false"'
+ ' :hx-headers hh'
+ ' :hx-swap-settle "0ms" :aria-label lb'
+ ' (i :aria-hidden "true" :class ic))',
+ colour=colour, lu=like_url,
+ hh=f'{{"X-CSRFToken": "{csrf}"}}',
+ lb=label, ic=icon,
)
@@ -1634,33 +1920,34 @@ def render_cart_added_response(cart: list, item: Any, d: dict) -> str:
# 1. Cart mini icon OOB
if count > 0:
cart_href = _cart_url("/")
- cart_mini = (
- f''
+ cart_mini = sexp(
+ '(div :id "cart-mini" :hx-swap-oob "outerHTML"'
+ ' (a :href ch :class "relative inline-flex items-center justify-center"'
+ ' (span :class "relative inline-flex items-center justify-center"'
+ ' (i :class "fa-solid fa-shopping-cart text-xl" :aria-hidden "true")'
+ ' (span :class "absolute -top-1.5 -right-2 pointer-events-none"'
+ ' (span :class "flex items-center justify-center bg-emerald-500 text-white rounded-full min-w-[1.25rem] h-5 text-xs font-bold px-1"'
+ ' ct)))))',
+ ch=cart_href, ct=str(count),
)
else:
from shared.config import config
blog_href = config().get("blog_url", "/")
logo = config().get("logo", "")
- cart_mini = (
- f''
+ cart_mini = sexp(
+ '(div :id "cart-mini" :hx-swap-oob "outerHTML"'
+ ' (a :href bh :class "relative inline-flex items-center justify-center"'
+ ' (img :src lg :class "h-8 w-8 rounded-full object-cover border border-stone-300" :alt "")))',
+ bh=blog_href, lg=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 = (
- f''
- + _cart_add_html(slug, quantity, action, csrf, cart_url_fn=_cart_url)
- + '
'
+ add_html = sexp(
+ '(div :id aid :hx-swap-oob "outerHTML" (raw! ah))',
+ aid=f"cart-add-{slug}",
+ ah=_cart_add_html(slug, quantity, action, csrf, cart_url_fn=_cart_url),
)
return cart_mini + add_html