diff --git a/market/sx/cards.sx b/market/sx/cards.sx index 039c096..a85ea14 100644 --- a/market/sx/cards.sx +++ b/market/sx/cards.sx @@ -95,3 +95,63 @@ (if desc-content desc-content (when desc desc))) (if badge-content badge-content (when badge badge)))) + +;; --------------------------------------------------------------------------- +;; Composition defcomps — receive data lists, compose component trees +;; --------------------------------------------------------------------------- + +;; Product cards grid with infinite scroll sentinels +(defcomp ~market-product-cards-content (&key products page total-pages next-url + mobile-sentinel-hs desktop-sentinel-hs) + (<> + (map (lambda (p) + (~market-product-card + :href (get p "href") :hx-select (get p "hx-select") :slug (get p "slug") + :image (get p "image") :brand (get p "brand") :brand-highlight (get p "brand-highlight") + :special-price (get p "special-price") :regular-price (get p "regular-price") + :cart-action (get p "cart-action") :quantity (get p "quantity") + :cart-href (get p "cart-href") :csrf (get p "csrf") + :title (get p "title") :has-like (get p "has-like") + :liked (get p "liked") :like-action (get p "like-action") + :labels (get p "labels") :stickers (get p "stickers") + :has-highlight (get p "has-highlight") + :search-pre (get p "search-pre") :search-mid (get p "search-mid") + :search-post (get p "search-post"))) + products) + (if (< page total-pages) + (<> (~sentinel-mobile :id (str "sentinel-" page "-m") :next-url next-url + :hyperscript mobile-sentinel-hs) + (~sentinel-desktop :id (str "sentinel-" page "-d") :next-url next-url + :hyperscript desktop-sentinel-hs)) + (~end-of-results)))) + +;; Single market card from data (handles conditional title/desc/badge) +(defcomp ~market-card-from-data (&key name description href show-badge badge-href badge-title) + (~market-market-card + :title-content (if href + (~market-market-card-title-link :href href :name name) + (~market-market-card-title :name name)) + :desc-content (when description + (~market-market-card-desc :description description)) + :badge-content (when (and show-badge badge-title) + (~market-market-card-badge :href badge-href :title badge-title)))) + +;; Market cards list with infinite scroll sentinel +(defcomp ~market-cards-content (&key markets page has-more next-url) + (<> + (map (lambda (m) + (~market-card-from-data + :name (get m "name") :description (get m "description") + :href (get m "href") :show-badge (get m "show-badge") + :badge-href (get m "badge-href") :badge-title (get m "badge-title"))) + markets) + (when has-more + (~sentinel-simple :id (str "sentinel-" page) :next-url next-url)))) + +;; Market landing page content from data +(defcomp ~market-landing-from-data (&key excerpt feature-image html) + (~market-landing-content :inner + (<> (when excerpt (~market-landing-excerpt :text excerpt)) + (when feature-image (~market-landing-image :src feature-image)) + (when html (~market-landing-html :html html))))) + diff --git a/market/sx/detail.sx b/market/sx/detail.sx index 24ab0eb..668abbd 100644 --- a/market/sx/detail.sx +++ b/market/sx/detail.sx @@ -92,3 +92,71 @@ (defcomp ~market-landing-content (&key inner) (<> (article :class "relative w-full" inner) (div :class "pb-8"))) + + +;; --------------------------------------------------------------------------- +;; Composition: product detail page from data +;; --------------------------------------------------------------------------- + +;; Gallery section from pre-computed data +(defcomp ~market-detail-gallery-from-data (&key images labels brand like-data has-nav-buttons thumbs) + (let ((like-sx (when like-data + (~market-like-button + :form-id (get like-data "form-id") :action (get like-data "action") + :slug (get like-data "slug") :csrf (get like-data "csrf") + :icon-cls (get like-data "icon-cls"))))) + (if images + (<> + (~market-detail-gallery + :inner (~market-detail-gallery-inner + :like like-sx + :image (get (first images) "src") :alt (get (first images) "alt") + :labels (when labels + (<> (map (lambda (src) (~market-label-overlay :src src)) labels))) + :brand brand) + :nav (when has-nav-buttons (~market-detail-nav-buttons))) + (when thumbs + (~market-detail-thumbs :thumbs + (<> (map (lambda (t) + (~market-detail-thumb + :title (get t "title") :src (get t "src") :alt (get t "alt"))) + thumbs))))) + (~market-detail-no-image :like like-sx)))) + +;; Right column details from data +(defcomp ~market-detail-info-from-data (&key extras desc-short desc-html sections) + (~market-detail-right-col :inner + (<> + (when extras + (~market-detail-extras :inner + (<> (map (lambda (e) + (if (= (get e "type") "unit-price") + (~market-detail-unit-price :price (get e "value")) + (~market-detail-case-size :size (get e "value")))) + extras)))) + (when (or desc-short desc-html) + (~market-detail-desc-wrapper :inner + (<> (when desc-short (~market-detail-desc-short :text desc-short)) + (when desc-html (~market-detail-desc-html :html desc-html))))) + (when sections + (~market-detail-sections :items + (<> (map (lambda (s) + (~market-detail-section :title (get s "title") :html (get s "html"))) + sections))))))) + +;; Full product detail layout from data +(defcomp ~market-product-detail-from-data (&key images labels brand like-data + has-nav-buttons thumbs sticker-items + extras desc-short desc-html sections) + (~market-detail-layout + :gallery (~market-detail-gallery-from-data + :images images :labels labels :brand brand :like-data like-data + :has-nav-buttons has-nav-buttons :thumbs thumbs) + :stickers (when sticker-items + (~market-detail-stickers :items + (<> (map (lambda (s) + (~market-detail-sticker :src (get s "src") :name (get s "name"))) + sticker-items)))) + :details (~market-detail-info-from-data + :extras extras :desc-short desc-short :desc-html desc-html + :sections sections))) diff --git a/market/sx/filters.sx b/market/sx/filters.sx index 4494a6e..7b2b710 100644 --- a/market/sx/filters.sx +++ b/market/sx/filters.sx @@ -122,3 +122,152 @@ (defcomp ~market-mobile-chip-brand-list (&key items) (ul items)) + + +;; --------------------------------------------------------------------------- +;; Composition defcomps — receive data, compose filter component trees +;; --------------------------------------------------------------------------- + +;; Sort option stickers from data +(defcomp ~market-filter-sort-from-data (&key items) + (~market-filter-sort-row :items + (<> (map (lambda (s) + (~market-filter-sort-item + :href (get s "href") :hx-select (get s "hx-select") + :ring-cls (get s "ring-cls") :src (get s "src") :label (get s "label"))) + items)))) + +;; Like filter from data +(defcomp ~market-filter-like-from-data (&key href hx-select liked mobile) + (~market-filter-like + :href href :hx-select hx-select + :icon-cls (if liked "fa-solid fa-heart text-red-500" "fa-regular fa-heart text-stone-400") + :size-cls (if mobile "text-[40px]" "text-2xl"))) + +;; Label filter items from data +(defcomp ~market-filter-labels-from-data (&key items hx-select) + (<> (map (lambda (lb) + (~market-filter-label-item + :href (get lb "href") :hx-select hx-select + :ring-cls (get lb "ring-cls") :src (get lb "src") :name (get lb "name"))) + items))) + +;; Sticker filter items from data +(defcomp ~market-filter-stickers-from-data (&key items hx-select) + (~market-filter-stickers-row :items + (<> (map (lambda (st) + (~market-filter-sticker-item + :href (get st "href") :hx-select hx-select + :ring-cls (get st "ring-cls") :src (get st "src") :name (get st "name") + :count-cls (get st "count-cls") :count (get st "count"))) + items)))) + +;; Brand filter items from data +(defcomp ~market-filter-brands-from-data (&key items hx-select) + (~market-filter-brands-panel :items + (<> (map (lambda (br) + (~market-filter-brand-item + :href (get br "href") :hx-select hx-select + :bg-cls (get br "bg-cls") :name-cls (get br "name-cls") + :name (get br "name") :count (get br "count"))) + items)))) + +;; Subcategory selector from data +(defcomp ~market-filter-subcategories-from-data (&key items hx-select all-href current-sub) + (~market-filter-subcategory-panel :items + (<> + (~market-filter-subcategory-item + :href all-href :hx-select hx-select + :active-cls (if (not current-sub) " bg-stone-200 font-medium" "") + :name "All") + (map (lambda (sub) + (~market-filter-subcategory-item + :href (get sub "href") :hx-select hx-select + :active-cls (get sub "active-cls") :name (get sub "name"))) + items)))) + +;; Desktop filter panel from data +(defcomp ~market-desktop-filter-from-data (&key search-sx category-label + sort-data like-data label-data + sticker-data brand-data sub-data hx-select) + (<> + search-sx + (~market-desktop-category-summary :inner + (<> + (~market-filter-category-label :label category-label) + (when sort-data (~market-filter-sort-from-data :items sort-data)) + (~market-filter-like-labels-nav :inner + (<> + (~market-filter-like-from-data + :href (get like-data "href") :hx-select hx-select + :liked (get like-data "liked") :mobile false) + (when label-data + (~market-filter-labels-from-data :items label-data :hx-select hx-select)))) + (when sticker-data + (~market-filter-stickers-from-data :items sticker-data :hx-select hx-select)) + (when sub-data + (~market-filter-subcategories-from-data + :items (get sub-data "items") :hx-select hx-select + :all-href (get sub-data "all-href") + :current-sub (get sub-data "current-sub"))))) + (~market-desktop-brand-summary + :inner (when brand-data + (~market-filter-brands-from-data :items brand-data :hx-select hx-select))))) + +;; Mobile filter chips from active filter data +(defcomp ~market-mobile-chips-from-data (&key sort-chip liked-chip label-chips sticker-chips brand-chips) + (~market-mobile-chips-row :inner + (<> + (when sort-chip + (~market-mobile-chip-sort :src (get sort-chip "src") :label (get sort-chip "label"))) + (when liked-chip + (~market-mobile-chip-liked :inner + (<> + (~market-mobile-chip-liked-icon) + (when (get liked-chip "count") + (~market-mobile-chip-count + :cls (get liked-chip "count-cls") :count (get liked-chip "count")))))) + (when label-chips + (~market-mobile-chip-list :items + (<> (map (lambda (lc) + (~market-mobile-chip-item :inner + (<> + (~market-mobile-chip-image :src (get lc "src") :name (get lc "name")) + (when (get lc "count") + (~market-mobile-chip-count :cls (get lc "count-cls") :count (get lc "count")))))) + label-chips)))) + (when sticker-chips + (~market-mobile-chip-list :items + (<> (map (lambda (sc) + (~market-mobile-chip-item :inner + (<> + (~market-mobile-chip-image :src (get sc "src") :name (get sc "name")) + (when (get sc "count") + (~market-mobile-chip-count :cls (get sc "count-cls") :count (get sc "count")))))) + sticker-chips)))) + (when brand-chips + (~market-mobile-chip-brand-list :items + (<> (map (lambda (bc) + (if (get bc "has-count") + (~market-mobile-chip-brand :name (get bc "name") :count (get bc "count")) + (~market-mobile-chip-brand-zero :name (get bc "name")))) + brand-chips))))))) + +;; Mobile filter content (expanded panel) from data +(defcomp ~market-mobile-filter-content-from-data (&key sort-data like-data label-data + sticker-data brand-data clear-href hx-select) + (<> + (when sort-data (~market-filter-sort-from-data :items sort-data)) + (when clear-href + (~market-mobile-clear-filters :href clear-href :hx-select hx-select)) + (~market-mobile-like-labels-row :inner + (<> + (~market-filter-like-from-data + :href (get like-data "href") :hx-select hx-select + :liked (get like-data "liked") :mobile true) + (when label-data + (~market-filter-labels-from-data :items label-data :hx-select hx-select)))) + (when sticker-data + (~market-filter-stickers-from-data :items sticker-data :hx-select hx-select)) + (when brand-data + (~market-filter-brands-from-data :items brand-data :hx-select hx-select)))) diff --git a/market/sx/headers.sx b/market/sx/headers.sx index b88c567..0f16ac0 100644 --- a/market/sx/headers.sx +++ b/market/sx/headers.sx @@ -15,3 +15,64 @@ :class "px-2 py-1 text-stone-500 hover:text-stone-700" (i :class "fa fa-cog" :aria-hidden "true"))) + +;; --------------------------------------------------------------------------- +;; Composition defcomps — receive data, compose component trees +;; --------------------------------------------------------------------------- + +;; Desktop category nav from pre-computed category data +(defcomp ~market-desktop-nav-from-data (&key categories hx-select select-colours + all-href all-active admin-href) + (~market-desktop-category-nav + :links (<> + (~market-category-link :href all-href :hx-select hx-select + :active all-active :select-colours select-colours :label "All") + (map (lambda (cat) + (~market-category-link + :href (get cat "href") :hx-select hx-select + :active (get cat "active") :select-colours select-colours + :label (get cat "label"))) categories)) + :admin (when admin-href + (~market-admin-link :href admin-href :hx-select hx-select)))) + +;; Market-level header row from data +(defcomp ~market-header-from-data (&key market-title top-slug sub-slug link-href + categories hx-select select-colours + all-href all-active admin-href oob) + (~menu-row-sx :id "market-row" :level 2 + :link-href link-href + :link-label-content (~market-shop-label + :title market-title :top-slug (or top-slug "") :sub-div sub-slug) + :nav (~market-desktop-nav-from-data + :categories categories :hx-select hx-select :select-colours select-colours + :all-href all-href :all-active all-active :admin-href admin-href) + :child-id "market-header-child" + :oob oob)) + +;; Product-level header row from data +(defcomp ~market-product-header-from-data (&key title link-href hx-select + price-data admin-href oob) + (~menu-row-sx :id "product-row" :level 3 + :link-href link-href + :link-label-content (~market-product-label :title title) + :nav (<> + (~market-prices-header-from-data + :cart-id (get price-data "cart-id") + :cart-action (get price-data "cart-action") + :csrf (get price-data "csrf") + :quantity (get price-data "quantity") + :cart-href (get price-data "cart-href") + :sp-val (get price-data "sp-val") :sp-str (get price-data "sp-str") + :rp-val (get price-data "rp-val") :rp-str (get price-data "rp-str") + :rrp-str (get price-data "rrp-str")) + (when admin-href + (~market-admin-link :href admin-href :hx-select hx-select))) + :child-id "product-header-child" + :oob oob)) + +;; Product admin header row from data +(defcomp ~market-product-admin-header-from-data (&key link-href oob) + (~menu-row-sx :id "product-admin-row" :level 4 + :link-href link-href :link-label "admin!!" :icon "fa fa-cog" + :child-id "product-admin-header-child" :oob oob)) + diff --git a/market/sx/meta.sx b/market/sx/meta.sx index 9a6e143..28fb7e4 100644 --- a/market/sx/meta.sx +++ b/market/sx/meta.sx @@ -17,3 +17,35 @@ (defcomp ~market-meta-jsonld (&key json) (script :type "application/ld+json" (~rich-text :html json))) + + +;; --------------------------------------------------------------------------- +;; Composition: all product meta tags from data +;; --------------------------------------------------------------------------- + +(defcomp ~market-product-meta-from-data (&key title description canonical image-url + site-title brand price price-currency + jsonld-json) + (<> + (~market-meta-title :title title) + (~market-meta-description :description description) + (when canonical (~market-meta-canonical :href canonical)) + ;; OpenGraph + (~market-meta-og :property "og:site_name" :content site-title) + (~market-meta-og :property "og:type" :content "product") + (~market-meta-og :property "og:title" :content title) + (~market-meta-og :property "og:description" :content description) + (when canonical (~market-meta-og :property "og:url" :content canonical)) + (when image-url (~market-meta-og :property "og:image" :content image-url)) + (when (and price price-currency) + (<> (~market-meta-og :property "product:price:amount" :content price) + (~market-meta-og :property "product:price:currency" :content price-currency))) + (when brand (~market-meta-og :property "product:brand" :content brand)) + ;; Twitter + (~market-meta-twitter :name "twitter:card" + :content (if image-url "summary_large_image" "summary")) + (~market-meta-twitter :name "twitter:title" :content title) + (~market-meta-twitter :name "twitter:description" :content description) + (when image-url (~market-meta-twitter :name "twitter:image" :content image-url)) + ;; JSON-LD + (~market-meta-jsonld :json jsonld-json))) diff --git a/market/sx/navigation.sx b/market/sx/navigation.sx index 82c6221..0e2b60f 100644 --- a/market/sx/navigation.sx +++ b/market/sx/navigation.sx @@ -61,3 +61,37 @@ (defcomp ~market-mobile-cat-details (&key open summary subs) (details :class "group/cat py-1" :open open summary subs)) + + +;; --------------------------------------------------------------------------- +;; Composition: mobile nav panel from pre-computed category data +;; --------------------------------------------------------------------------- + +(defcomp ~market-mobile-nav-from-data (&key categories all-href all-active hx-select select-colours) + (~market-mobile-nav-wrapper :items + (<> + (~market-mobile-all-link :href all-href :hx-select hx-select + :active all-active :select-colours select-colours) + (map (lambda (cat) + (~market-mobile-cat-details + :open (get cat "active") + :summary (~market-mobile-cat-summary + :bg-cls (if (get cat "active") " bg-stone-900 text-white hover:bg-stone-900" "") + :href (get cat "href") :hx-select hx-select + :select-colours select-colours :cat-name (get cat "name") + :count-label (str (get cat "count") " products") + :count-str (str (get cat "count")) + :chevron (~market-mobile-chevron)) + :subs (if (get cat "subs") + (~market-mobile-subs-panel :links + (<> (map (lambda (sub) + (~market-mobile-sub-link + :select-colours select-colours + :active (get sub "active") + :href (get sub "href") :hx-select hx-select + :label (get sub "label") + :count-label (str (get sub "count") " products") + :count-str (str (get sub "count")))) + (get cat "subs")))) + (~market-mobile-view-all :href (get cat "href") :hx-select hx-select)))) + categories)))) diff --git a/market/sx/prices.sx b/market/sx/prices.sx index ca6238e..8073223 100644 --- a/market/sx/prices.sx +++ b/market/sx/prices.sx @@ -32,3 +32,36 @@ (defcomp ~market-prices-row (&key inner) (div :class "flex flex-row items-center justify-between md:gap-2 md:px-2" inner)) + + +;; --------------------------------------------------------------------------- +;; Composition: prices header + cart button from data +;; --------------------------------------------------------------------------- + +(defcomp ~market-prices-header-from-data (&key cart-id cart-action csrf quantity cart-href + sp-val sp-str rp-val rp-str rrp-str) + (~market-prices-row :inner + (<> + (if quantity + (~market-cart-add-quantity :cart-id cart-id :action cart-action :csrf csrf + :minus-val (str (- quantity 1)) :plus-val (str (+ quantity 1)) + :quantity (str quantity) :cart-href cart-href) + (~market-cart-add-empty :cart-id cart-id :action cart-action :csrf csrf)) + (when sp-val + (<> (~market-header-price-special-label) + (~market-header-price-special :price sp-str) + (when rp-val (~market-header-price-strike :price rp-str)))) + (when (and (not sp-val) rp-val) + (<> (~market-header-price-regular-label) + (~market-header-price-regular :price rp-str))) + (when rrp-str (~market-header-rrp :rrp rrp-str))))) + +;; Card price line from data (used in product cards) +(defcomp ~market-card-price-from-data (&key sp-val sp-str rp-val rp-str) + (~market-price-line :inner + (<> + (when sp-val + (<> (~market-price-special :price sp-str) + (when rp-val (~market-price-regular-strike :price rp-str)))) + (when (and (not sp-val) rp-val) + (~market-price-regular :price rp-str))))) diff --git a/market/sxc/pages/cards.py b/market/sxc/pages/cards.py index 9ca6ad0..460ea3e 100644 --- a/market/sxc/pages/cards.py +++ b/market/sxc/pages/cards.py @@ -1,9 +1,8 @@ -"""Product/market card builders.""" +"""Product/market card data 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 @@ -11,11 +10,11 @@ from .filters import _MOBILE_SENTINEL_HS, _DESKTOP_SENTINEL_HS # --------------------------------------------------------------------------- -# Product card (browse grid item) +# Product card data extraction # --------------------------------------------------------------------------- -def _product_card_sx(p: dict, ctx: dict) -> str: - """Build a single product card for browse grid as sx call.""" +def _product_card_data(p: dict, ctx: dict) -> dict: + """Extract data for a single product card.""" from quart import url_for from shared.browser.app.csrf import generate_csrf_token from shared.utils import route_prefix @@ -49,7 +48,8 @@ def _product_card_sx(p: dict, ctx: dict) -> str: 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}) + sticker_data.append({"src": asset_url_fn(f"stickers/{s}.svg"), + "name": s, "ring-cls": ring}) # Title highlighting title = p.get("title", "") @@ -71,38 +71,37 @@ def _product_card_sx(p: dict, ctx: dict) -> str: 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), - ) + d: dict[str, Any] = { + "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 + d["labels"] = label_srcs elif labels: - kwargs["labels"] = labels + d["labels"] = labels if user: - kwargs["liked"] = p.get("is_liked", False) - kwargs["like_action"] = url_for("market.browse.product.like_toggle", product_slug=slug) + d["liked"] = p.get("is_liked", False) + d["like-action"] = url_for("market.browse.product.like_toggle", product_slug=slug) if sticker_data: - kwargs["stickers"] = sticker_data + d["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 + d["has-highlight"] = True + d["search-pre"] = search_pre + d["search-mid"] = search_mid + d["search-post"] = search_post - return sx_call("market-product-card", **kwargs) + return d def _product_cards_sx(ctx: dict) -> str: - """S-expression wire format for product cards (client renders).""" + """S-expression wire format for product cards — delegates to .sx defcomp.""" from shared.utils import route_prefix prefix = route_prefix() @@ -112,48 +111,46 @@ def _product_cards_sx(ctx: dict) -> str: 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)) + product_data = [_product_card_data(p, ctx) for p in products] + next_url = "" 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) + ")" + return sx_call("market-product-cards-content", + products=product_data, + page=page, + total_pages=total_pages, + next_url=next_url, + mobile_sentinel_hs=_MOBILE_SENTINEL_HS, + desktop_sentinel_hs=_DESKTOP_SENTINEL_HS) -def _like_button_sx(slug: str, liked: bool, csrf: str, ctx: dict) -> str: - """Build the like/unlike heart button overlay as sx.""" +def _like_button_data(slug: str, liked: bool, csrf: str, ctx: dict) -> dict: + """Extract like button data.""" 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, - ) + return { + "form-id": f"like-{slug}", + "action": action, + "slug": slug, + "csrf": csrf, + "icon-cls": icon_cls, + } # --------------------------------------------------------------------------- -# Market cards (all markets / page markets) +# Market cards data extraction # --------------------------------------------------------------------------- -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.""" +def _market_card_data(market: Any, page_info: dict, *, show_page_badge: bool = True, + post_slug: str = "") -> dict: + """Extract data for a single market card.""" from shared.infrastructure.urls import market_url name = getattr(market, "name", "") @@ -161,78 +158,60 @@ def _market_card_sx(market: Any, page_info: dict, *, show_page_badge: bool = Tru slug = getattr(market, "slug", "") container_id = getattr(market, "container_id", None) + href = "" + badge_href = "" + badge_title = "" + 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 "" + href = market_url(f"/{p_slug}/{slug}/") if p_slug else "" + if p_title: + badge_href = market_url(f"/{p_slug}/") + badge_title = p_title else: p_slug = post_slug - p_title = "" - market_href = market_url(f"/{post_slug}/{slug}/") if post_slug else "" + 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, - ) + return { + "name": name, + "description": description, + "href": href, + "show-badge": show_page_badge, + "badge-href": badge_href, + "badge-title": badge_title, + } 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) + ")" + next_url: str, *, show_page_badge: bool = True, + post_slug: str = "") -> str: + """Build market cards as sx — delegates to .sx defcomp.""" + market_data = [_market_card_data(m, page_info, show_page_badge=show_page_badge, + post_slug=post_slug) for m in markets] + return sx_call("market-cards-content", + markets=market_data, + page=page, + has_more=has_more, + next_url=next_url) def _markets_grid(cards_sx: str) -> str: """Wrap market cards in a grid as sx.""" + from shared.sx.parser import SxExpr 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") + 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)) + """Build market landing page content — delegates to .sx defcomp.""" + return sx_call("market-landing-from-data", + excerpt=post.get("custom_excerpt") or None, + feature_image=post.get("feature_image") or None, + html=post.get("html") or None) diff --git a/market/sxc/pages/filters.py b/market/sxc/pages/filters.py index 8637fc2..56f3bac 100644 --- a/market/sxc/pages/filters.py +++ b/market/sxc/pages/filters.py @@ -1,4 +1,4 @@ -"""Filter panel functions (mobile + desktop).""" +"""Filter panel data extraction (mobile + desktop).""" from __future__ import annotations from shared.sx.parser import SxExpr @@ -77,365 +77,308 @@ _DESKTOP_SENTINEL_HS = ( # --------------------------------------------------------------------------- -# Browse filter panels (mobile + desktop) +# Filter data extraction helpers # --------------------------------------------------------------------------- -async def _desktop_filter_sx(ctx: dict) -> str: - """Build the desktop aside filter panel as sx.""" - category_label = ctx.get("category_label", "") - sort_options = ctx.get("sort_options", []) - sort = ctx.get("sort", "") - labels = ctx.get("labels", []) - selected_labels = ctx.get("selected_labels", []) - stickers = ctx.get("stickers", []) - selected_stickers = ctx.get("selected_stickers", []) - brands = ctx.get("brands", []) - selected_brands = ctx.get("selected_brands", []) - liked = ctx.get("liked", False) - liked_count = ctx.get("liked_count", 0) - subs_local = ctx.get("subs_local", []) - top_local_href = ctx.get("top_local_href", "") - sub_slug = ctx.get("sub_slug", "") - - # Search - search_sx = await search_desktop_sx(ctx) - - # Category summary + sort + like + labels + stickers - cat_parts = [sx_call("market-filter-category-label", label=category_label)] - - if sort_options: - cat_parts.append(_sort_stickers_sx(sort_options, sort, ctx)) - - like_label_parts = [_like_filter_sx(liked, liked_count, ctx)] - if labels: - like_label_parts.append(_labels_filter_sx(labels, selected_labels, ctx, prefix="nav-labels")) - like_labels_sx = "(<> " + " ".join(like_label_parts) + ")" - cat_parts.append(sx_call("market-filter-like-labels-nav", inner=SxExpr(like_labels_sx))) - - if stickers: - cat_parts.append(_stickers_filter_sx(stickers, selected_stickers, ctx)) - - if subs_local and top_local_href: - cat_parts.append(_subcategory_selector_sx(subs_local, top_local_href, sub_slug, ctx)) - - cat_inner_sx = "(<> " + " ".join(cat_parts) + ")" - cat_summary = sx_call("market-desktop-category-summary", inner=SxExpr(cat_inner_sx)) - - # Brand filter - brand_inner = "" - if brands: - brand_inner = _brand_filter_sx(brands, selected_brands, ctx) - brand_summary = sx_call("market-desktop-brand-summary", - inner=brand_inner or None) - - return "(<> " + " ".join([search_sx, cat_summary, brand_summary]) + ")" - - -async def _mobile_filter_summary_sx(ctx: dict) -> str: - """Build mobile filter summary as sx.""" - asset_url_fn = ctx.get("asset_url") - sort = ctx.get("sort", "") - sort_options = ctx.get("sort_options", []) - liked = ctx.get("liked", False) - liked_count = ctx.get("liked_count", 0) - selected_labels = ctx.get("selected_labels", []) - selected_stickers = ctx.get("selected_stickers", []) - selected_brands = ctx.get("selected_brands", []) - labels = ctx.get("labels", []) - stickers = ctx.get("stickers", []) - brands = ctx.get("brands", []) - - # Search bar - search_bar = await search_mobile_sx(ctx) - - # Summary chips showing active filters - chip_parts: list[str] = [] - - if sort and sort_options: - for k, l, i in sort_options: - if k == sort and callable(asset_url_fn): - chip_parts.append(sx_call("market-mobile-chip-sort", src=asset_url_fn(i), label=l)) - if liked: - liked_parts = [sx_call("market-mobile-chip-liked-icon")] - if liked_count is not None: - cls = "text-[10px] text-stone-500" if liked_count != 0 else "text-md text-red-500 font-bold" - liked_parts.append(sx_call("market-mobile-chip-count", cls=cls, count=str(liked_count))) - liked_inner = "(<> " + " ".join(liked_parts) + ")" - chip_parts.append(sx_call("market-mobile-chip-liked", inner=SxExpr(liked_inner))) - - # Selected labels - if selected_labels: - label_item_parts = [] - for sl in selected_labels: - for lb in labels: - if lb.get("name") == sl and callable(asset_url_fn): - li_parts = [sx_call( - "market-mobile-chip-image", - src=asset_url_fn("nav-labels/" + sl + ".svg"), name=sl, - )] - if lb.get("count") is not None: - cls = "text-[10px] text-stone-500" if lb["count"] != 0 else "text-md text-red-500 font-bold" - li_parts.append(sx_call("market-mobile-chip-count", cls=cls, count=str(lb["count"]))) - li_inner = "(<> " + " ".join(li_parts) + ")" - label_item_parts.append(sx_call("market-mobile-chip-item", inner=SxExpr(li_inner))) - if label_item_parts: - label_items = "(<> " + " ".join(label_item_parts) + ")" - chip_parts.append(sx_call("market-mobile-chip-list", items=SxExpr(label_items))) - - # Selected stickers - if selected_stickers: - sticker_item_parts = [] - for ss in selected_stickers: - for st in stickers: - if st.get("name") == ss and callable(asset_url_fn): - si_parts = [sx_call( - "market-mobile-chip-image", - src=asset_url_fn("stickers/" + ss + ".svg"), name=ss, - )] - if st.get("count") is not None: - cls = "text-[10px] text-stone-500" if st["count"] != 0 else "text-md text-red-500 font-bold" - si_parts.append(sx_call("market-mobile-chip-count", cls=cls, count=str(st["count"]))) - si_inner = "(<> " + " ".join(si_parts) + ")" - sticker_item_parts.append(sx_call("market-mobile-chip-item", inner=SxExpr(si_inner))) - if sticker_item_parts: - sticker_items = "(<> " + " ".join(sticker_item_parts) + ")" - chip_parts.append(sx_call("market-mobile-chip-list", items=SxExpr(sticker_items))) - - # Selected brands - if selected_brands: - brand_item_parts = [] - for b in selected_brands: - count = 0 - for br in brands: - if br.get("name") == b: - count = br.get("count", 0) - if count: - brand_item_parts.append(sx_call("market-mobile-chip-brand", name=b, count=str(count))) - else: - brand_item_parts.append(sx_call("market-mobile-chip-brand-zero", name=b)) - brand_items = "(<> " + " ".join(brand_item_parts) + ")" - chip_parts.append(sx_call("market-mobile-chip-brand-list", items=SxExpr(brand_items))) - - chips_sx = "(<> " + " ".join(chip_parts) + ")" if chip_parts else '(<>)' - chips_row = sx_call("market-mobile-chips-row", inner=SxExpr(chips_sx)) - - # Full mobile filter details +def _filter_common(ctx: dict) -> tuple: + """Extract common filter params from context.""" from shared.utils import route_prefix prefix = route_prefix() - mobile_filter = _mobile_filter_content_sx(ctx, prefix) - - return sx_call( - "market-mobile-filter-summary", - search_bar=search_bar, - chips=chips_row, - filter=SxExpr(mobile_filter), - ) - - -def _mobile_filter_content_sx(ctx: dict, prefix: str) -> str: - """Build the expanded mobile filter panel contents as sx.""" - selected_labels = ctx.get("selected_labels", []) - selected_stickers = ctx.get("selected_stickers", []) - selected_brands = ctx.get("selected_brands", []) - current_local_href = ctx.get("current_local_href", "/") - hx_select = ctx.get("hx_select_search", "#main-panel") - sort_options = ctx.get("sort_options", []) - sort = ctx.get("sort", "") - liked = ctx.get("liked", False) - liked_count = ctx.get("liked_count", 0) - labels = ctx.get("labels", []) - stickers = ctx.get("stickers", []) - brands = ctx.get("brands", []) - search = ctx.get("search", "") - qs_fn = ctx.get("qs_filter") - - parts: list[str] = [] - - # Sort options - if sort_options: - parts.append(_sort_stickers_sx(sort_options, sort, ctx, mobile=True)) - - # Clear filters button - has_filters = search or selected_labels or selected_stickers or selected_brands - if has_filters and callable(qs_fn): - clear_url = prefix + current_local_href + qs_fn({"clear_filters": True}) - parts.append(sx_call("market-mobile-clear-filters", href=clear_url, hx_select=hx_select)) - - # Like + labels row - like_label_parts = [_like_filter_sx(liked, liked_count, ctx, mobile=True)] - if labels: - like_label_parts.append(_labels_filter_sx(labels, selected_labels, ctx, prefix="nav-labels", mobile=True)) - like_labels_sx = "(<> " + " ".join(like_label_parts) + ")" - parts.append(sx_call("market-mobile-like-labels-row", inner=SxExpr(like_labels_sx))) - - # Stickers - if stickers: - parts.append(_stickers_filter_sx(stickers, selected_stickers, ctx, mobile=True)) - - # Brands - if brands: - parts.append(_brand_filter_sx(brands, selected_brands, ctx, mobile=True)) - - return "(<> " + " ".join(parts) + ")" if parts else "(<>)" - - -def _sort_stickers_sx(sort_options: list, current_sort: str, ctx: dict, mobile: bool = False) -> str: - """Build sort option stickers as sx.""" - asset_url_fn = ctx.get("asset_url") current_local_href = ctx.get("current_local_href", "/") hx_select = ctx.get("hx_select_search", "#main-panel") qs_fn = ctx.get("qs_filter") - from shared.utils import route_prefix - prefix = route_prefix() + asset_url_fn = ctx.get("asset_url") + return prefix, current_local_href, hx_select, qs_fn, asset_url_fn - item_parts: list[str] = [] + +def _sort_data(sort_options: list, current_sort: str, ctx: dict) -> list: + """Extract sort option data for .sx composition.""" + prefix, current_local_href, hx_select, qs_fn, asset_url_fn = _filter_common(ctx) + items = [] for k, label, icon in sort_options: - if callable(qs_fn): - href = prefix + current_local_href + qs_fn({"sort": k}) - else: - href = "#" - active = (k == current_sort) + href = prefix + current_local_href + qs_fn({"sort": k}) if callable(qs_fn) else "#" + 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 - item_parts.append(sx_call( - "market-filter-sort-item", - href=href, hx_select=hx_select, ring_cls=ring, src=src, label=label, - )) - items_sx = "(<> " + " ".join(item_parts) + ")" if item_parts else "(<>)" - return sx_call("market-filter-sort-row", items=SxExpr(items_sx)) + items.append({"href": href, "hx-select": hx_select, + "ring-cls": ring, "src": src, "label": label}) + return items -def _like_filter_sx(liked: bool, liked_count: int, ctx: dict, mobile: bool = False) -> str: - """Build the like filter toggle as sx.""" - current_local_href = ctx.get("current_local_href", "/") - hx_select = ctx.get("hx_select_search", "#main-panel") - qs_fn = ctx.get("qs_filter") - from shared.utils import route_prefix - prefix = route_prefix() - - if callable(qs_fn): - href = prefix + current_local_href + qs_fn({"liked": not liked}) - else: - href = "#" - - icon_cls = "fa-solid fa-heart text-red-500" if liked else "fa-regular fa-heart text-stone-400" - size = "text-[40px]" if mobile else "text-2xl" - return sx_call( - "market-filter-like", - href=href, hx_select=hx_select, icon_cls=icon_cls, size_cls=size, - ) +def _like_data(liked: bool, liked_count: int, ctx: dict) -> dict: + """Extract like filter data for .sx composition.""" + prefix, current_local_href, hx_select, qs_fn, _ = _filter_common(ctx) + href = prefix + current_local_href + qs_fn({"liked": not liked}) if callable(qs_fn) else "#" + return {"href": href, "hx-select": hx_select, "liked": liked} -def _labels_filter_sx(labels: list, selected: list, ctx: dict, *, - prefix: str = "nav-labels", mobile: bool = False) -> str: - """Build label filter buttons as sx.""" - asset_url_fn = ctx.get("asset_url") - current_local_href = ctx.get("current_local_href", "/") - hx_select = ctx.get("hx_select_search", "#main-panel") - qs_fn = ctx.get("qs_filter") - from shared.utils import route_prefix - rp = route_prefix() - - item_parts: list[str] = [] +def _label_data(labels: list, selected: list, ctx: dict, *, img_prefix: str = "nav-labels") -> list: + """Extract label filter data for .sx composition.""" + prefix, current_local_href, hx_select, qs_fn, asset_url_fn = _filter_common(ctx) + items = [] for lb in labels: name = lb.get("name", "") is_sel = name in selected if callable(qs_fn): new_sel = [s for s in selected if s != name] if is_sel else selected + [name] - href = rp + current_local_href + qs_fn({"labels": new_sel}) + href = prefix + current_local_href + qs_fn({"labels": new_sel}) else: href = "#" ring = " ring-2 ring-emerald-500 rounded" if is_sel else "" - src = asset_url_fn(f"{prefix}/{name}.svg") if callable(asset_url_fn) else "" - item_parts.append(sx_call( - "market-filter-label-item", - href=href, hx_select=hx_select, ring_cls=ring, src=src, name=name, - )) - return "(<> " + " ".join(item_parts) + ")" if item_parts else "(<>)" + src = asset_url_fn(f"{img_prefix}/{name}.svg") if callable(asset_url_fn) else "" + items.append({"href": href, "hx-select": hx_select, + "ring-cls": ring, "src": src, "name": name}) + return items -def _stickers_filter_sx(stickers: list, selected: list, ctx: dict, mobile: bool = False) -> str: - """Build sticker filter grid as sx.""" - asset_url_fn = ctx.get("asset_url") - current_local_href = ctx.get("current_local_href", "/") - hx_select = ctx.get("hx_select_search", "#main-panel") - qs_fn = ctx.get("qs_filter") - from shared.utils import route_prefix - rp = route_prefix() - - item_parts: list[str] = [] +def _sticker_data(stickers: list, selected: list, ctx: dict) -> list: + """Extract sticker filter data for .sx composition.""" + prefix, current_local_href, hx_select, qs_fn, asset_url_fn = _filter_common(ctx) + items = [] for st in stickers: name = st.get("name", "") count = st.get("count", 0) is_sel = name in selected if callable(qs_fn): new_sel = [s for s in selected if s != name] if is_sel else selected + [name] - href = rp + current_local_href + qs_fn({"stickers": new_sel}) + href = prefix + current_local_href + qs_fn({"stickers": new_sel}) else: href = "#" ring = " ring-2 ring-emerald-500 rounded" if is_sel else "" src = asset_url_fn(f"stickers/{name}.svg") if callable(asset_url_fn) else "" cls = "text-[10px] text-stone-500" if count != 0 else "text-md text-red-500 font-bold" - item_parts.append(sx_call( - "market-filter-sticker-item", - href=href, hx_select=hx_select, ring_cls=ring, - src=src, name=name, count_cls=cls, count=str(count), - )) - items_sx = "(<> " + " ".join(item_parts) + ")" if item_parts else "(<>)" - return sx_call("market-filter-stickers-row", items=SxExpr(items_sx)) + items.append({"href": href, "hx-select": hx_select, "ring-cls": ring, + "src": src, "name": name, "count-cls": cls, "count": str(count)}) + return items -def _brand_filter_sx(brands: list, selected: list, ctx: dict, mobile: bool = False) -> str: - """Build brand filter checkboxes as sx.""" - current_local_href = ctx.get("current_local_href", "/") - hx_select = ctx.get("hx_select_search", "#main-panel") - qs_fn = ctx.get("qs_filter") - from shared.utils import route_prefix - rp = route_prefix() - - item_parts: list[str] = [] +def _brand_data(brands: list, selected: list, ctx: dict) -> list: + """Extract brand filter data for .sx composition.""" + prefix, current_local_href, hx_select, qs_fn, _ = _filter_common(ctx) + items = [] for br in brands: name = br.get("name", "") count = br.get("count", 0) is_sel = name in selected if callable(qs_fn): new_sel = [s for s in selected if s != name] if is_sel else selected + [name] - href = rp + current_local_href + qs_fn({"brands": new_sel}) + href = prefix + current_local_href + qs_fn({"brands": new_sel}) else: href = "#" bg = " bg-yellow-200" if is_sel else "" cls = "text-md" if count else "text-md text-red-500" - item_parts.append(sx_call( - "market-filter-brand-item", - href=href, hx_select=hx_select, bg_cls=bg, - name_cls=cls, name=name, count=str(count), - )) - items_sx = "(<> " + " ".join(item_parts) + ")" if item_parts else "(<>)" - return sx_call("market-filter-brands-panel", items=SxExpr(items_sx)) + items.append({"href": href, "hx-select": hx_select, "bg-cls": bg, + "name-cls": cls, "name": name, "count": str(count)}) + return items -def _subcategory_selector_sx(subs: list, top_href: str, current_sub: str, ctx: dict) -> str: - """Build subcategory vertical nav as sx.""" - hx_select = ctx.get("hx_select_search", "#main-panel") +def _subcategory_data(subs: list, top_href: str, current_sub: str, ctx: dict) -> dict: + """Extract subcategory filter data for .sx composition.""" from shared.utils import route_prefix rp = route_prefix() + hx_select = ctx.get("hx_select_search", "#main-panel") - all_cls = " bg-stone-200 font-medium" if not current_sub else "" - all_full_href = rp + top_href - item_parts = [sx_call( - "market-filter-subcategory-item", - href=all_full_href, hx_select=hx_select, active_cls=all_cls, name="All", - )] + items = [] for sub in subs: slug = sub.get("slug", "") - name = sub.get("name", "") - href = sub.get("href", "") - active = (slug == current_sub) + active = slug == current_sub active_cls = " bg-stone-200 font-medium" if active else "" - full_href = rp + href - item_parts.append(sx_call( - "market-filter-subcategory-item", - href=full_href, hx_select=hx_select, active_cls=active_cls, name=name, - )) - items_sx = "(<> " + " ".join(item_parts) + ")" - return sx_call("market-filter-subcategory-panel", items=SxExpr(items_sx)) + items.append({"href": rp + sub.get("href", ""), "hx-select": hx_select, + "active-cls": active_cls, "name": sub.get("name", "")}) + return {"items": items, "all-href": rp + top_href, "current-sub": current_sub} + + +# --------------------------------------------------------------------------- +# Desktop filter panel +# --------------------------------------------------------------------------- + +async def _desktop_filter_sx(ctx: dict) -> str: + """Build the desktop aside filter panel — delegates to .sx defcomp.""" + hx_select = ctx.get("hx_select_search", "#main-panel") + + # Search (still uses render_to_sx for shared component) + search_sx = await search_desktop_sx(ctx) + + # Sort data + sort_options = ctx.get("sort_options", []) + sort = ctx.get("sort", "") + sd = _sort_data(sort_options, sort, ctx) if sort_options else None + + # Like data + ld = _like_data(ctx.get("liked", False), ctx.get("liked_count", 0), ctx) + + # Labels data + labels = ctx.get("labels", []) + selected_labels = ctx.get("selected_labels", []) + lab_d = _label_data(labels, selected_labels, ctx) if labels else None + + # Stickers data + stickers = ctx.get("stickers", []) + selected_stickers = ctx.get("selected_stickers", []) + st_d = _sticker_data(stickers, selected_stickers, ctx) if stickers else None + + # Brands data + brands = ctx.get("brands", []) + selected_brands = ctx.get("selected_brands", []) + br_d = _brand_data(brands, selected_brands, ctx) if brands else None + + # Subcategory data + subs_local = ctx.get("subs_local", []) + top_local_href = ctx.get("top_local_href", "") + sub_slug = ctx.get("sub_slug", "") + sub_d = (_subcategory_data(subs_local, top_local_href, sub_slug, ctx) + if subs_local and top_local_href else None) + + return sx_call("market-desktop-filter-from-data", + search_sx=SxExpr(search_sx), + category_label=ctx.get("category_label", ""), + sort_data=sd, + like_data=ld, + label_data=lab_d, + sticker_data=st_d, + brand_data=br_d, + sub_data=sub_d, + hx_select=hx_select) + + +# --------------------------------------------------------------------------- +# Mobile filter summary +# --------------------------------------------------------------------------- + +def _mobile_chips_data(ctx: dict) -> dict: + """Extract mobile filter chip data for .sx composition.""" + asset_url_fn = ctx.get("asset_url") + sort = ctx.get("sort", "") + sort_options = ctx.get("sort_options", []) + liked = ctx.get("liked", False) + liked_count = ctx.get("liked_count", 0) + selected_labels = ctx.get("selected_labels", []) + selected_stickers = ctx.get("selected_stickers", []) + selected_brands = ctx.get("selected_brands", []) + labels = ctx.get("labels", []) + stickers = ctx.get("stickers", []) + brands = ctx.get("brands", []) + + # Sort chip + sort_chip = None + if sort and sort_options: + for k, l, i in sort_options: + if k == sort and callable(asset_url_fn): + sort_chip = {"src": asset_url_fn(i), "label": l} + + # Liked chip + liked_chip = None + if liked: + count_cls = ("text-[10px] text-stone-500" if liked_count != 0 + else "text-md text-red-500 font-bold") + liked_chip = {"count": str(liked_count) if liked_count is not None else None, + "count-cls": count_cls} + + # Label chips + label_chips = [] + if selected_labels: + for sl in selected_labels: + for lb in labels: + if lb.get("name") == sl and callable(asset_url_fn): + chip: dict = {"src": asset_url_fn("nav-labels/" + sl + ".svg"), "name": sl} + if lb.get("count") is not None: + cls = ("text-[10px] text-stone-500" if lb["count"] != 0 + else "text-md text-red-500 font-bold") + chip["count"] = str(lb["count"]) + chip["count-cls"] = cls + label_chips.append(chip) + + # Sticker chips + sticker_chips = [] + if selected_stickers: + for ss in selected_stickers: + for st in stickers: + if st.get("name") == ss and callable(asset_url_fn): + chip = {"src": asset_url_fn("stickers/" + ss + ".svg"), "name": ss} + if st.get("count") is not None: + cls = ("text-[10px] text-stone-500" if st["count"] != 0 + else "text-md text-red-500 font-bold") + chip["count"] = str(st["count"]) + chip["count-cls"] = cls + sticker_chips.append(chip) + + # Brand chips + brand_chips = [] + if selected_brands: + for b in selected_brands: + count = 0 + for br in brands: + if br.get("name") == b: + count = br.get("count", 0) + brand_chips.append({"name": b, "count": str(count), "has-count": bool(count)}) + + return { + "sort-chip": sort_chip, + "liked-chip": liked_chip, + "label-chips": label_chips or None, + "sticker-chips": sticker_chips or None, + "brand-chips": brand_chips or None, + } + + +def _mobile_filter_content_data(ctx: dict) -> dict: + """Extract mobile filter expanded panel data.""" + from shared.utils import route_prefix + prefix = route_prefix() + hx_select = ctx.get("hx_select_search", "#main-panel") + + sort_options = ctx.get("sort_options", []) + sort = ctx.get("sort", "") + sd = _sort_data(sort_options, sort, ctx) if sort_options else None + + ld = _like_data(ctx.get("liked", False), ctx.get("liked_count", 0), ctx) + + labels = ctx.get("labels", []) + selected_labels = ctx.get("selected_labels", []) + lab_d = _label_data(labels, selected_labels, ctx) if labels else None + + stickers = ctx.get("stickers", []) + selected_stickers = ctx.get("selected_stickers", []) + st_d = _sticker_data(stickers, selected_stickers, ctx) if stickers else None + + brands = ctx.get("brands", []) + selected_brands = ctx.get("selected_brands", []) + br_d = _brand_data(brands, selected_brands, ctx) if brands else None + + # Clear filters URL + clear_href = None + search = ctx.get("search", "") + has_filters = search or selected_labels or selected_stickers or selected_brands + qs_fn = ctx.get("qs_filter") + if has_filters and callable(qs_fn): + current_local_href = ctx.get("current_local_href", "/") + clear_href = prefix + current_local_href + qs_fn({"clear_filters": True}) + + return { + "sort-data": sd, + "like-data": ld, + "label-data": lab_d, + "sticker-data": st_d, + "brand-data": br_d, + "clear-href": clear_href, + "hx-select": hx_select, + } + + +async def _mobile_filter_summary_sx(ctx: dict) -> str: + """Build mobile filter summary — delegates to .sx defcomps.""" + # Search bar (still uses render_to_sx for shared component) + search_bar = await search_mobile_sx(ctx) + + # Chips data + chips_data = _mobile_chips_data(ctx) + chips = sx_call("market-mobile-chips-from-data", **chips_data) + + # Expanded filter content data + filter_data = _mobile_filter_content_data(ctx) + filter_content = sx_call("market-mobile-filter-content-from-data", **filter_data) + + return sx_call("market-mobile-filter-summary", + search_bar=SxExpr(search_bar), + chips=SxExpr(chips), + filter=SxExpr(filter_content)) diff --git a/market/sxc/pages/layouts.py b/market/sxc/pages/layouts.py index 9145a81..dd643c1 100644 --- a/market/sxc/pages/layouts.py +++ b/market/sxc/pages/layouts.py @@ -1,274 +1,197 @@ -"""Layout registration + header builders.""" +"""Layout registration + header data 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 # --------------------------------------------------------------------------- -# Header helpers +# Header data extraction — pure data, no component references # --------------------------------------------------------------------------- -def _market_header_sx(ctx: dict, *, oob: bool = False) -> str: - """Build the market-level header row as sx call string.""" - from quart import url_for - - market_title = ctx.get("market_title", "") - top_slug = ctx.get("top_slug", "") - sub_slug = ctx.get("sub_slug", "") - hx_select_search = ctx.get("hx_select_search", "#main-panel") - - label_sx = sx_call( - "market-shop-label", - title=market_title, top_slug=top_slug or "", - sub_div=sub_slug or None, - ) - - link_href = url_for("defpage_market_home") - - # Build desktop nav from categories - categories = ctx.get("categories", {}) - qs = ctx.get("qs", "") - nav_sx = _desktop_category_nav_sx(ctx, categories, qs, hx_select_search) - - return sx_call( - "menu-row-sx", - id="market-row", level=2, - link_href=link_href, link_label_content=label_sx, - nav=nav_sx or None, - child_id="market-header-child", oob=oob, - ) - - -def _desktop_category_nav_sx(ctx: dict, categories: dict, qs: str, - hx_select: str) -> str: - """Build desktop category navigation links as sx.""" +def _market_header_data(ctx: dict) -> dict: + """Extract market header data for .sx composition.""" from quart import url_for from shared.utils import route_prefix prefix = route_prefix() - category_label = ctx.get("category_label", "") + hx_select = ctx.get("hx_select_search", "#main-panel") select_colours = ctx.get("select_colours", "") rights = ctx.get("rights", {}) + qs = ctx.get("qs", "") + categories = ctx.get("categories", {}) all_href = prefix + url_for("market.browse.browse_all") + qs - all_active = (category_label == "All Products") - link_parts = [sx_call( - "market-category-link", - href=all_href, hx_select=hx_select, active=all_active, - select_colours=select_colours, label="All", - )] + cat_data = [] 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) - link_parts.append(sx_call( - "market-category-link", - href=cat_href, hx_select=hx_select, active=cat_active, - select_colours=select_colours, label=cat, - )) + cat_data.append({ + "href": cat_href, + "active": cat == ctx.get("category_label", ""), + "label": cat, + }) - links_sx = "(<> " + " ".join(link_parts) + ")" - - admin_sx = "" + admin_href = "" if rights and rights.get("admin"): admin_href = prefix + url_for("defpage_market_admin") - admin_sx = sx_call("market-admin-link", href=admin_href, hx_select=hx_select) - return sx_call("market-desktop-category-nav", - links=SxExpr(links_sx), - admin=admin_sx or None) + return { + "market-title": ctx.get("market_title", ""), + "top-slug": ctx.get("top_slug", ""), + "sub-slug": ctx.get("sub_slug", ""), + "link-href": url_for("defpage_market_home"), + "categories": cat_data, + "hx-select": hx_select, + "select-colours": select_colours, + "all-href": all_href, + "all-active": ctx.get("category_label", "") == "All Products", + "admin-href": admin_href, + } -def _product_header_sx(ctx: dict, d: dict, *, oob: bool = False) -> str: - """Build the product-level header row as sx call string.""" +def _market_header_sx(ctx: dict, *, oob: bool = False) -> str: + """Build market header as sx — delegates to .sx defcomp.""" + data = _market_header_data(ctx) + return sx_call("market-header-from-data", oob=oob, **data) + + +def _product_header_data(ctx: dict, d: dict) -> dict: + """Extract product header data for .sx composition.""" from quart import url_for from shared.browser.app.csrf import generate_csrf_token slug = d.get("slug", "") - title = d.get("title", "") - hx_select_search = ctx.get("hx_select_search", "#main-panel") - link_href = url_for("market.browse.product.product_detail", product_slug=slug) - - label_sx = sx_call("market-product-label", title=title) - - # Prices in nav area - pr = _set_prices(d) + hx_select = ctx.get("hx_select_search", "#main-panel") cart = ctx.get("cart", []) - prices_nav = _prices_header_sx(d, pr, cart, slug, ctx) - rights = ctx.get("rights", {}) - nav_parts = [prices_nav] - if rights and rights.get("admin"): - admin_href = url_for("market.browse.product.admin", product_slug=slug) - nav_parts.append(sx_call("market-admin-link", href=admin_href, hx_select=hx_select_search)) - nav_sx = "(<> " + " ".join(nav_parts) + ")" - return sx_call( - "menu-row-sx", - id="product-row", level=3, - link_href=link_href, link_label_content=label_sx, - nav=SxExpr(nav_sx), child_id="product-header-child", oob=oob, - ) - - -def _prices_header_sx(d: dict, pr: dict, cart: list, slug: str, ctx: dict) -> str: - """Build prices + add-to-cart for product header row as sx.""" - from quart import url_for - from shared.browser.app.csrf import generate_csrf_token - - csrf = generate_csrf_token() - cart_action = url_for("market.browse.product.cart", product_slug=slug) - cart_url_fn = ctx.get("cart_url") - - # Add-to-cart button - quantity = sum(ci.quantity for ci in cart if ci.product.slug == slug) if cart else 0 - add_sx = _cart_add_sx(slug, quantity, cart_action, csrf, cart_url_fn) - - parts = [add_sx] - sp_val, rp_val = pr.get("sp_val"), pr.get("rp_val") - if sp_val: - parts.append(sx_call("market-header-price-special-label")) - parts.append(sx_call("market-header-price-special", - price=_price_str(sp_val, pr["sp_raw"], pr["sp_cur"]))) - if rp_val: - parts.append(sx_call("market-header-price-strike", - price=_price_str(rp_val, pr["rp_raw"], pr["rp_cur"]))) - elif rp_val: - parts.append(sx_call("market-header-price-regular-label")) - parts.append(sx_call("market-header-price-regular", - price=_price_str(rp_val, pr["rp_raw"], pr["rp_cur"]))) + # Price data + pr = _set_prices(d) + sp_str = _price_str(pr["sp_val"], pr["sp_raw"], pr["sp_cur"]) if pr["sp_val"] else "" + rp_str = _price_str(pr["rp_val"], pr["rp_raw"], pr["rp_cur"]) if pr["rp_val"] else "" # RRP + rrp_str = "" rrp_raw = d.get("rrp_raw") rrp_val = d.get("rrp") case_size = d.get("case_size_count") or 1 if rrp_raw and rrp_val: rrp_str = f"{rrp_raw[0]}{rrp_val * case_size:.2f}" - parts.append(sx_call("market-header-rrp", rrp=rrp_str)) - - inner_sx = "(<> " + " ".join(parts) + ")" - return sx_call("market-prices-row", inner=SxExpr(inner_sx)) - - -def _cart_add_sx(slug: str, quantity: int, action: str, csrf: str, - cart_url_fn: Any = None) -> str: - """Build add-to-cart button or quantity controls as sx.""" - if not quantity: - return sx_call( - "market-cart-add-empty", - cart_id=f"cart-{slug}", action=action, csrf=csrf, - ) + # Cart state + csrf = generate_csrf_token() + cart_action = url_for("market.browse.product.cart", product_slug=slug) + quantity = sum(ci.quantity for ci in cart if ci.product.slug == slug) if cart else 0 + cart_url_fn = ctx.get("cart_url") cart_href = cart_url_fn("/") if callable(cart_url_fn) else "/" - return sx_call( - "market-cart-add-quantity", - cart_id=f"cart-{slug}", action=action, csrf=csrf, - minus_val=str(quantity - 1), plus_val=str(quantity + 1), - quantity=str(quantity), cart_href=cart_href, - ) + + admin_href = "" + if rights and rights.get("admin"): + admin_href = url_for("market.browse.product.admin", product_slug=slug) + + return { + "title": d.get("title", ""), + "link-href": url_for("market.browse.product.product_detail", product_slug=slug), + "hx-select": hx_select, + "price-data": { + "cart-id": f"cart-{slug}", + "cart-action": cart_action, + "csrf": csrf, + "quantity": quantity, + "cart-href": cart_href, + "sp-val": pr["sp_val"] or "", + "sp-str": sp_str, + "rp-val": pr["rp_val"] or "", + "rp-str": rp_str, + "rrp-str": rrp_str, + }, + "admin-href": admin_href, + } + + +def _product_header_sx(ctx: dict, d: dict, *, oob: bool = False) -> str: + """Build product header as sx — delegates to .sx defcomp.""" + data = _product_header_data(ctx, d) + return sx_call("market-product-header-from-data", oob=oob, **data) + + +def _product_admin_header_sx(ctx: dict, d: dict, *, oob: bool = False) -> str: + """Build product admin header as sx — delegates to .sx defcomp.""" + from quart import url_for + slug = d.get("slug", "") + link_href = url_for("market.browse.product.admin", product_slug=slug) + return sx_call("market-product-admin-header-from-data", + link_href=link_href, oob=oob) # --------------------------------------------------------------------------- -# Mobile nav panel +# Mobile nav panel data extraction # --------------------------------------------------------------------------- -def _mobile_nav_panel_sx(ctx: dict) -> str: - """Build mobile nav panel with category accordion as sx.""" +def _mobile_nav_data(ctx: dict) -> dict: + """Extract mobile nav panel data for .sx composition.""" from quart import url_for from shared.utils import route_prefix prefix = route_prefix() categories = ctx.get("categories", {}) qs = ctx.get("qs", "") - category_label = ctx.get("category_label", "") top_slug = ctx.get("top_slug", "") sub_slug = ctx.get("sub_slug", "") hx_select = ctx.get("hx_select_search", "#main-panel") select_colours = ctx.get("select_colours", "") all_href = prefix + url_for("market.browse.browse_all") + qs - all_active = (category_label == "All Products") - item_parts = [sx_call( - "market-mobile-all-link", - href=all_href, hx_select=hx_select, active=all_active, - select_colours=select_colours, - )] + cat_data = [] for cat, data in categories.items(): cat_slug = data.get("slug", "") - cat_active = (top_slug == cat_slug.lower() if top_slug else False) + cat_active = top_slug == cat_slug.lower() if top_slug else False cat_href = prefix + url_for("market.browse.browse_top", top_slug=cat_slug) + qs - bg_cls = " bg-stone-900 text-white hover:bg-stone-900" if cat_active else "" - - chevron_sx = sx_call("market-mobile-chevron") - - cat_count = data.get("count", 0) - summary_sx = sx_call( - "market-mobile-cat-summary", - bg_cls=bg_cls, href=cat_href, hx_select=hx_select, - select_colours=select_colours, cat_name=cat, - count_label=f"{cat_count} products", count_str=str(cat_count), - chevron=chevron_sx, - ) subs = data.get("subs", []) - subs_sx = "" + sub_data = [] if subs: - sub_link_parts = [] for sub in subs: - sub_href = prefix + url_for("market.browse.browse_sub", top_slug=cat_slug, sub_slug=sub["slug"]) + qs - sub_active = (cat_active and sub_slug == sub.get("slug")) + sub_href = prefix + url_for("market.browse.browse_sub", + top_slug=cat_slug, sub_slug=sub["slug"]) + qs + sub_active = cat_active and sub_slug == sub.get("slug") sub_label = sub.get("html_label") or sub.get("name", "") - sub_count = sub.get("count", 0) - sub_link_parts.append(sx_call( - "market-mobile-sub-link", - select_colours=select_colours, active=sub_active, - href=sub_href, hx_select=hx_select, label=sub_label, - count_label=f"{sub_count} products", count_str=str(sub_count), - )) - sub_links_sx = "(<> " + " ".join(sub_link_parts) + ")" - subs_sx = sx_call("market-mobile-subs-panel", links=SxExpr(sub_links_sx)) - else: - view_href = prefix + url_for("market.browse.browse_top", top_slug=cat_slug) + qs - subs_sx = sx_call("market-mobile-view-all", href=view_href, hx_select=hx_select) + sub_data.append({ + "href": sub_href, + "active": sub_active, + "label": sub_label, + "count": sub.get("count", 0), + }) - item_parts.append(sx_call( - "market-mobile-cat-details", - open=cat_active or None, - summary=summary_sx, - subs=subs_sx, - )) + cat_data.append({ + "name": cat, + "href": cat_href, + "active": cat_active, + "count": data.get("count", 0), + "subs": sub_data if sub_data else None, + }) - items_sx = "(<> " + " ".join(item_parts) + ")" - return sx_call("market-mobile-nav-wrapper", items=SxExpr(items_sx)) + return { + "categories": cat_data, + "all-href": all_href, + "all-active": ctx.get("category_label", "") == "All Products", + "hx-select": hx_select, + "select-colours": select_colours, + } + + +def _mobile_nav_panel_sx(ctx: dict) -> str: + """Build mobile nav panel as sx — delegates to .sx defcomp.""" + data = _mobile_nav_data(ctx) + return sx_call("market-mobile-nav-from-data", **data) # --------------------------------------------------------------------------- -# Product admin header -# --------------------------------------------------------------------------- - -def _product_admin_header_sx(ctx: dict, d: dict, *, oob: bool = False) -> str: - """Build product admin header row as sx.""" - from quart import url_for - - slug = d.get("slug", "") - link_href = url_for("market.browse.product.admin", product_slug=slug) - return sx_call( - "menu-row-sx", - id="product-admin-row", level=4, - link_href=link_href, link_label="admin!!", icon="fa fa-cog", - child_id="product-admin-header-child", oob=oob, - ) - - -# =========================================================================== # Layout registration — all layouts delegate to .sx defcomps -# =========================================================================== +# --------------------------------------------------------------------------- def _register_market_layouts() -> None: from shared.sx.layouts import register_sx_layout diff --git a/market/sxc/pages/renders.py b/market/sxc/pages/renders.py index ce6d8c6..2a4d1cf 100644 --- a/market/sxc/pages/renders.py +++ b/market/sxc/pages/renders.py @@ -11,7 +11,7 @@ from shared.sx.helpers import ( full_page_sx, oob_page_sx, ) -from .utils import _set_prices, _price_str, _clear_deeper_oob, _product_detail_sx, _product_meta_sx +from .utils import _clear_deeper_oob, _product_detail_sx, _product_meta_sx from .cards import _product_cards_sx, _market_cards_sx from .filters import _desktop_filter_sx, _mobile_filter_summary_sx from .layouts import ( @@ -36,9 +36,7 @@ async def render_browse_page(ctx: dict) -> str: content = _product_grid(cards) from shared.sx.helpers import render_to_sx_with_env - hdr = await render_to_sx_with_env("market-browse-layout-full", {}, - post_header=await _post_header_sx(ctx), - market_header=_market_header_sx(ctx)) + hdr = await render_to_sx_with_env("market-browse-layout-full", {}) menu = _mobile_nav_panel_sx(ctx) filter_sx = await _mobile_filter_summary_sx(ctx) aside_sx = await _desktop_filter_sx(ctx) @@ -52,13 +50,8 @@ async def render_browse_oob(ctx: dict) -> str: cards = _product_cards_sx(ctx) content = _product_grid(cards) - oob_hdr = await _oob_header_sx("post-header-child", "market-header-child", - _market_header_sx(ctx)) - oobs = sx_call("market-browse-layout-oob", - oob_header=oob_hdr, - post_header_oob=await _post_header_sx(ctx, oob=True), - clear_oob=SxExpr(_clear_deeper_oob("post-row", "post-header-child", - "market-row", "market-header-child"))) + # Layout handles all OOB headers via auto-fetch macros + oobs = sx_call("market-browse-layout-oob") menu = _mobile_nav_panel_sx(ctx) filter_sx = await _mobile_filter_summary_sx(ctx) aside_sx = await _desktop_filter_sx(ctx) @@ -207,7 +200,7 @@ def render_cart_added_response(cart: list, item: Any, d: dict) -> str: Returns OOB fragments: cart-mini icon + product add/remove buttons + cart item row. """ from shared.browser.app.csrf import generate_csrf_token - from quart import url_for, g + from quart import url_for from shared.infrastructure.urls import cart_url as _cart_url csrf = generate_csrf_token() diff --git a/market/sxc/pages/utils.py b/market/sxc/pages/utils.py index 67446af..afe019c 100644 --- a/market/sxc/pages/utils.py +++ b/market/sxc/pages/utils.py @@ -1,9 +1,6 @@ -"""Price helpers, OOB helpers, product detail/meta builders.""" +"""Price helpers, OOB helpers, product detail/meta data builders.""" from __future__ import annotations -from typing import Any - -from shared.sx.parser import SxExpr from shared.sx.helpers import sx_call @@ -54,31 +51,14 @@ def _set_prices(item: dict) -> dict: rp_val=rp_val, rp_raw=rp_raw, rp_cur=rp_cur) -def _card_price_sx(p: dict) -> str: - """Build price line for product card as sx call.""" - 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: list[str] = [] - if pr["sp_val"]: - parts.append(sx_call("market-price-special", price=sp_str)) - if pr["rp_val"]: - parts.append(sx_call("market-price-regular-strike", price=rp_str)) - elif pr["rp_val"]: - parts.append(sx_call("market-price-regular", price=rp_str)) - inner = "(<> " + " ".join(parts) + ")" if parts else None - return sx_call("market-price-line", inner=SxExpr(inner) if inner else None) - - # --------------------------------------------------------------------------- -# Product detail page content +# Product detail data extraction # --------------------------------------------------------------------------- -def _product_detail_sx(d: dict, ctx: dict) -> str: - """Build product detail main panel content as sx.""" - from quart import url_for +def _product_detail_data(d: dict, ctx: dict) -> dict: + """Extract product detail page data for .sx composition.""" from shared.browser.app.csrf import generate_csrf_token - from .cards import _like_button_sx + from .cards import _like_button_data asset_url_fn = ctx.get("asset_url") user = ctx.get("user") @@ -91,132 +71,70 @@ def _product_detail_sx(d: dict, ctx: dict) -> str: brand = d.get("brand", "") slug = d.get("slug", "") - # Gallery - if images: - # Like button - like_sx = "" - if user: - like_sx = _like_button_sx(slug, liked_by_current_user, csrf, ctx) + # Like button data + like_data = None + if user: + like_data = _like_button_data(slug, liked_by_current_user, csrf, ctx) - # Main image + labels - label_parts: list[str] = [] - if callable(asset_url_fn): - for l in labels: - label_parts.append(sx_call( - "market-label-overlay", - src=asset_url_fn("labels/" + l + ".svg"), - )) - labels_sx = "(<> " + " ".join(label_parts) + ")" if label_parts else None + # Label overlay URLs + label_urls = [] + if callable(asset_url_fn): + label_urls = [asset_url_fn("labels/" + l + ".svg") for l in labels] - gallery_inner = sx_call( - "market-detail-gallery-inner", - like=like_sx or None, - image=images[0], alt=d.get("title", ""), - labels=SxExpr(labels_sx) if labels_sx else None, - brand=brand, - ) + # Image data + image_data = [{"src": u, "alt": d.get("title", "")} for u in images] if images else [] - # Prev/next buttons - nav_buttons = "" - if len(images) > 1: - nav_buttons = sx_call("market-detail-nav-buttons") + # Thumbnail data + thumb_data = [] + if len(images) > 1: + for i, u in enumerate(images): + thumb_data.append({"title": f"Image {i+1}", "src": u, "alt": f"thumb {i+1}"}) - gallery_sx = sx_call( - "market-detail-gallery", - inner=gallery_inner, - nav=nav_buttons or None, - ) - - # Thumbnails - gallery_parts = [gallery_sx] - if len(images) > 1: - thumb_parts = [] - for i, u in enumerate(images): - thumb_parts.append(sx_call( - "market-detail-thumb", - title=f"Image {i+1}", src=u, alt=f"thumb {i+1}", - )) - thumbs_sx = "(<> " + " ".join(thumb_parts) + ")" - gallery_parts.append(sx_call("market-detail-thumbs", thumbs=SxExpr(thumbs_sx))) - gallery_final = "(<> " + " ".join(gallery_parts) + ")" - else: - like_sx = "" - if user: - like_sx = _like_button_sx(slug, liked_by_current_user, csrf, ctx) - gallery_final = sx_call("market-detail-no-image", - like=like_sx or None) - - # Stickers below gallery - stickers_sx = "" + # Sticker items + sticker_items = [] if stickers and callable(asset_url_fn): - sticker_parts = [] for s in stickers: - sticker_parts.append(sx_call( - "market-detail-sticker", - src=asset_url_fn("stickers/" + s + ".svg"), name=s, - )) - sticker_items_sx = "(<> " + " ".join(sticker_parts) + ")" - stickers_sx = sx_call("market-detail-stickers", items=SxExpr(sticker_items_sx)) + sticker_items.append({"src": asset_url_fn("stickers/" + s + ".svg"), "name": s}) - # Right column: prices, description, sections - pr = _set_prices(d) - detail_parts: list[str] = [] - - # Unit price / case size extras - extra_parts: list[str] = [] + # Extras (unit price, case size) + extras = [] ppu = d.get("price_per_unit") or d.get("price_per_unit_raw") if ppu: - extra_parts.append(sx_call( - "market-detail-unit-price", - price=_price_str(d.get("price_per_unit"), d.get("price_per_unit_raw"), d.get("price_per_unit_currency")), - )) + extras.append({ + "type": "unit-price", + "value": _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"): - extra_parts.append(sx_call("market-detail-case-size", size=d["case_size_raw"])) - if extra_parts: - extras_sx = "(<> " + " ".join(extra_parts) + ")" - detail_parts.append(sx_call("market-detail-extras", inner=SxExpr(extras_sx))) + extras.append({"type": "case-size", "value": d["case_size_raw"]}) - # Description - desc_short = d.get("description_short") - desc_html_val = d.get("description_html") - if desc_short or desc_html_val: - desc_parts: list[str] = [] - if desc_short: - desc_parts.append(sx_call("market-detail-desc-short", text=desc_short)) - if desc_html_val: - desc_parts.append(sx_call("market-detail-desc-html", html=desc_html_val)) - desc_inner = "(<> " + " ".join(desc_parts) + ")" - detail_parts.append(sx_call("market-detail-desc-wrapper", inner=SxExpr(desc_inner))) + return { + "images": image_data or None, + "labels": label_urls or None, + "brand": brand, + "like-data": like_data, + "has-nav-buttons": len(images) > 1, + "thumbs": thumb_data or None, + "sticker-items": sticker_items or None, + "extras": extras or None, + "desc-short": d.get("description_short") or None, + "desc-html": d.get("description_html") or None, + "sections": d.get("sections") or None, + } - # Sections (expandable) - sections = d.get("sections", []) - if sections: - sec_parts = [] - for sec in sections: - sec_parts.append(sx_call( - "market-detail-section", - title=sec.get("title", ""), html=sec.get("html", ""), - )) - sec_items_sx = "(<> " + " ".join(sec_parts) + ")" - detail_parts.append(sx_call("market-detail-sections", items=SxExpr(sec_items_sx))) - details_inner_sx = "(<> " + " ".join(detail_parts) + ")" if detail_parts else "(<>)" - details_sx = sx_call("market-detail-right-col", inner=SxExpr(details_inner_sx)) - - return sx_call( - "market-detail-layout", - gallery=SxExpr(gallery_final), - stickers=stickers_sx or None, - details=details_sx, - ) +def _product_detail_sx(d: dict, ctx: dict) -> str: + """Build product detail content — delegates to .sx defcomp.""" + data = _product_detail_data(d, ctx) + return sx_call("market-product-detail-from-data", **data) # --------------------------------------------------------------------------- -# Product meta (OpenGraph, JSON-LD) +# Product meta data extraction # --------------------------------------------------------------------------- -def _product_meta_sx(d: dict, ctx: dict) -> str: - """Build product meta tags as sx (auto-hoisted to by sx.js).""" +def _product_meta_data(d: dict, ctx: dict) -> dict: + """Extract product meta/SEO data for .sx composition.""" import json from quart import request @@ -231,36 +149,8 @@ def _product_meta_sx(d: dict, ctx: dict) -> str: brand = d.get("brand", "") sku = d.get("sku", "") price = d.get("special_price") or d.get("regular_price") or d.get("rrp") - price_currency = d.get("special_price_currency") or d.get("regular_price_currency") or d.get("rrp_currency") - - parts = [sx_call("market-meta-title", title=title)] - parts.append(sx_call("market-meta-description", description=description)) - if canonical: - parts.append(sx_call("market-meta-canonical", href=canonical)) - - # OpenGraph - site_title = ctx.get("base_title", "") - parts.append(sx_call("market-meta-og", property="og:site_name", content=site_title)) - parts.append(sx_call("market-meta-og", property="og:type", content="product")) - parts.append(sx_call("market-meta-og", property="og:title", content=title)) - parts.append(sx_call("market-meta-og", property="og:description", content=description)) - if canonical: - parts.append(sx_call("market-meta-og", property="og:url", content=canonical)) - if image_url: - parts.append(sx_call("market-meta-og", property="og:image", content=image_url)) - if price and price_currency: - parts.append(sx_call("market-meta-og", property="product:price:amount", content=f"{price:.2f}")) - parts.append(sx_call("market-meta-og", property="product:price:currency", content=price_currency)) - if brand: - parts.append(sx_call("market-meta-og", property="product:brand", content=brand)) - - # Twitter - card_type = "summary_large_image" if image_url else "summary" - parts.append(sx_call("market-meta-twitter", name="twitter:card", content=card_type)) - parts.append(sx_call("market-meta-twitter", name="twitter:title", content=title)) - parts.append(sx_call("market-meta-twitter", name="twitter:description", content=description)) - if image_url: - parts.append(sx_call("market-meta-twitter", name="twitter:image", content=image_url)) + price_currency = (d.get("special_price_currency") or d.get("regular_price_currency") + or d.get("rrp_currency")) # JSON-LD jsonld = { @@ -282,6 +172,21 @@ def _product_meta_sx(d: dict, ctx: dict) -> str: "url": canonical, "availability": "https://schema.org/InStock", } - parts.append(sx_call("market-meta-jsonld", json=json.dumps(jsonld))) - return "(<> " + " ".join(parts) + ")" + return { + "title": title, + "description": description, + "canonical": canonical or None, + "image-url": image_url or None, + "site-title": ctx.get("base_title", ""), + "brand": brand or None, + "price": f"{price:.2f}" if price and price_currency else None, + "price-currency": price_currency if price else None, + "jsonld-json": json.dumps(jsonld), + } + + +def _product_meta_sx(d: dict, ctx: dict) -> str: + """Build product meta tags — delegates to .sx defcomp.""" + data = _product_meta_data(d, ctx) + return sx_call("market-product-meta-from-data", **data)