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 = ['") - 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'') - 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'
' - f'' - 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'
' - f'
' - f'' - f'' - f'
' - f'' - f'' - f'' - f'{quantity}' - f'
' - f'' - f'' - f'
' - 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'no image' - 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'{escape(s)}' + 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'
    ' - f'{like_html}' - f'' - f'{img_html}{price_html}' - f'
    {add_html}
    ' - f'' - f'{stickers_html}' - f'
    {highlighted}
    ' - 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'
    ' - 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'' - ) + 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'' - ) + 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'
    {escape(category_label)}
    ') + 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('") + 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'') + 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('") + 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('") + 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 += 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'
    ' - f'' - f'clear filters
    ' - ) + 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'{escape(label)}' - 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'{escape(name)}' + 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'{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-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'{escape(d.get(' - 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'' - 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'
    {thumbs}
    ' 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'{escape(s)}' - 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'
    ' - f'' - f'{escape(p_title)}
    ' + 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'
    ' - f'
    {row_html}' - 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'' + 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'
    ' - f'' - f'' - f'' - f'' - f'' - f'{count}
    ' + 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'
    ' - f'' - f'' - 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