SxExpr is now a str subclass so it works everywhere a plain string does (join, isinstance, f-strings) while serialize() still emits it unquoted. sx_call() and all internal render functions (_render_to_sx, async_eval_to_sx, etc.) return SxExpr, eliminating the "forgot to wrap" bug class that caused the sx_content leak and list serialization bugs. - Phase 0: SxExpr(str) with .source property, __add__/__radd__ - Phase 1: sx_call returns SxExpr (drop-in, all 200+ sites unchanged) - Phase 2: async_eval_to_sx, async_eval_slot_to_sx, _render_to_sx, mobile_menu_sx return SxExpr; remove isinstance(str) workaround - Phase 3: Remove ~150 redundant SxExpr() wrappings across 45 files - Phase 4: serialize() docstring, handler return docs, ;; returns: sx Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
239 lines
8.6 KiB
Python
239 lines
8.6 KiB
Python
"""Product/market card builders."""
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
from shared.sx.parser import SxExpr
|
|
from shared.sx.helpers import sx_call
|
|
|
|
from .utils import _set_prices, _price_str
|
|
from .filters import _MOBILE_SENTINEL_HS, _DESKTOP_SENTINEL_HS
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Product card (browse grid item)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _product_card_sx(p: dict, ctx: dict) -> str:
|
|
"""Build a single product card for browse grid as sx call."""
|
|
from quart import url_for
|
|
from shared.browser.app.csrf import generate_csrf_token
|
|
from shared.utils import route_prefix
|
|
|
|
prefix = route_prefix()
|
|
slug = p.get("slug", "")
|
|
item_href = prefix + url_for("market.browse.product.product_detail", product_slug=slug)
|
|
hx_select = ctx.get("hx_select_search", "#main-panel")
|
|
asset_url_fn = ctx.get("asset_url")
|
|
cart = ctx.get("cart", [])
|
|
selected_brands = ctx.get("selected_brands", [])
|
|
selected_stickers = ctx.get("selected_stickers", [])
|
|
search = ctx.get("search", "")
|
|
user = ctx.get("user")
|
|
csrf = generate_csrf_token()
|
|
|
|
# Price data
|
|
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"])
|
|
|
|
# Image labels as src URLs
|
|
labels = p.get("labels", [])
|
|
label_srcs = []
|
|
if p.get("image") and callable(asset_url_fn):
|
|
label_srcs = [asset_url_fn("labels/" + l + ".svg") for l in labels]
|
|
|
|
# Stickers as data
|
|
raw_stickers = p.get("stickers", [])
|
|
sticker_data = []
|
|
if raw_stickers and callable(asset_url_fn):
|
|
for s in raw_stickers:
|
|
ring = " ring-2 ring-emerald-500 rounded" if s in selected_stickers else ""
|
|
sticker_data.append({"src": asset_url_fn(f"stickers/{s}.svg"), "name": s, "ring-cls": ring})
|
|
|
|
# Title highlighting
|
|
title = p.get("title", "")
|
|
has_highlight = False
|
|
search_pre = search_mid = search_post = ""
|
|
if search and search.lower() in title.lower():
|
|
idx = title.lower().index(search.lower())
|
|
has_highlight = True
|
|
search_pre = title[:idx]
|
|
search_mid = title[idx:idx + len(search)]
|
|
search_post = title[idx + len(search):]
|
|
|
|
# Cart
|
|
quantity = sum(ci.quantity for ci in cart if ci.product.slug == slug) if cart else 0
|
|
cart_action = url_for("market.browse.product.cart", product_slug=slug)
|
|
cart_url_fn = ctx.get("cart_url")
|
|
cart_href = cart_url_fn("/") if callable(cart_url_fn) else "/"
|
|
|
|
brand = p.get("brand", "")
|
|
brand_highlight = " bg-yellow-200" if brand in selected_brands else ""
|
|
|
|
kwargs = dict(
|
|
href=item_href, hx_select=hx_select, slug=slug,
|
|
image=p.get("image", ""), brand=brand, brand_highlight=brand_highlight,
|
|
special_price=sp_str, regular_price=rp_str,
|
|
cart_action=cart_action, quantity=quantity, cart_href=cart_href, csrf=csrf,
|
|
title=title,
|
|
has_like=bool(user),
|
|
)
|
|
|
|
if label_srcs:
|
|
kwargs["labels"] = label_srcs
|
|
elif labels:
|
|
kwargs["labels"] = labels
|
|
|
|
if user:
|
|
kwargs["liked"] = p.get("is_liked", False)
|
|
kwargs["like_action"] = url_for("market.browse.product.like_toggle", product_slug=slug)
|
|
|
|
if sticker_data:
|
|
kwargs["stickers"] = sticker_data
|
|
|
|
if has_highlight:
|
|
kwargs["has_highlight"] = True
|
|
kwargs["search_pre"] = search_pre
|
|
kwargs["search_mid"] = search_mid
|
|
kwargs["search_post"] = search_post
|
|
|
|
return sx_call("market-product-card", **kwargs)
|
|
|
|
|
|
def _product_cards_sx(ctx: dict) -> str:
|
|
"""S-expression wire format for product cards (client renders)."""
|
|
from shared.utils import route_prefix
|
|
|
|
prefix = route_prefix()
|
|
products = ctx.get("products", [])
|
|
page = ctx.get("page", 1)
|
|
total_pages = ctx.get("total_pages", 1)
|
|
current_local_href = ctx.get("current_local_href", "/")
|
|
qs_fn = ctx.get("qs_filter")
|
|
|
|
parts = []
|
|
for p in products:
|
|
parts.append(_product_card_sx(p, ctx))
|
|
|
|
if page < total_pages:
|
|
if callable(qs_fn):
|
|
next_qs = qs_fn({"page": page + 1})
|
|
else:
|
|
next_qs = f"?page={page + 1}"
|
|
next_url = prefix + current_local_href + next_qs
|
|
parts.append(sx_call("sentinel-mobile",
|
|
id=f"sentinel-{page}-m", next_url=next_url,
|
|
hyperscript=_MOBILE_SENTINEL_HS))
|
|
parts.append(sx_call("sentinel-desktop",
|
|
id=f"sentinel-{page}-d", next_url=next_url,
|
|
hyperscript=_DESKTOP_SENTINEL_HS))
|
|
else:
|
|
parts.append(sx_call("end-of-results"))
|
|
|
|
return "(<> " + " ".join(parts) + ")"
|
|
|
|
|
|
def _like_button_sx(slug: str, liked: bool, csrf: str, ctx: dict) -> str:
|
|
"""Build the like/unlike heart button overlay as sx."""
|
|
from quart import url_for
|
|
|
|
action = url_for("market.browse.product.like_toggle", product_slug=slug)
|
|
icon_cls = "fa-solid fa-heart text-red-500" if liked else "fa-regular fa-heart text-stone-400"
|
|
return sx_call(
|
|
"market-like-button",
|
|
form_id=f"like-{slug}", action=action, slug=slug,
|
|
csrf=csrf, icon_cls=icon_cls,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Market cards (all markets / page markets)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _market_card_sx(market: Any, page_info: dict, *, show_page_badge: bool = True,
|
|
post_slug: str = "") -> str:
|
|
"""Build a single market card as sx."""
|
|
from shared.infrastructure.urls import market_url
|
|
|
|
name = getattr(market, "name", "")
|
|
description = getattr(market, "description", "")
|
|
slug = getattr(market, "slug", "")
|
|
container_id = getattr(market, "container_id", None)
|
|
|
|
if show_page_badge and page_info:
|
|
pi = page_info.get(container_id, {})
|
|
p_slug = pi.get("slug", "")
|
|
p_title = pi.get("title", "")
|
|
market_href = market_url(f"/{p_slug}/{slug}/") if p_slug else ""
|
|
else:
|
|
p_slug = post_slug
|
|
p_title = ""
|
|
market_href = market_url(f"/{post_slug}/{slug}/") if post_slug else ""
|
|
|
|
title_sx = ""
|
|
if market_href:
|
|
title_sx = sx_call("market-market-card-title-link", href=market_href, name=name)
|
|
else:
|
|
title_sx = sx_call("market-market-card-title", name=name)
|
|
|
|
desc_sx = ""
|
|
if description:
|
|
desc_sx = sx_call("market-market-card-desc", description=description)
|
|
|
|
badge_sx = ""
|
|
if show_page_badge and p_title:
|
|
badge_href = market_url(f"/{p_slug}/")
|
|
badge_sx = sx_call("market-market-card-badge", href=badge_href, title=p_title)
|
|
|
|
return sx_call(
|
|
"market-market-card",
|
|
title_content=title_sx or None,
|
|
desc_content=desc_sx or None,
|
|
badge_content=badge_sx or None,
|
|
)
|
|
|
|
|
|
def _market_cards_sx(markets: list, page_info: dict, page: int, has_more: bool,
|
|
next_url: str, *, show_page_badge: bool = True,
|
|
post_slug: str = "") -> str:
|
|
"""Build market cards with infinite scroll sentinel as sx."""
|
|
parts = []
|
|
for m in markets:
|
|
parts.append(_market_card_sx(m, page_info, show_page_badge=show_page_badge,
|
|
post_slug=post_slug))
|
|
if has_more:
|
|
parts.append(sx_call(
|
|
"sentinel-simple",
|
|
id=f"sentinel-{page}", next_url=next_url,
|
|
))
|
|
return "(<> " + " ".join(parts) + ")"
|
|
|
|
|
|
def _markets_grid(cards_sx: str) -> str:
|
|
"""Wrap market cards in a grid as sx."""
|
|
return sx_call("market-markets-grid", cards=SxExpr(cards_sx))
|
|
|
|
|
|
def _no_markets_sx(message: str = "No markets available") -> str:
|
|
"""Empty state for markets as sx."""
|
|
return sx_call("empty-state", icon="fa fa-store", message=message,
|
|
cls="px-3 py-12 text-center text-stone-400")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Market landing page
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _market_landing_content_sx(post: dict) -> str:
|
|
"""Build market landing page content as sx."""
|
|
parts: list[str] = []
|
|
if post.get("custom_excerpt"):
|
|
parts.append(sx_call("market-landing-excerpt", text=post["custom_excerpt"]))
|
|
if post.get("feature_image"):
|
|
parts.append(sx_call("market-landing-image", src=post["feature_image"]))
|
|
if post.get("html"):
|
|
parts.append(sx_call("market-landing-html", html=post["html"]))
|
|
inner = "(<> " + " ".join(parts) + ")" if parts else "(<>)"
|
|
return sx_call("market-landing-content", inner=SxExpr(inner))
|