From 193578ef8882b6fdd4e155034d468bd3ed9bbd20 Mon Sep 17 00:00:00 2001 From: giles Date: Tue, 3 Mar 2026 22:36:34 +0000 Subject: [PATCH] Move SX construction from Python to .sx defcomps (phases 0-4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eliminate Python s-expression string building across account, orders, federation, and cart services. Visual rendering logic now lives entirely in .sx defcomp components; Python files contain only data serialization, header/layout wiring, and thin wrappers that call defcomps. Phase 0: Shared DRY extraction — auth/orders header defcomps, format-decimal/ pluralize/escape/route-prefix primitives. Phase 1: Account — dashboard, newsletters, login/device/check-email content. Phase 2: Orders — order list, detail, filter, checkout return assembled defcomps. Phase 3: Federation — social nav, post cards, timeline, search, actors, notifications, compose, profile assembled defcomps. Phase 4: Cart — overview, page cart items/calendar/tickets/summary, admin, payments assembled defcomps; orders rendering reuses Phase 2 shared defcomps. Co-Authored-By: Claude Opus 4.6 --- account/sx/auth.sx | 22 + account/sx/dashboard.sx | 17 + account/sx/newsletters.sx | 31 ++ account/sx/sx_components.py | 320 +++--------- account/sxc/pages/__init__.py | 70 ++- account/sxc/pages/account.sx | 2 +- cart/sx/items.sx | 111 +++++ cart/sx/overview.sx | 53 ++ cart/sx/payments.sx | 24 + cart/sx/sx_components.py | 693 ++++++-------------------- cart/sxc/pages/__init__.py | 208 +++++++- federation/sx/notifications.sx | 47 ++ federation/sx/profile.sx | 37 ++ federation/sx/search.sx | 96 ++++ federation/sx/social.sx | 126 +++++ federation/sx/sx_components.py | 803 ++++++++----------------------- federation/sxc/pages/__init__.py | 148 +++++- orders/bp/orders/routes.py | 36 +- orders/sx/sx_components.py | 401 +++++---------- orders/sxc/pages/__init__.py | 142 +++++- shared/sx/primitives.py | 40 ++ shared/sx/templates/auth.sx | 43 +- shared/sx/templates/orders.sx | 149 ++++++ 23 files changed, 1824 insertions(+), 1795 deletions(-) diff --git a/account/sx/auth.sx b/account/sx/auth.sx index 672b5db..c357397 100644 --- a/account/sx/auth.sx +++ b/account/sx/auth.sx @@ -27,3 +27,25 @@ (h1 :class "text-2xl font-bold mb-4" "Device authorized") (p :class "text-stone-600" "You can close this window and return to your terminal."))) +;; Assembled auth page content — replaces Python _login_page_content etc. + +(defcomp ~account-login-content (&key error email) + (~auth-login-form + :error (when error (~auth-error-banner :error error)) + :action (url-for "auth.start_login") + :csrf-token (csrf-token) + :email (or email ""))) + +(defcomp ~account-device-content (&key error code) + (~account-device-form + :error (when error (~account-device-error :error error)) + :action (url-for "auth.device_submit") + :csrf-token (csrf-token) + :code (or code ""))) + +(defcomp ~account-check-email-content (&key email email-error) + (~auth-check-email + :email (escape (or email "")) + :error (when email-error + (~auth-check-email-error :error (escape email-error))))) + diff --git a/account/sx/dashboard.sx b/account/sx/dashboard.sx index e666551..479e1b0 100644 --- a/account/sx/dashboard.sx +++ b/account/sx/dashboard.sx @@ -41,3 +41,20 @@ name) logout) labels))) + +;; Assembled dashboard content — replaces Python _account_main_panel_sx +(defcomp ~account-dashboard-content (&key error) + (let* ((user (current-user)) + (csrf (csrf-token))) + (~account-main-panel + :error (when error (~account-error-banner :error error)) + :email (when (get user "email") + (~account-user-email :email (get user "email"))) + :name (when (get user "name") + (~account-user-name :name (get user "name"))) + :logout (~account-logout-form :csrf-token csrf) + :labels (when (not (empty? (or (get user "labels") (list)))) + (~account-labels-section + :items (map (lambda (label) + (~account-label-item :name (get label "name"))) + (get user "labels"))))))) diff --git a/account/sx/newsletters.sx b/account/sx/newsletters.sx index ab4e518..4b0626b 100644 --- a/account/sx/newsletters.sx +++ b/account/sx/newsletters.sx @@ -29,3 +29,34 @@ (div :class "bg-white/70 backdrop-blur rounded-2xl shadow border border-stone-200 p-6 sm:p-8 space-y-6" (h1 :class "text-xl font-semibold tracking-tight" "Newsletters") list))) + +;; Assembled newsletters content — replaces Python _newsletters_panel_sx +;; Takes pre-fetched newsletter-list from page helper +(defcomp ~account-newsletters-content (&key newsletter-list account-url) + (let* ((csrf (csrf-token))) + (if (empty? newsletter-list) + (~account-newsletter-empty) + (~account-newsletters-panel + :list (~account-newsletter-list + :items (map (lambda (item) + (let* ((nl (get item "newsletter")) + (un (get item "un")) + (nid (get nl "id")) + (subscribed (get item "subscribed")) + (toggle-url (str (or account-url "") "/newsletter/" nid "/toggle/")) + (bg (if subscribed "bg-emerald-500" "bg-stone-300")) + (translate (if subscribed "translate-x-6" "translate-x-1")) + (checked (if subscribed "true" "false"))) + (~account-newsletter-item + :name (get nl "name") + :desc (when (get nl "description") + (~account-newsletter-desc :description (get nl "description"))) + :toggle (~account-newsletter-toggle + :id (str "nl-" nid) + :url toggle-url + :hdrs (str "{\"X-CSRFToken\": \"" csrf "\"}") + :target (str "#nl-" nid) + :cls (str "relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 " bg) + :checked checked + :knob-cls (str "inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform " translate))))) + newsletter-list)))))) diff --git a/account/sx/sx_components.py b/account/sx/sx_components.py index 75eb232..a094a5e 100644 --- a/account/sx/sx_components.py +++ b/account/sx/sx_components.py @@ -1,8 +1,8 @@ """ Account service s-expression page components. -Renders account dashboard, newsletters, fragment pages, login, and device -auth pages. Called from route handlers in place of ``render_template()``. +Renders login, device auth, and check-email pages. Dashboard and newsletters +are now fully handled by .sx defcomps called from defpage expressions. """ from __future__ import annotations @@ -11,7 +11,7 @@ from typing import Any from shared.sx.jinja_bridge import load_service_components from shared.sx.helpers import ( - call_url, sx_call, SxExpr, + sx_call, SxExpr, root_header_sx, full_page_sx, ) @@ -21,101 +21,70 @@ load_service_components(os.path.dirname(os.path.dirname(__file__)), # --------------------------------------------------------------------------- -# Header helpers +# Public API: Auth pages (login, device, check_email) # --------------------------------------------------------------------------- -def _auth_nav_sx(ctx: dict) -> str: - """Auth section desktop nav items.""" - parts = [ - sx_call("nav-link", - href=call_url(ctx, "account_url", "/newsletters/"), - label="newsletters", - select_colours=ctx.get("select_colours", ""), - ) - ] - account_nav = ctx.get("account_nav") - if account_nav: - parts.append(account_nav) - return "(<> " + " ".join(parts) + ")" +async def render_login_page(ctx: dict) -> str: + """Full page: login form.""" + error = ctx.get("error", "") + email = ctx.get("email", "") + hdr = root_header_sx(ctx) + content = sx_call("account-login-content", error=error or None, email=email) + return full_page_sx(ctx, header_rows=hdr, + content=content, + meta_html='Login \u2014 Rose Ash') -def _auth_header_sx(ctx: dict, *, oob: bool = False) -> str: - """Build the account section header row.""" - return sx_call( - "menu-row-sx", - id="auth-row", level=1, colour="sky", - link_href=call_url(ctx, "account_url", "/"), - link_label="account", icon="fa-solid fa-user", - nav=SxExpr(_auth_nav_sx(ctx)), - child_id="auth-header-child", oob=oob, - ) +async def render_device_page(ctx: dict) -> str: + """Full page: device authorization form.""" + error = ctx.get("error", "") + code = ctx.get("code", "") + hdr = root_header_sx(ctx) + content = sx_call("account-device-content", error=error or None, code=code) + return full_page_sx(ctx, header_rows=hdr, + content=content, + meta_html='Authorize Device \u2014 Rose Ash') -def _auth_nav_mobile_sx(ctx: dict) -> str: - """Mobile nav menu for auth section.""" - parts = [ - sx_call("nav-link", - href=call_url(ctx, "account_url", "/newsletters/"), - label="newsletters", - select_colours=ctx.get("select_colours", ""), - ) - ] - account_nav = ctx.get("account_nav") - if account_nav: - parts.append(account_nav) - return "(<> " + " ".join(parts) + ")" +async def render_device_approved_page(ctx: dict) -> str: + """Full page: device approved.""" + hdr = root_header_sx(ctx) + content = sx_call("account-device-approved") + return full_page_sx(ctx, header_rows=hdr, + content=content, + meta_html='Device Authorized \u2014 Rose Ash') + + +async def render_check_email_page(ctx: dict) -> str: + """Full page: check email after magic link sent.""" + email = ctx.get("email", "") + email_error = ctx.get("email_error") + hdr = root_header_sx(ctx) + content = sx_call("account-check-email-content", + email=email, email_error=email_error) + return full_page_sx(ctx, header_rows=hdr, + content=content, + meta_html='Check your email \u2014 Rose Ash') # --------------------------------------------------------------------------- -# Account dashboard (GET /) +# Public API: Fragment renderers for POST handlers # --------------------------------------------------------------------------- -def _account_main_panel_sx(ctx: dict) -> str: - """Account info panel with user details and logout.""" - from quart import g +def render_newsletter_toggle(un) -> str: + """Render a newsletter toggle switch for POST response.""" from shared.browser.app.csrf import generate_csrf_token - user = getattr(g, "user", None) - error = ctx.get("error", "") - - error_sx = sx_call("account-error-banner", error=error) if error else "" - - user_email_sx = "" - user_name_sx = "" - if user: - user_email_sx = sx_call("account-user-email", email=user.email) - if user.name: - user_name_sx = sx_call("account-user-name", name=user.name) - - logout_sx = sx_call("account-logout-form", csrf_token=generate_csrf_token()) - - labels_sx = "" - if user and hasattr(user, "labels") and user.labels: - label_items = " ".join( - sx_call("account-label-item", name=label.name) - for label in user.labels - ) - labels_sx = sx_call("account-labels-section", - items=SxExpr("(<> " + label_items + ")")) - - return sx_call( - "account-main-panel", - error=SxExpr(error_sx) if error_sx else None, - email=SxExpr(user_email_sx) if user_email_sx else None, - name=SxExpr(user_name_sx) if user_name_sx else None, - logout=SxExpr(logout_sx), - labels=SxExpr(labels_sx) if labels_sx else None, - ) - - -# --------------------------------------------------------------------------- -# Newsletters (GET /newsletters/) -# --------------------------------------------------------------------------- - -def _newsletter_toggle_sx(un: Any, account_url_fn: Any, csrf_token: str) -> str: - """Render a single newsletter toggle switch.""" nid = un.newsletter_id + from quart import g + account_url_fn = getattr(g, "_account_url", None) + if account_url_fn is None: + from shared.infrastructure.urls import account_url + account_url_fn = account_url + toggle_url = account_url_fn(f"/newsletter/{nid}/toggle/") + csrf = generate_csrf_token() + if un.subscribed: bg = "bg-emerald-500" translate = "translate-x-6" @@ -124,10 +93,11 @@ def _newsletter_toggle_sx(un: Any, account_url_fn: Any, csrf_token: str) -> str: bg = "bg-stone-300" translate = "translate-x-1" checked = "false" + return sx_call( "account-newsletter-toggle", id=f"nl-{nid}", url=toggle_url, - hdrs=f'{{"X-CSRFToken": "{csrf_token}"}}', + hdrs=f'{{"X-CSRFToken": "{csrf}"}}', target=f"#nl-{nid}", cls=f"relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 {bg}", checked=checked, @@ -135,113 +105,10 @@ def _newsletter_toggle_sx(un: Any, account_url_fn: Any, csrf_token: str) -> str: ) -def _newsletter_toggle_off_sx(nid: int, toggle_url: str, csrf_token: str) -> str: - """Render an unsubscribed newsletter toggle (no subscription record yet).""" - return sx_call( - "account-newsletter-toggle", - id=f"nl-{nid}", url=toggle_url, - hdrs=f'{{"X-CSRFToken": "{csrf_token}"}}', - target=f"#nl-{nid}", - cls="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 bg-stone-300", - checked="false", - knob_cls="inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform translate-x-1", - ) - - -def _newsletters_panel_sx(ctx: dict, newsletter_list: list) -> str: - """Newsletters management panel.""" - from shared.browser.app.csrf import generate_csrf_token - - account_url_fn = ctx.get("account_url") or (lambda p: p) - csrf = generate_csrf_token() - - if newsletter_list: - items = [] - for item in newsletter_list: - nl = item["newsletter"] - un = item.get("un") - - desc_sx = sx_call( - "account-newsletter-desc", description=nl.description - ) if nl.description else "" - - if un: - toggle = _newsletter_toggle_sx(un, account_url_fn, csrf) - else: - toggle_url = account_url_fn(f"/newsletter/{nl.id}/toggle/") - toggle = _newsletter_toggle_off_sx(nl.id, toggle_url, csrf) - - items.append(sx_call( - "account-newsletter-item", - name=nl.name, - desc=SxExpr(desc_sx) if desc_sx else None, - toggle=SxExpr(toggle), - )) - list_sx = sx_call( - "account-newsletter-list", - items=SxExpr("(<> " + " ".join(items) + ")"), - ) - else: - list_sx = sx_call("account-newsletter-empty") - - return sx_call("account-newsletters-panel", list=SxExpr(list_sx)) - - # --------------------------------------------------------------------------- -# Auth pages (login, device, check_email) +# Internal helpers # --------------------------------------------------------------------------- -def _login_page_content(ctx: dict) -> str: - """Login form content.""" - from shared.browser.app.csrf import generate_csrf_token - from quart import url_for - - error = ctx.get("error", "") - email = ctx.get("email", "") - action = url_for("auth.start_login") - - error_sx = sx_call("auth-error-banner", error=error) if error else "" - - return sx_call( - "auth-login-form", - error=SxExpr(error_sx) if error_sx else None, - action=action, - csrf_token=generate_csrf_token(), email=email, - ) - - -def _device_page_content(ctx: dict) -> str: - """Device authorization form content.""" - from shared.browser.app.csrf import generate_csrf_token - from quart import url_for - - error = ctx.get("error", "") - code = ctx.get("code", "") - action = url_for("auth.device_submit") - - error_sx = sx_call("account-device-error", error=error) if error else "" - - return sx_call( - "account-device-form", - error=SxExpr(error_sx) if error_sx else None, - action=action, - csrf_token=generate_csrf_token(), code=code, - ) - - -def _device_approved_content() -> str: - """Device approved success content.""" - return sx_call("account-device-approved") - - -# --------------------------------------------------------------------------- -# Public API: Account dashboard -# --------------------------------------------------------------------------- - - - - - def _fragment_content(frag: object) -> str: """Convert a fragment response to sx content string. @@ -257,83 +124,6 @@ def _fragment_content(frag: object) -> str: return f'(~rich-text :html "{_sx_escape(s)}")' -# --------------------------------------------------------------------------- -# Public API: Auth pages (login, device) -# --------------------------------------------------------------------------- - -async def render_login_page(ctx: dict) -> str: - """Full page: login form.""" - hdr = root_header_sx(ctx) - return full_page_sx(ctx, header_rows=hdr, - content=_login_page_content(ctx), - meta_html='Login \u2014 Rose Ash') - - -async def render_device_page(ctx: dict) -> str: - """Full page: device authorization form.""" - hdr = root_header_sx(ctx) - return full_page_sx(ctx, header_rows=hdr, - content=_device_page_content(ctx), - meta_html='Authorize Device \u2014 Rose Ash') - - -async def render_device_approved_page(ctx: dict) -> str: - """Full page: device approved.""" - hdr = root_header_sx(ctx) - return full_page_sx(ctx, header_rows=hdr, - content=_device_approved_content(), - meta_html='Device Authorized \u2014 Rose Ash') - - -# --------------------------------------------------------------------------- -# Public API: Check email page (POST /start/ success) -# --------------------------------------------------------------------------- - -def _check_email_content(email: str, email_error: str | None = None) -> str: - """Check email confirmation content.""" - from markupsafe import escape - - error_sx = sx_call( - "auth-check-email-error", error=str(escape(email_error)) - ) if email_error else "" - - return sx_call( - "auth-check-email", - email=str(escape(email)), - error=SxExpr(error_sx) if error_sx else None, - ) - - -async def render_check_email_page(ctx: dict) -> str: - """Full page: check email after magic link sent.""" - email = ctx.get("email", "") - email_error = ctx.get("email_error") - hdr = root_header_sx(ctx) - return full_page_sx(ctx, header_rows=hdr, - content=_check_email_content(email, email_error), - meta_html='Check your email \u2014 Rose Ash') - - -# --------------------------------------------------------------------------- -# Public API: Fragment renderers for POST handlers -# --------------------------------------------------------------------------- - - -def render_newsletter_toggle(un) -> str: - """Render a newsletter toggle switch for POST response (uses account_url).""" - from shared.browser.app.csrf import generate_csrf_token - from quart import g - account_url_fn = getattr(g, "_account_url", None) - if account_url_fn is None: - from shared.infrastructure.urls import account_url - account_url_fn = account_url - return _newsletter_toggle_sx(un, account_url_fn, generate_csrf_token()) - - -# --------------------------------------------------------------------------- -# Internal helpers -# --------------------------------------------------------------------------- - def _sx_escape(s: str) -> str: """Escape a string for embedding in sx string literals.""" return s.replace("\\", "\\\\").replace('"', '\\"') diff --git a/account/sxc/pages/__init__.py b/account/sxc/pages/__init__.py index 269ad2d..12aebc4 100644 --- a/account/sxc/pages/__init__.py +++ b/account/sxc/pages/__init__.py @@ -27,28 +27,41 @@ def _register_account_layouts() -> None: def _account_full(ctx: dict, **kw: Any) -> str: - from shared.sx.helpers import root_header_sx, header_child_sx - from sx.sx_components import _auth_header_sx + from shared.sx.helpers import root_header_sx, header_child_sx, call_url, sx_call, SxExpr root_hdr = root_header_sx(ctx) - hdr_child = header_child_sx(_auth_header_sx(ctx)) + auth_hdr = sx_call("auth-header-row", + account_url=call_url(ctx, "account_url", ""), + select_colours=ctx.get("select_colours", ""), + account_nav=_as_sx_nav(ctx), + ) + hdr_child = header_child_sx(auth_hdr) return "(<> " + root_hdr + " " + hdr_child + ")" def _account_oob(ctx: dict, **kw: Any) -> str: - from shared.sx.helpers import root_header_sx - from sx.sx_components import _auth_header_sx + from shared.sx.helpers import root_header_sx, call_url, sx_call - return "(<> " + _auth_header_sx(ctx, oob=True) + " " + root_header_sx(ctx, oob=True) + ")" + auth_hdr = sx_call("auth-header-row", + account_url=call_url(ctx, "account_url", ""), + select_colours=ctx.get("select_colours", ""), + account_nav=_as_sx_nav(ctx), + oob=True, + ) + return "(<> " + auth_hdr + " " + root_header_sx(ctx, oob=True) + ")" def _account_mobile(ctx: dict, **kw: Any) -> str: - from shared.sx.helpers import mobile_menu_sx, mobile_root_nav_sx, sx_call, SxExpr - from sx.sx_components import _auth_nav_mobile_sx + from shared.sx.helpers import mobile_menu_sx, mobile_root_nav_sx, sx_call, SxExpr, call_url ctx = _inject_account_nav(ctx) + nav_items = sx_call("auth-nav-items", + account_url=call_url(ctx, "account_url", ""), + select_colours=ctx.get("select_colours", ""), + account_nav=_as_sx_nav(ctx), + ) auth_section = sx_call("mobile-menu-section", label="account", href="/", level=1, colour="sky", - items=SxExpr(_auth_nav_mobile_sx(ctx))) + items=SxExpr(nav_items)) return mobile_menu_sx(auth_section, mobile_root_nav_sx(ctx)) @@ -61,6 +74,13 @@ def _inject_account_nav(ctx: dict) -> dict: return ctx +def _as_sx_nav(ctx: dict) -> Any: + """Convert account_nav fragment to SxExpr for use in sx_call.""" + from shared.sx.helpers import _as_sx + ctx = _inject_account_nav(ctx) + return _as_sx(ctx.get("account_nav")) + + # --------------------------------------------------------------------------- # Page helpers # --------------------------------------------------------------------------- @@ -69,22 +89,19 @@ def _register_account_helpers() -> None: from shared.sx.pages import register_page_helpers register_page_helpers("account", { - "account-content": _h_account_content, "newsletters-content": _h_newsletters_content, "fragment-content": _h_fragment_content, }) -def _h_account_content(**kw): - from sx.sx_components import _account_main_panel_sx - return _account_main_panel_sx({}) - - async def _h_newsletters_content(**kw): + """Fetch newsletter data, return assembled defcomp call.""" from quart import g from sqlalchemy import select from shared.models import UserNewsletter from shared.models.ghost_membership_entities import GhostNewsletter + from shared.sx.helpers import sx_call, SxExpr + from shared.sx.parser import serialize result = await g.s.execute( select(GhostNewsletter).order_by(GhostNewsletter.name) @@ -102,20 +119,21 @@ async def _h_newsletters_content(**kw): for nl in all_newsletters: un = user_subs.get(nl.id) newsletter_list.append({ - "newsletter": nl, - "un": un, + "newsletter": {"id": nl.id, "name": nl.name, "description": nl.description}, + "un": {"newsletter_id": un.newsletter_id, "subscribed": un.subscribed} if un else None, "subscribed": un.subscribed if un else False, }) - if not newsletter_list: - from shared.sx.helpers import sx_call - return sx_call("account-newsletter-empty") - from sx.sx_components import _newsletters_panel_sx - ctx = {"account_url": getattr(g, "_account_url", None)} - if ctx["account_url"] is None: - from shared.infrastructure.urls import account_url - ctx["account_url"] = account_url - return _newsletters_panel_sx(ctx, newsletter_list) + account_url = getattr(g, "_account_url", None) + if account_url is None: + from shared.infrastructure.urls import account_url as _account_url + account_url = _account_url + # Call account_url to get the base URL string + account_url_str = account_url("") if callable(account_url) else str(account_url or "") + + return sx_call("account-newsletters-content", + newsletter_list=SxExpr(serialize(newsletter_list)), + account_url=account_url_str) async def _h_fragment_content(slug=None, **kw): diff --git a/account/sxc/pages/account.sx b/account/sxc/pages/account.sx index 1e3b3ca..8da12d1 100644 --- a/account/sxc/pages/account.sx +++ b/account/sxc/pages/account.sx @@ -8,7 +8,7 @@ :path "/" :auth :login :layout :account - :content (account-content)) + :content (~account-dashboard-content)) ;; --------------------------------------------------------------------------- ;; Newsletters diff --git a/cart/sx/items.sx b/cart/sx/items.sx index 30f1f59..00d86e7 100644 --- a/cart/sx/items.sx +++ b/cart/sx/items.sx @@ -52,3 +52,114 @@ (div :id "cart" (div (section :class "space-y-3 sm:space-y-4" items cal tickets) summary)))) + +;; Assembled cart item from serialized data — replaces Python _cart_item_sx +(defcomp ~cart-item-from-data (&key item) + (let* ((slug (or (get item "slug") "")) + (title (or (get item "title") "")) + (image (get item "image")) + (brand (get item "brand")) + (is-deleted (get item "is_deleted")) + (unit-price (get item "unit_price")) + (special-price (get item "special_price")) + (regular-price (get item "regular_price")) + (currency (or (get item "currency") "GBP")) + (symbol (if (= currency "GBP") "\u00a3" currency)) + (quantity (or (get item "quantity") 1)) + (product-id (get item "product_id")) + (prod-url (or (get item "product_url") "")) + (qty-url (or (get item "qty_url") "")) + (csrf (csrf-token)) + (line-total (when unit-price (* unit-price quantity)))) + (~cart-item + :id (str "cart-item-" slug) + :img (if image + (~cart-item-img :src image :alt title) + (~img-or-placeholder :src nil + :size-cls "w-24 h-24 sm:w-32 sm:h-28 rounded-xl border border-dashed border-stone-300" + :placeholder-text "No image")) + :prod-url prod-url + :title title + :brand (when brand (~cart-item-brand :brand brand)) + :deleted (when is-deleted (~cart-item-deleted)) + :price (if unit-price + (<> + (~cart-item-price :text (str symbol (format-decimal unit-price 2))) + (when (and special-price (!= special-price regular-price)) + (~cart-item-price-was :text (str symbol (format-decimal regular-price 2))))) + (~cart-item-no-price)) + :qty-url qty-url :csrf csrf + :minus (str (- quantity 1)) + :qty (str quantity) + :plus (str (+ quantity 1)) + :line-total (when line-total + (~cart-item-line-total :text (str "Line total: " symbol (format-decimal line-total 2))))))) + +;; Assembled calendar entries section — replaces Python _calendar_entries_sx +(defcomp ~cart-cal-section-from-data (&key entries) + (when (not (empty? entries)) + (~cart-cal-section + :items (map (lambda (e) + (let* ((name (or (get e "name") "")) + (date-str (or (get e "date_str") ""))) + (~cart-cal-entry + :name name :date-str date-str + :cost (str "\u00a3" (format-decimal (or (get e "cost") 0) 2))))) + entries)))) + +;; Assembled ticket groups section — replaces Python _ticket_groups_sx +(defcomp ~cart-tickets-section-from-data (&key ticket-groups) + (when (not (empty? ticket-groups)) + (let* ((csrf (csrf-token)) + (qty-url (url-for "cart_global.update_ticket_quantity"))) + (~cart-tickets-section + :items (map (lambda (tg) + (let* ((name (or (get tg "entry_name") "")) + (tt-name (get tg "ticket_type_name")) + (price (or (get tg "price") 0)) + (quantity (or (get tg "quantity") 0)) + (line-total (or (get tg "line_total") 0)) + (entry-id (str (or (get tg "entry_id") ""))) + (tt-id (get tg "ticket_type_id")) + (date-str (or (get tg "date_str") ""))) + (~cart-ticket-article + :name name + :type-name (when tt-name (~cart-ticket-type-name :name tt-name)) + :date-str date-str + :price (str "\u00a3" (format-decimal price 2)) + :qty-url qty-url :csrf csrf + :entry-id entry-id + :type-hidden (when tt-id (~cart-ticket-type-hidden :value (str tt-id))) + :minus (str (max (- quantity 1) 0)) + :qty (str quantity) + :plus (str (+ quantity 1)) + :line-total (str "Line total: \u00a3" (format-decimal line-total 2))))) + ticket-groups))))) + +;; Assembled cart summary — replaces Python _cart_summary_sx +(defcomp ~cart-summary-from-data (&key item-count grand-total symbol is-logged-in checkout-action login-href user-email) + (~cart-summary-panel + :item-count (str item-count) + :subtotal (str symbol (format-decimal grand-total 2)) + :checkout (if is-logged-in + (~cart-checkout-form + :action checkout-action :csrf (csrf-token) + :label (str " Checkout as " user-email)) + (~cart-checkout-signin :href login-href)))) + +;; Assembled page cart content — replaces Python _page_cart_main_panel_sx +(defcomp ~cart-page-cart-content (&key cart-items cal-entries ticket-groups summary) + (if (and (empty? (or cart-items (list))) + (empty? (or cal-entries (list))) + (empty? (or ticket-groups (list)))) + (div :class "max-w-full px-3 py-3 space-y-3" + (div :id "cart" + (div :class "rounded-2xl border border-dashed border-stone-300 bg-white/80 p-6 sm:p-8 text-center" + (~empty-state :icon "fa fa-shopping-cart" :message "Your cart is empty" :cls "text-center")))) + (~cart-page-panel + :items (map (lambda (item) (~cart-item-from-data :item item)) (or cart-items (list))) + :cal (when (not (empty? (or cal-entries (list)))) + (~cart-cal-section-from-data :entries cal-entries)) + :tickets (when (not (empty? (or ticket-groups (list)))) + (~cart-tickets-section-from-data :ticket-groups ticket-groups)) + :summary summary))) diff --git a/cart/sx/overview.sx b/cart/sx/overview.sx index cbeaa4f..9d04328 100644 --- a/cart/sx/overview.sx +++ b/cart/sx/overview.sx @@ -39,3 +39,56 @@ (defcomp ~cart-overview-panel (&key cards) (div :class "max-w-full px-3 py-3 space-y-3" (div :class "space-y-4" cards))) + +(defcomp ~cart-empty () + (div :class "max-w-full px-3 py-3 space-y-3" + (div :class "rounded-2xl border border-dashed border-stone-300 bg-white/80 p-6 sm:p-8 text-center" + (~empty-state :icon "fa fa-shopping-cart" :message "Your cart is empty" :cls "text-center")))) + +;; Assembled page group card — replaces Python _page_group_card_sx +(defcomp ~cart-page-group-card-from-data (&key grp cart-url-base) + (let* ((post (get grp "post")) + (product-count (or (get grp "product_count") 0)) + (calendar-count (or (get grp "calendar_count") 0)) + (ticket-count (or (get grp "ticket_count") 0)) + (total (or (get grp "total") 0)) + (market-place (get grp "market_place")) + (badges (<> + (when (> product-count 0) + (~cart-badge :icon "fa fa-box-open" + :text (str product-count " item" (pluralize product-count)))) + (when (> calendar-count 0) + (~cart-badge :icon "fa fa-calendar" + :text (str calendar-count " booking" (pluralize calendar-count)))) + (when (> ticket-count 0) + (~cart-badge :icon "fa fa-ticket" + :text (str ticket-count " ticket" (pluralize ticket-count))))))) + (if post + (let* ((slug (or (get post "slug") "")) + (title (or (get post "title") "")) + (feature-image (get post "feature_image")) + (mp-name (if market-place (or (get market-place "name") "") "")) + (display-title (if (!= mp-name "") mp-name title))) + (~cart-group-card + :href (str cart-url-base "/" slug "/") + :img (if feature-image + (~cart-group-card-img :src feature-image :alt title) + (~img-or-placeholder :src nil :size-cls "h-16 w-16 rounded-xl" + :placeholder-icon "fa fa-store text-xl")) + :display-title display-title + :subtitle (when (!= mp-name "") + (~cart-mp-subtitle :title title)) + :badges (~cart-badges-wrap :badges badges) + :total (str "\u00a3" (format-decimal total 2)))) + (~cart-orphan-card + :badges (~cart-badges-wrap :badges badges) + :total (str "\u00a3" (format-decimal total 2)))))) + +;; Assembled cart overview content — replaces Python _overview_main_panel_sx +(defcomp ~cart-overview-content (&key page-groups cart-url-base) + (if (empty? page-groups) + (~cart-empty) + (~cart-overview-panel + :cards (map (lambda (grp) + (~cart-page-group-card-from-data :grp grp :cart-url-base cart-url-base)) + page-groups)))) diff --git a/cart/sx/payments.sx b/cart/sx/payments.sx index 50343cd..36f6d35 100644 --- a/cart/sx/payments.sx +++ b/cart/sx/payments.sx @@ -5,3 +5,27 @@ (~sumup-settings-form :update-url update-url :csrf csrf :merchant-code merchant-code :placeholder placeholder :input-cls input-cls :sumup-configured sumup-configured :checkout-prefix checkout-prefix :sx-select "#payments-panel"))) + +;; Assembled cart admin overview content +(defcomp ~cart-admin-content () + (let* ((payments-href (url-for "defpage_cart_payments"))) + (div :id "main-panel" + (div :class "flex items-center justify-between p-3 border-b" + (span :class "font-medium" (i :class "fa fa-credit-card text-purple-600 mr-1") " Payments") + (a :href payments-href :class "text-sm underline" "configure"))))) + +;; Assembled cart payments content +(defcomp ~cart-payments-content (&key page-config) + (let* ((sumup-configured (and page-config (get page-config "sumup_api_key"))) + (merchant-code (or (get page-config "sumup_merchant_code") "")) + (checkout-prefix (or (get page-config "sumup_checkout_prefix") "")) + (placeholder (if sumup-configured "--------" "sup_sk_...")) + (input-cls "w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500")) + (~cart-payments-panel + :update-url (url-for "page_admin.update_sumup") + :csrf (csrf-token) + :merchant-code merchant-code + :placeholder placeholder + :input-cls input-cls + :sumup-configured sumup-configured + :checkout-prefix checkout-prefix))) diff --git a/cart/sx/sx_components.py b/cart/sx/sx_components.py index 293ffcf..f8174a2 100644 --- a/cart/sx/sx_components.py +++ b/cart/sx/sx_components.py @@ -1,8 +1,8 @@ """ Cart service s-expression page components. -Renders cart overview, page cart, orders list, and single order detail. -Called from route handlers in place of ``render_template()``. +Thin Python wrappers for header/layout helpers and route-level render +functions. All visual rendering logic lives in .sx defcomps. """ from __future__ import annotations @@ -18,7 +18,8 @@ from shared.sx.helpers import ( full_page_sx, oob_page_sx, header_child_sx, sx_call, SxExpr, ) -from shared.infrastructure.urls import market_product_url, cart_url +from shared.sx.parser import serialize +from shared.infrastructure.urls import cart_url # Load cart-specific .sx components + handlers at import time load_service_components(os.path.dirname(os.path.dirname(__file__)), @@ -26,7 +27,7 @@ load_service_components(os.path.dirname(os.path.dirname(__file__)), # --------------------------------------------------------------------------- -# Header helpers +# Header helpers (used by layouts in sxc/pages/__init__.py) # --------------------------------------------------------------------------- def _ensure_post_ctx(ctx: dict, page_post: Any) -> dict: @@ -104,493 +105,74 @@ def _page_cart_header_sx(ctx: dict, page_post: Any, *, oob: bool = False) -> str def _auth_header_sx(ctx: dict, *, oob: bool = False) -> str: """Build the account section header row (for orders).""" return sx_call( - "menu-row-sx", - id="auth-row", level=1, colour="sky", - link_href=call_url(ctx, "account_url", "/"), - link_label="account", icon="fa-solid fa-user", - child_id="auth-header-child", oob=oob, + "auth-header-row-simple", + account_url=call_url(ctx, "account_url", ""), + oob=oob, ) def _orders_header_sx(ctx: dict, list_url: str) -> str: """Build the orders section header row.""" - return sx_call( - "menu-row-sx", - id="orders-row", level=2, colour="sky", - link_href=list_url, link_label="Orders", icon="fa fa-gbp", - child_id="orders-header-child", - ) + return sx_call("orders-header-row", list_url=list_url) + + +def _cart_page_admin_header_sx(ctx: dict, page_post: Any, *, oob: bool = False, + selected: str = "") -> str: + """Build the page-level admin header row.""" + slug = page_post.slug if page_post else "" + ctx = _ensure_post_ctx(ctx, page_post) + return post_admin_header_sx(ctx, slug, oob=oob, selected=selected) # --------------------------------------------------------------------------- -# Cart overview +# Serialization helpers (shared with sxc/pages/__init__.py) # --------------------------------------------------------------------------- -def _badge_sx(icon: str, count: int, label: str) -> str: - """Render a count badge.""" - s = "s" if count != 1 else "" - return sx_call("cart-badge", icon=icon, text=f"{count} {label}{s}") - - -def _page_group_card_sx(grp: Any, ctx: dict) -> str: - """Render a single page group card for cart overview.""" - post = grp.get("post") if isinstance(grp, dict) else getattr(grp, "post", None) - cart_items = grp.get("cart_items", []) if isinstance(grp, dict) else getattr(grp, "cart_items", []) - cal_entries = grp.get("calendar_entries", []) if isinstance(grp, dict) else getattr(grp, "calendar_entries", []) - tickets = grp.get("tickets", []) if isinstance(grp, dict) else getattr(grp, "tickets", []) - product_count = grp.get("product_count", 0) if isinstance(grp, dict) else getattr(grp, "product_count", 0) - calendar_count = grp.get("calendar_count", 0) if isinstance(grp, dict) else getattr(grp, "calendar_count", 0) - ticket_count = grp.get("ticket_count", 0) if isinstance(grp, dict) else getattr(grp, "ticket_count", 0) - total = grp.get("total", 0) if isinstance(grp, dict) else getattr(grp, "total", 0) - market_place = grp.get("market_place") if isinstance(grp, dict) else getattr(grp, "market_place", None) - - if not cart_items and not cal_entries and not tickets: - return "" - - # Count badges - badge_parts = [] - if product_count > 0: - badge_parts.append(_badge_sx("fa fa-box-open", product_count, "item")) - if calendar_count > 0: - badge_parts.append(_badge_sx("fa fa-calendar", calendar_count, "booking")) - if ticket_count > 0: - badge_parts.append(_badge_sx("fa fa-ticket", ticket_count, "ticket")) - badges_sx = "(<> " + " ".join(badge_parts) + ")" if badge_parts else '""' - badges_wrap = sx_call("cart-badges-wrap", badges=SxExpr(badges_sx)) - - if post: - slug = post.slug if hasattr(post, "slug") else post.get("slug", "") - title = post.title if hasattr(post, "title") else post.get("title", "") - feature_image = post.feature_image if hasattr(post, "feature_image") else post.get("feature_image") - cart_href = call_url(ctx, "cart_url", f"/{slug}/") - - if feature_image: - img = sx_call("cart-group-card-img", src=feature_image, alt=title) - else: - img = sx_call("img-or-placeholder", src=None, - size_cls="h-16 w-16 rounded-xl", - placeholder_icon="fa fa-store text-xl") - - mp_sub = "" - if market_place: - mp_name = market_place.name if hasattr(market_place, "name") else market_place.get("name", "") - mp_sub = sx_call("cart-mp-subtitle", title=title) - else: - mp_name = "" - display_title = mp_name or title - - return sx_call( - "cart-group-card", - href=cart_href, img=SxExpr(img), display_title=display_title, - subtitle=SxExpr(mp_sub) if mp_sub else None, - badges=SxExpr(badges_wrap), - total=f"\u00a3{total:.2f}", - ) - else: - # Orphan items - return sx_call( - "cart-orphan-card", - badges=SxExpr(badges_wrap), - total=f"\u00a3{total:.2f}", - ) - - -def _empty_cart_sx() -> str: - """Empty cart state.""" - empty = sx_call("empty-state", icon="fa fa-shopping-cart", - message="Your cart is empty", cls="text-center") - return ( - '(div :class "max-w-full px-3 py-3 space-y-3"' - ' (div :class "rounded-2xl border border-dashed border-stone-300 bg-white/80 p-6 sm:p-8 text-center"' - f' {empty}))' - ) - - -def _overview_main_panel_sx(page_groups: list, ctx: dict) -> str: - """Cart overview main panel.""" - if not page_groups: - return _empty_cart_sx() - - cards = [_page_group_card_sx(grp, ctx) for grp in page_groups] - has_items = any(c for c in cards) - if not has_items: - return _empty_cart_sx() - - cards_sx = "(<> " + " ".join(c for c in cards if c) + ")" - return sx_call("cart-overview-panel", cards=SxExpr(cards_sx)) - - -# --------------------------------------------------------------------------- -# Page cart -# --------------------------------------------------------------------------- - -def _cart_item_sx(item: Any, ctx: dict) -> str: - """Render a single product cart item.""" - from shared.browser.app.csrf import generate_csrf_token - from quart import url_for - - p = item.product if hasattr(item, "product") else item - slug = p.slug if hasattr(p, "slug") else "" - unit_price = getattr(p, "special_price", None) or getattr(p, "regular_price", None) - currency = getattr(p, "regular_price_currency", "GBP") or "GBP" - symbol = "\u00a3" if currency == "GBP" else currency - csrf = generate_csrf_token() - qty_url = url_for("cart_global.update_quantity", product_id=p.id) - prod_url = market_product_url(slug) - - if p.image: - img = sx_call("cart-item-img", src=p.image, alt=p.title) - else: - img = sx_call("img-or-placeholder", src=None, - size_cls="w-24 h-24 sm:w-32 sm:h-28 rounded-xl border border-dashed border-stone-300", - placeholder_text="No image") - - price_parts = [] - if unit_price: - price_parts.append(sx_call("cart-item-price", text=f"{symbol}{unit_price:.2f}")) - if p.special_price and p.special_price != p.regular_price: - price_parts.append(sx_call("cart-item-price-was", text=f"{symbol}{p.regular_price:.2f}")) - else: - price_parts.append(sx_call("cart-item-no-price")) - price_sx = "(<> " + " ".join(price_parts) + ")" if len(price_parts) > 1 else price_parts[0] - - deleted_sx = sx_call("cart-item-deleted") if getattr(item, "is_deleted", False) else None - - brand_sx = sx_call("cart-item-brand", brand=p.brand) if getattr(p, "brand", None) else None - - line_total_sx = None - if unit_price: - lt = unit_price * item.quantity - line_total_sx = sx_call("cart-item-line-total", text=f"Line total: {symbol}{lt:.2f}") - - return sx_call( - "cart-item", - id=f"cart-item-{slug}", img=SxExpr(img), prod_url=prod_url, title=p.title, - brand=SxExpr(brand_sx) if brand_sx else None, - deleted=SxExpr(deleted_sx) if deleted_sx else None, - price=SxExpr(price_sx), - qty_url=qty_url, csrf=csrf, minus=str(item.quantity - 1), - qty=str(item.quantity), plus=str(item.quantity + 1), - line_total=SxExpr(line_total_sx) if line_total_sx else None, - ) - - -def _calendar_entries_sx(entries: list) -> str: - """Render calendar booking entries in cart.""" - if not entries: - return "" - parts = [] - for e in entries: - name = getattr(e, "name", None) or getattr(e, "calendar_name", "") - start = e.start_at if hasattr(e, "start_at") else "" - end = getattr(e, "end_at", None) - cost = getattr(e, "cost", 0) or 0 - end_str = f" \u2013 {end}" if end else "" - parts.append(sx_call( - "cart-cal-entry", - name=name, date_str=f"{start}{end_str}", cost=f"\u00a3{cost:.2f}", - )) - items_sx = "(<> " + " ".join(parts) + ")" - return sx_call("cart-cal-section", items=SxExpr(items_sx)) - - -def _ticket_groups_sx(ticket_groups: list, ctx: dict) -> str: - """Render ticket groups in cart.""" - if not ticket_groups: - return "" - from shared.browser.app.csrf import generate_csrf_token - from quart import url_for - - csrf = generate_csrf_token() - qty_url = url_for("cart_global.update_ticket_quantity") - parts = [] - - for tg in ticket_groups: - name = tg.entry_name if hasattr(tg, "entry_name") else tg.get("entry_name", "") - tt_name = tg.ticket_type_name if hasattr(tg, "ticket_type_name") else tg.get("ticket_type_name", "") - price = tg.price if hasattr(tg, "price") else tg.get("price", 0) - quantity = tg.quantity if hasattr(tg, "quantity") else tg.get("quantity", 0) - line_total = tg.line_total if hasattr(tg, "line_total") else tg.get("line_total", 0) - entry_id = tg.entry_id if hasattr(tg, "entry_id") else tg.get("entry_id", "") - tt_id = tg.ticket_type_id if hasattr(tg, "ticket_type_id") else tg.get("ticket_type_id", "") - start_at = tg.entry_start_at if hasattr(tg, "entry_start_at") else tg.get("entry_start_at") - end_at = tg.entry_end_at if hasattr(tg, "entry_end_at") else tg.get("entry_end_at") - - date_str = start_at.strftime("%-d %b %Y, %H:%M") if start_at else "" - if end_at: - date_str += f" \u2013 {end_at.strftime('%-d %b %Y, %H:%M')}" - - tt_name_sx = sx_call("cart-ticket-type-name", name=tt_name) if tt_name else None - tt_hidden_sx = sx_call("cart-ticket-type-hidden", value=str(tt_id)) if tt_id else None - - parts.append(sx_call( - "cart-ticket-article", - name=name, - type_name=SxExpr(tt_name_sx) if tt_name_sx else None, - date_str=date_str, - price=f"\u00a3{price or 0:.2f}", qty_url=qty_url, csrf=csrf, - entry_id=str(entry_id), - type_hidden=SxExpr(tt_hidden_sx) if tt_hidden_sx else None, - minus=str(max(quantity - 1, 0)), qty=str(quantity), - plus=str(quantity + 1), line_total=f"Line total: \u00a3{line_total:.2f}", - )) - - items_sx = "(<> " + " ".join(parts) + ")" - return sx_call("cart-tickets-section", items=SxExpr(items_sx)) - - -def _cart_summary_sx(ctx: dict, cart: list, cal_entries: list, tickets: list, - total_fn: Any, cal_total_fn: Any, ticket_total_fn: Any) -> str: - """Render the order summary sidebar.""" - from shared.browser.app.csrf import generate_csrf_token - from quart import g, url_for, request - from shared.infrastructure.urls import login_url - - csrf = generate_csrf_token() - product_qty = sum(ci.quantity for ci in cart) if cart else 0 - ticket_qty = len(tickets) if tickets else 0 - item_count = product_qty + ticket_qty - - product_total = total_fn(cart) or 0 - cal_total = cal_total_fn(cal_entries) or 0 - tk_total = ticket_total_fn(tickets) or 0 - grand = float(product_total) + float(cal_total) + float(tk_total) - - symbol = "\u00a3" - if cart and hasattr(cart[0], "product") and getattr(cart[0].product, "regular_price_currency", None): - cur = cart[0].product.regular_price_currency - symbol = "\u00a3" if cur == "GBP" else cur - - user = getattr(g, "user", None) - page_post = ctx.get("page_post") - - if user: - if page_post: - action = url_for("page_cart.page_checkout") - else: - action = url_for("cart_global.checkout") - from shared.utils import route_prefix - action = route_prefix() + action - checkout_sx = sx_call( - "cart-checkout-form", - action=action, csrf=csrf, label=f" Checkout as {user.email}", - ) - else: - href = login_url(request.url) - checkout_sx = sx_call("cart-checkout-signin", href=href) - - return sx_call( - "cart-summary-panel", - item_count=str(item_count), subtotal=f"{symbol}{grand:.2f}", - checkout=SxExpr(checkout_sx), - ) - - -def _page_cart_main_panel_sx(ctx: dict, cart: list, cal_entries: list, - tickets: list, ticket_groups: list, - total_fn: Any, cal_total_fn: Any, - ticket_total_fn: Any) -> str: - """Page cart main panel.""" - if not cart and not cal_entries and not tickets: - empty = sx_call("empty-state", icon="fa fa-shopping-cart", - message="Your cart is empty", cls="text-center") - return ( - '(div :class "max-w-full px-3 py-3 space-y-3"' - ' (div :id "cart"' - ' (div :class "rounded-2xl border border-dashed border-stone-300 bg-white/80 p-6 sm:p-8 text-center"' - f' {empty})))' - ) - - item_parts = [_cart_item_sx(item, ctx) for item in cart] - items_sx = "(<> " + " ".join(item_parts) + ")" if item_parts else '""' - cal_sx = _calendar_entries_sx(cal_entries) - tickets_sx = _ticket_groups_sx(ticket_groups, ctx) - summary_sx = _cart_summary_sx(ctx, cart, cal_entries, tickets, total_fn, cal_total_fn, ticket_total_fn) - - return sx_call( - "cart-page-panel", - items=SxExpr(items_sx), - cal=SxExpr(cal_sx) if cal_sx else None, - tickets=SxExpr(tickets_sx) if tickets_sx else None, - summary=SxExpr(summary_sx), - ) - - -# --------------------------------------------------------------------------- -# Orders list (same pattern as orders service) -# --------------------------------------------------------------------------- - -def _order_row_sx(order: Any, detail_url: str) -> str: - """Render a single order as desktop table row + mobile card.""" - status = order.status or "pending" - sl = status.lower() - pill = ( - "border-emerald-300 bg-emerald-50 text-emerald-700" if sl == "paid" - else "border-rose-300 bg-rose-50 text-rose-700" if sl in ("failed", "cancelled") - else "border-stone-300 bg-stone-50 text-stone-700" - ) - pill_cls = f"inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] sm:text-xs {pill}" +def _serialize_order(order: Any) -> dict: + """Serialize an order for SX defcomps.""" + from shared.infrastructure.urls import market_product_url created = order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else "\u2014" - total = f"{order.currency or 'GBP'} {order.total_amount or 0:.2f}" - - desktop = sx_call( - "order-row-desktop", - oid=f"#{order.id}", created=created, desc=order.description or "", - total=total, pill=pill_cls, status=status, url=detail_url, - ) - - mobile_pill = f"inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] {pill}" - mobile = sx_call( - "order-row-mobile", - oid=f"#{order.id}", pill=mobile_pill, status=status, - created=created, total=total, url=detail_url, - ) - - return "(<> " + desktop + " " + mobile + ")" + items = [] + if order.items: + for item in order.items: + items.append({ + "product_image": item.product_image, + "product_title": item.product_title or "Unknown product", + "product_id": item.product_id, + "product_slug": item.product_slug, + "product_url": market_product_url(item.product_slug), + "quantity": item.quantity, + "unit_price_formatted": f"{item.unit_price or 0:.2f}", + "currency": item.currency or order.currency or "GBP", + }) + return { + "id": order.id, + "status": order.status or "pending", + "created_at_formatted": created, + "description": order.description or "", + "total_formatted": f"{order.total_amount or 0:.2f}", + "total_amount": float(order.total_amount or 0), + "currency": order.currency or "GBP", + "items": items, + } -def _orders_rows_sx(orders: list, page: int, total_pages: int, - url_for_fn: Any, qs_fn: Any) -> str: - """Render order rows + infinite scroll sentinel.""" - from shared.utils import route_prefix - pfx = route_prefix() - - parts = [ - _order_row_sx(o, pfx + url_for_fn("orders.order.order_detail", order_id=o.id)) - for o in orders - ] - - if page < total_pages: - next_url = pfx + url_for_fn("orders.list_orders") + qs_fn(page=page + 1) - parts.append(sx_call( - "infinite-scroll", - url=next_url, page=page, total_pages=total_pages, - id_prefix="orders", colspan=5, - )) - else: - parts.append(sx_call("order-end-row")) - - return "(<> " + " ".join(parts) + ")" - - -def _orders_main_panel_sx(orders: list, rows_sx: str) -> str: - """Main panel for orders list.""" - if not orders: - return sx_call("order-empty-state") - return sx_call("order-table", rows=SxExpr(rows_sx)) - - -def _orders_summary_sx(ctx: dict) -> str: - """Filter section for orders list.""" - return sx_call("order-list-header", search_mobile=SxExpr(search_mobile_sx(ctx))) +def _serialize_calendar_entry(e: Any) -> dict: + """Serialize an order calendar entry for SX defcomps.""" + st = e.state or "" + ds = e.start_at.strftime("%-d %b %Y, %H:%M") if e.start_at else "" + if e.end_at: + ds += f" \u2013 {e.end_at.strftime('%-d %b %Y, %H:%M')}" + return { + "name": e.name, + "state": st, + "date_str": ds, + "cost_formatted": f"{e.cost or 0:.2f}", + } # --------------------------------------------------------------------------- -# Single order detail -# --------------------------------------------------------------------------- - -def _order_items_sx(order: Any) -> str: - """Render order items list.""" - if not order or not order.items: - return "" - parts = [] - for item in order.items: - prod_url = market_product_url(item.product_slug) - if item.product_image: - img = sx_call( - "order-item-image", - src=item.product_image, alt=item.product_title or "Product image", - ) - else: - img = sx_call("order-item-no-image") - parts.append(sx_call( - "order-item-row", - href=prod_url, img=SxExpr(img), - title=item.product_title or "Unknown product", - pid=f"Product ID: {item.product_id}", - qty=f"Qty: {item.quantity}", - price=f"{item.currency or order.currency or 'GBP'} {item.unit_price or 0:.2f}", - )) - items_sx = "(<> " + " ".join(parts) + ")" - return sx_call("order-items-panel", items=SxExpr(items_sx)) - - -def _order_summary_sx(order: Any) -> str: - """Order summary card.""" - return sx_call( - "order-summary-card", - order_id=order.id, - created_at=order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else None, - description=order.description, status=order.status, currency=order.currency, - total_amount=f"{order.total_amount:.2f}" if order.total_amount else None, - ) - - -def _order_calendar_items_sx(calendar_entries: list | None) -> str: - """Render calendar bookings for an order.""" - if not calendar_entries: - return "" - parts = [] - for e in calendar_entries: - st = e.state or "" - pill = ( - "bg-emerald-100 text-emerald-800" if st == "confirmed" - else "bg-amber-100 text-amber-800" if st == "provisional" - else "bg-blue-100 text-blue-800" if st == "ordered" - else "bg-stone-100 text-stone-700" - ) - pill_cls = f"inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium {pill}" - ds = e.start_at.strftime("%-d %b %Y, %H:%M") if e.start_at else "" - if e.end_at: - ds += f" \u2013 {e.end_at.strftime('%-d %b %Y, %H:%M')}" - parts.append(sx_call( - "order-calendar-entry", - name=e.name, pill=pill_cls, status=st.capitalize(), - date_str=ds, cost=f"\u00a3{e.cost or 0:.2f}", - )) - items_sx = "(<> " + " ".join(parts) + ")" - return sx_call("order-calendar-section", items=SxExpr(items_sx)) - - -def _order_main_sx(order: Any, calendar_entries: list | None) -> str: - """Main panel for single order detail.""" - summary = _order_summary_sx(order) - items = _order_items_sx(order) - cal = _order_calendar_items_sx(calendar_entries) - return sx_call( - "order-detail-panel", - summary=SxExpr(summary), - items=SxExpr(items) if items else None, - calendar=SxExpr(cal) if cal else None, - ) - - -def _order_filter_sx(order: Any, list_url: str, recheck_url: str, - pay_url: str, csrf_token: str) -> str: - """Filter section for single order detail.""" - created = order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else "\u2014" - status = order.status or "pending" - - pay_sx = None - if status != "paid": - pay_sx = sx_call("order-pay-btn", url=pay_url) - - return sx_call( - "order-detail-filter", - info=f"Placed {created} \u00b7 Status: {status}", - list_url=list_url, recheck_url=recheck_url, csrf=csrf_token, - pay=SxExpr(pay_sx) if pay_sx else None, - ) - - -# --------------------------------------------------------------------------- -# Public API: Cart overview -# --------------------------------------------------------------------------- - - - -# --------------------------------------------------------------------------- -# Public API: Orders list +# Public API: Orders list (used by cart/bp/orders/routes.py) # --------------------------------------------------------------------------- async def render_orders_page(ctx: dict, orders: list, page: int, @@ -602,10 +184,16 @@ async def render_orders_page(ctx: dict, orders: list, page: int, ctx["search"] = search ctx["search_count"] = search_count - list_url = route_prefix() + url_for_fn("orders.list_orders") + pfx = route_prefix() + list_url = pfx + url_for_fn("orders.list_orders") + rows_url = list_url + detail_url_prefix = pfx + url_for_fn("orders.order.order_detail", order_id=0).rsplit("0/", 1)[0] - rows = _orders_rows_sx(orders, page, total_pages, url_for_fn, qs_fn) - main = _orders_main_panel_sx(orders, rows) + order_dicts = [_serialize_order(o) for o in orders] + content = sx_call("orders-list-content", + orders=SxExpr(serialize(order_dicts)), + page=page, total_pages=total_pages, + rows_url=rows_url, detail_url_prefix=detail_url_prefix) hdr = root_header_sx(ctx) auth = _auth_header_sx(ctx) @@ -616,17 +204,40 @@ async def render_orders_page(ctx: dict, orders: list, page: int, ) header_rows = "(<> " + hdr + " " + auth_child + ")" + filt = sx_call("order-list-header", search_mobile=SxExpr(search_mobile_sx(ctx))) return full_page_sx(ctx, header_rows=header_rows, - filter=_orders_summary_sx(ctx), + filter=filt, aside=search_desktop_sx(ctx), - content=main) + content=content) async def render_orders_rows(ctx: dict, orders: list, page: int, total_pages: int, url_for_fn: Any, qs_fn: Any) -> str: """Pagination: just the table rows.""" - return _orders_rows_sx(orders, page, total_pages, url_for_fn, qs_fn) + from shared.utils import route_prefix + + pfx = route_prefix() + list_url = pfx + url_for_fn("orders.list_orders") + detail_url_prefix = pfx + url_for_fn("orders.order.order_detail", order_id=0).rsplit("0/", 1)[0] + + order_dicts = [_serialize_order(o) for o in orders] + parts = [sx_call("order-row-pair", + order=SxExpr(serialize(od)), + detail_url_prefix=detail_url_prefix) + for od in order_dicts] + + if page < total_pages: + next_url = list_url + qs_fn(page=page + 1) + parts.append(sx_call( + "infinite-scroll", + url=next_url, page=page, total_pages=total_pages, + id_prefix="orders", colspan=5, + )) + else: + parts.append(sx_call("order-end-row")) + + return "(<> " + " ".join(parts) + ")" async def render_orders_oob(ctx: dict, orders: list, page: int, @@ -638,10 +249,16 @@ async def render_orders_oob(ctx: dict, orders: list, page: int, ctx["search"] = search ctx["search_count"] = search_count - list_url = route_prefix() + url_for_fn("orders.list_orders") + pfx = route_prefix() + list_url = pfx + url_for_fn("orders.list_orders") + rows_url = list_url + detail_url_prefix = pfx + url_for_fn("orders.order.order_detail", order_id=0).rsplit("0/", 1)[0] - rows = _orders_rows_sx(orders, page, total_pages, url_for_fn, qs_fn) - main = _orders_main_panel_sx(orders, rows) + order_dicts = [_serialize_order(o) for o in orders] + content = sx_call("orders-list-content", + orders=SxExpr(serialize(order_dicts)), + page=page, total_pages=total_pages, + rows_url=rows_url, detail_url_prefix=detail_url_prefix) auth_oob = _auth_header_sx(ctx, oob=True) auth_child_oob = sx_call( @@ -652,14 +269,15 @@ async def render_orders_oob(ctx: dict, orders: list, page: int, root_oob = root_header_sx(ctx, oob=True) oobs = "(<> " + auth_oob + " " + auth_child_oob + " " + root_oob + ")" + filt = sx_call("order-list-header", search_mobile=SxExpr(search_mobile_sx(ctx))) return oob_page_sx(oobs=oobs, - filter=_orders_summary_sx(ctx), + filter=filt, aside=search_desktop_sx(ctx), - content=main) + content=content) # --------------------------------------------------------------------------- -# Public API: Single order detail +# Public API: Single order detail (used by cart/bp/order/routes.py) # --------------------------------------------------------------------------- async def render_order_page(ctx: dict, order: Any, @@ -675,8 +293,16 @@ async def render_order_page(ctx: dict, order: Any, recheck_url = pfx + url_for_fn("orders.order.order_recheck", order_id=order.id) pay_url = pfx + url_for_fn("orders.order.order_pay", order_id=order.id) - main = _order_main_sx(order, calendar_entries) - filt = _order_filter_sx(order, list_url, recheck_url, pay_url, generate_csrf_token()) + order_data = _serialize_order(order) + cal_data = [_serialize_calendar_entry(e) for e in (calendar_entries or [])] + + main = sx_call("order-detail-content", + order=SxExpr(serialize(order_data)), + calendar_entries=SxExpr(serialize(cal_data))) + filt = sx_call("order-detail-filter-content", + order=SxExpr(serialize(order_data)), + list_url=list_url, recheck_url=recheck_url, + pay_url=pay_url, csrf=generate_csrf_token()) hdr = root_header_sx(ctx) order_row = sx_call( @@ -708,8 +334,16 @@ async def render_order_oob(ctx: dict, order: Any, recheck_url = pfx + url_for_fn("orders.order.order_recheck", order_id=order.id) pay_url = pfx + url_for_fn("orders.order.order_pay", order_id=order.id) - main = _order_main_sx(order, calendar_entries) - filt = _order_filter_sx(order, list_url, recheck_url, pay_url, generate_csrf_token()) + order_data = _serialize_order(order) + cal_data = [_serialize_calendar_entry(e) for e in (calendar_entries or [])] + + main = sx_call("order-detail-content", + order=SxExpr(serialize(order_data)), + calendar_entries=SxExpr(serialize(cal_data))) + filt = sx_call("order-detail-filter-content", + order=SxExpr(serialize(order_data)), + list_url=list_url, recheck_url=recheck_url, + pay_url=pay_url, csrf=generate_csrf_token()) order_row_oob = sx_call( "menu-row-sx", @@ -727,81 +361,42 @@ async def render_order_oob(ctx: dict, order: Any, # --------------------------------------------------------------------------- -# Public API: Checkout error +# Public API: Checkout error (used by cart/bp/cart routes + order routes) # --------------------------------------------------------------------------- -def _checkout_error_filter_sx() -> str: - return sx_call("checkout-error-header") - - -def _checkout_error_content_sx(error: str | None, order: Any | None) -> str: +async def render_checkout_error_page(ctx: dict, error: str | None = None, + order: Any | None = None) -> str: + """Full page: checkout error.""" err_msg = error or "Unexpected error while creating the hosted checkout session." order_sx = None if order: order_sx = sx_call("checkout-error-order-id", oid=f"#{order.id}") back_url = cart_url("/") - return sx_call( + + hdr = root_header_sx(ctx) + filt = sx_call("checkout-error-header") + content = sx_call( "checkout-error-content", msg=err_msg, order=SxExpr(order_sx) if order_sx else None, back_url=back_url, ) - - -async def render_checkout_error_page(ctx: dict, error: str | None = None, order: Any | None = None) -> str: - """Full page: checkout error.""" - hdr = root_header_sx(ctx) - filt = _checkout_error_filter_sx() - content = _checkout_error_content_sx(error, order) return full_page_sx(ctx, header_rows=hdr, filter=filt, content=content) # --------------------------------------------------------------------------- -# Page admin (//admin/) +# Public API: POST response renderers # --------------------------------------------------------------------------- -def _cart_page_admin_header_sx(ctx: dict, page_post: Any, *, oob: bool = False, - selected: str = "") -> str: - """Build the page-level admin header row -- delegates to shared helper.""" - slug = page_post.slug if page_post else "" - ctx = _ensure_post_ctx(ctx, page_post) - return post_admin_header_sx(ctx, slug, oob=oob, selected=selected) - - -def _cart_admin_main_panel_sx(ctx: dict) -> str: - """Admin overview panel -- links to sub-admin pages.""" - from quart import url_for - payments_href = url_for("defpage_cart_payments") - return ( - '(div :id "main-panel"' - ' (div :class "flex items-center justify-between p-3 border-b"' - ' (span :class "font-medium" (i :class "fa fa-credit-card text-purple-600 mr-1") " Payments")' - f' (a :href "{payments_href}" :class "text-sm underline" "configure")))' - ) - - -def _cart_payments_main_panel_sx(ctx: dict) -> str: - """Render SumUp payment config form.""" - from quart import url_for - csrf_token = ctx.get("csrf_token") - csrf = csrf_token() if callable(csrf_token) else (csrf_token or "") - page_config = ctx.get("page_config") - sumup_configured = bool(page_config and getattr(page_config, "sumup_api_key", None)) - merchant_code = (getattr(page_config, "sumup_merchant_code", None) or "") if page_config else "" - checkout_prefix = (getattr(page_config, "sumup_checkout_prefix", None) or "") if page_config else "" - update_url = url_for("page_admin.update_sumup") - - placeholder = "--------" if sumup_configured else "sup_sk_..." - input_cls = "w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500" - - return sx_call("cart-payments-panel", - update_url=update_url, csrf=csrf, - merchant_code=merchant_code, placeholder=placeholder, - input_cls=input_cls, sumup_configured=sumup_configured, - checkout_prefix=checkout_prefix) - - - def render_cart_payments_panel(ctx: dict) -> str: """Render the payments config panel for PUT response.""" - return _cart_payments_main_panel_sx(ctx) + page_config = ctx.get("page_config") + pc_data = None + if page_config: + pc_data = { + "sumup_api_key": bool(getattr(page_config, "sumup_api_key", None)), + "sumup_merchant_code": getattr(page_config, "sumup_merchant_code", None) or "", + "sumup_checkout_prefix": getattr(page_config, "sumup_checkout_prefix", None) or "", + } + return sx_call("cart-payments-content", + page_config=SxExpr(serialize(pc_data)) if pc_data else None) diff --git a/cart/sxc/pages/__init__.py b/cart/sxc/pages/__init__.py index 710e6ed..b41a861 100644 --- a/cart/sxc/pages/__init__.py +++ b/cart/sxc/pages/__init__.py @@ -90,20 +90,177 @@ def _register_cart_helpers() -> None: }) +# --------------------------------------------------------------------------- +# Serialization helpers +# --------------------------------------------------------------------------- + +def _serialize_cart_item(item: Any) -> dict: + """Serialize a cart item + product for SX defcomps.""" + from quart import url_for + from shared.infrastructure.urls import market_product_url + + p = item.product if hasattr(item, "product") else item + slug = p.slug if hasattr(p, "slug") else "" + unit_price = getattr(p, "special_price", None) or getattr(p, "regular_price", None) + currency = getattr(p, "regular_price_currency", "GBP") or "GBP" + return { + "slug": slug, + "title": p.title if hasattr(p, "title") else "", + "image": p.image if hasattr(p, "image") else None, + "brand": getattr(p, "brand", None), + "is_deleted": getattr(item, "is_deleted", False), + "unit_price": float(unit_price) if unit_price else None, + "special_price": float(p.special_price) if getattr(p, "special_price", None) else None, + "regular_price": float(p.regular_price) if getattr(p, "regular_price", None) else None, + "currency": currency, + "quantity": item.quantity, + "product_id": p.id, + "product_url": market_product_url(slug), + "qty_url": url_for("cart_global.update_quantity", product_id=p.id), + } + + +def _serialize_cal_entry(e: Any) -> dict: + """Serialize a calendar entry for SX defcomps.""" + name = getattr(e, "name", None) or getattr(e, "calendar_name", "") + start = e.start_at if hasattr(e, "start_at") else "" + end = getattr(e, "end_at", None) + cost = getattr(e, "cost", 0) or 0 + end_str = f" \u2013 {end}" if end else "" + return { + "name": name, + "date_str": f"{start}{end_str}", + "cost": float(cost), + } + + +def _serialize_ticket_group(tg: Any) -> dict: + """Serialize a ticket group for SX defcomps.""" + name = tg.entry_name if hasattr(tg, "entry_name") else tg.get("entry_name", "") + tt_name = tg.ticket_type_name if hasattr(tg, "ticket_type_name") else tg.get("ticket_type_name", "") + price = tg.price if hasattr(tg, "price") else tg.get("price", 0) + quantity = tg.quantity if hasattr(tg, "quantity") else tg.get("quantity", 0) + line_total = tg.line_total if hasattr(tg, "line_total") else tg.get("line_total", 0) + entry_id = tg.entry_id if hasattr(tg, "entry_id") else tg.get("entry_id", "") + tt_id = tg.ticket_type_id if hasattr(tg, "ticket_type_id") else tg.get("ticket_type_id", "") + start_at = tg.entry_start_at if hasattr(tg, "entry_start_at") else tg.get("entry_start_at") + end_at = tg.entry_end_at if hasattr(tg, "entry_end_at") else tg.get("entry_end_at") + + date_str = start_at.strftime("%-d %b %Y, %H:%M") if start_at else "" + if end_at: + date_str += f" \u2013 {end_at.strftime('%-d %b %Y, %H:%M')}" + + return { + "entry_name": name, + "ticket_type_name": tt_name or None, + "price": float(price or 0), + "quantity": quantity, + "line_total": float(line_total or 0), + "entry_id": entry_id, + "ticket_type_id": tt_id or None, + "date_str": date_str, + } + + +def _serialize_page_group(grp: Any) -> dict: + """Serialize a page group for SX defcomps.""" + post = grp.get("post") if isinstance(grp, dict) else getattr(grp, "post", None) + cart_items = grp.get("cart_items", []) if isinstance(grp, dict) else getattr(grp, "cart_items", []) + cal_entries = grp.get("calendar_entries", []) if isinstance(grp, dict) else getattr(grp, "calendar_entries", []) + tickets = grp.get("tickets", []) if isinstance(grp, dict) else getattr(grp, "tickets", []) + + if not cart_items and not cal_entries and not tickets: + return None + + post_data = None + if post: + post_data = { + "slug": post.slug if hasattr(post, "slug") else post.get("slug", ""), + "title": post.title if hasattr(post, "title") else post.get("title", ""), + "feature_image": post.feature_image if hasattr(post, "feature_image") else post.get("feature_image"), + } + market_place = grp.get("market_place") if isinstance(grp, dict) else getattr(grp, "market_place", None) + mp_data = None + if market_place: + mp_data = {"name": market_place.name if hasattr(market_place, "name") else market_place.get("name", "")} + + return { + "post": post_data, + "product_count": grp.get("product_count", 0) if isinstance(grp, dict) else getattr(grp, "product_count", 0), + "calendar_count": grp.get("calendar_count", 0) if isinstance(grp, dict) else getattr(grp, "calendar_count", 0), + "ticket_count": grp.get("ticket_count", 0) if isinstance(grp, dict) else getattr(grp, "ticket_count", 0), + "total": float(grp.get("total", 0) if isinstance(grp, dict) else getattr(grp, "total", 0)), + "market_place": mp_data, + } + + +def _build_summary_data(ctx: dict, cart: list, cal_entries: list, tickets: list, + total_fn, cal_total_fn, ticket_total_fn) -> dict: + """Build cart summary data dict for SX defcomps.""" + from quart import g, request, url_for + from shared.infrastructure.urls import login_url + from shared.utils import route_prefix + + product_qty = sum(ci.quantity for ci in cart) if cart else 0 + ticket_qty = len(tickets) if tickets else 0 + item_count = product_qty + ticket_qty + + product_total = total_fn(cart) or 0 + cal_total = cal_total_fn(cal_entries) or 0 + tk_total = ticket_total_fn(tickets) or 0 + grand = float(product_total) + float(cal_total) + float(tk_total) + + symbol = "\u00a3" + if cart and hasattr(cart[0], "product") and getattr(cart[0].product, "regular_price_currency", None): + cur = cart[0].product.regular_price_currency + symbol = "\u00a3" if cur == "GBP" else cur + + user = getattr(g, "user", None) + page_post = ctx.get("page_post") + + result = { + "item_count": item_count, + "grand_total": grand, + "symbol": symbol, + "is_logged_in": bool(user), + } + + if user: + if page_post: + action = url_for("page_cart.page_checkout") + else: + action = url_for("cart_global.checkout") + result["checkout_action"] = route_prefix() + action + result["user_email"] = user.email + else: + result["login_href"] = login_url(request.url) + + return result + + +# --------------------------------------------------------------------------- +# Page helper implementations +# --------------------------------------------------------------------------- + async def _h_overview_content(**kw): from quart import g - from shared.sx.page import get_template_context - from sx.sx_components import _overview_main_panel_sx + from shared.sx.helpers import sx_call, SxExpr + from shared.sx.parser import serialize + from shared.infrastructure.urls import cart_url from bp.cart.services import get_cart_grouped_by_page + page_groups = await get_cart_grouped_by_page(g.s) - ctx = await get_template_context() - return _overview_main_panel_sx(page_groups, ctx) + grp_dicts = [d for d in (_serialize_page_group(grp) for grp in page_groups) if d] + return sx_call("cart-overview-content", + page_groups=SxExpr(serialize(grp_dicts)), + cart_url_base=cart_url("")) async def _h_page_cart_content(page_slug=None, **kw): from quart import g + from shared.sx.helpers import sx_call, SxExpr + from shared.sx.parser import serialize from shared.sx.page import get_template_context - from sx.sx_components import _page_cart_main_panel_sx from bp.cart.services import total, calendar_total, ticket_total from bp.cart.services.page_cart import ( get_cart_for_page, get_calendar_entries_for_page, get_tickets_for_page, @@ -117,21 +274,42 @@ async def _h_page_cart_content(page_slug=None, **kw): ticket_groups = group_tickets(page_tickets) ctx = await get_template_context() - return _page_cart_main_panel_sx( - ctx, cart, cal_entries, page_tickets, ticket_groups, - total, calendar_total, ticket_total, - ) + sd = _build_summary_data(ctx, cart, cal_entries, page_tickets, + total, calendar_total, ticket_total) + + summary_sx = sx_call("cart-summary-from-data", + item_count=sd["item_count"], + grand_total=sd["grand_total"], + symbol=sd["symbol"], + is_logged_in=sd["is_logged_in"], + checkout_action=sd.get("checkout_action"), + login_href=sd.get("login_href"), + user_email=sd.get("user_email")) + + return sx_call("cart-page-cart-content", + cart_items=SxExpr(serialize([_serialize_cart_item(i) for i in cart])), + cal_entries=SxExpr(serialize([_serialize_cal_entry(e) for e in cal_entries])), + ticket_groups=SxExpr(serialize([_serialize_ticket_group(tg) for tg in ticket_groups])), + summary=SxExpr(summary_sx)) async def _h_cart_admin_content(page_slug=None, **kw): - from shared.sx.page import get_template_context - from sx.sx_components import _cart_admin_main_panel_sx - ctx = await get_template_context() - return _cart_admin_main_panel_sx(ctx) + return '(~cart-admin-content)' async def _h_cart_payments_content(page_slug=None, **kw): from shared.sx.page import get_template_context - from sx.sx_components import _cart_payments_main_panel_sx + from shared.sx.helpers import sx_call, SxExpr + from shared.sx.parser import serialize + ctx = await get_template_context() - return _cart_payments_main_panel_sx(ctx) + page_config = ctx.get("page_config") + pc_data = None + if page_config: + pc_data = { + "sumup_api_key": bool(getattr(page_config, "sumup_api_key", None)), + "sumup_merchant_code": getattr(page_config, "sumup_merchant_code", None) or "", + "sumup_checkout_prefix": getattr(page_config, "sumup_checkout_prefix", None) or "", + } + return sx_call("cart-payments-content", + page_config=SxExpr(serialize(pc_data)) if pc_data else None) diff --git a/federation/sx/notifications.sx b/federation/sx/notifications.sx index de56df6..05c0499 100644 --- a/federation/sx/notifications.sx +++ b/federation/sx/notifications.sx @@ -20,3 +20,50 @@ (defcomp ~federation-notifications-page (&key notifs) (h1 :class "text-2xl font-bold mb-6" "Notifications") notifs) + +;; Assembled notification card — replaces Python _notification_sx +(defcomp ~federation-notification-from-data (&key notif) + (let* ((from-name (or (get notif "from_actor_name") "?")) + (from-username (or (get notif "from_actor_username") "")) + (from-domain (or (get notif "from_actor_domain") "")) + (from-icon (get notif "from_actor_icon")) + (ntype (or (get notif "notification_type") "")) + (preview (get notif "target_content_preview")) + (created (or (get notif "created_at_formatted") "")) + (read (get notif "read")) + (app-domain (or (get notif "app_domain") "")) + (border (if (not read) " border-l-4 border-l-stone-400" "")) + (initial (if (and (not from-icon) from-name) + (upper (slice from-name 0 1)) "?")) + (action-text (cond + ((= ntype "follow") (str "followed you" + (if (and app-domain (!= app-domain "federation")) + (str " on " (escape app-domain)) ""))) + ((= ntype "like") "liked your post") + ((= ntype "boost") "boosted your post") + ((= ntype "mention") "mentioned you") + ((= ntype "reply") "replied to your post") + (true "")))) + (~federation-notification-card + :cls (str "bg-white rounded-lg shadow-sm border border-stone-200 p-4" border) + :avatar (~avatar + :src from-icon + :cls (if from-icon "w-8 h-8 rounded-full" + "w-8 h-8 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-xs") + :initial (when (not from-icon) initial)) + :from-name (escape from-name) + :from-username (escape from-username) + :from-domain (if from-domain (str "@" (escape from-domain)) "") + :action-text action-text + :preview (when preview (~federation-notification-preview :preview (escape preview))) + :time created))) + +;; Assembled notifications content — replaces Python _notifications_content_sx +(defcomp ~federation-notifications-content (&key notifications) + (~federation-notifications-page + :notifs (if (empty? notifications) + (~empty-state :message "No notifications yet." :cls "text-stone-500") + (~federation-notifications-list + :items (map (lambda (n) + (~federation-notification-from-data :notif n)) + notifications))))) diff --git a/federation/sx/profile.sx b/federation/sx/profile.sx index 9ce33e6..16108a0 100644 --- a/federation/sx/profile.sx +++ b/federation/sx/profile.sx @@ -53,3 +53,40 @@ (defcomp ~federation-profile-summary-text (&key text) (p :class "mt-2" text)) + +;; Assembled actor timeline content — replaces Python _actor_timeline_content_sx +(defcomp ~federation-actor-timeline-content (&key remote-actor items is-following actor) + (let* ((display-name (or (get remote-actor "display_name") (get remote-actor "preferred_username") "")) + (icon-url (get remote-actor "icon_url")) + (summary (get remote-actor "summary")) + (actor-url (or (get remote-actor "actor_url") "")) + (csrf (csrf-token)) + (initial (if (and (not icon-url) display-name) + (upper (slice display-name 0 1)) "?"))) + (~federation-actor-timeline-layout + :header (~federation-actor-profile-header + :avatar (~avatar + :src icon-url + :cls (if icon-url "w-16 h-16 rounded-full" + "w-16 h-16 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-xl") + :initial (when (not icon-url) initial)) + :display-name (escape display-name) + :username (escape (or (get remote-actor "preferred_username") "")) + :domain (escape (or (get remote-actor "domain") "")) + :summary (when summary (~federation-profile-summary :summary summary)) + :follow (when actor + (if is-following + (~federation-follow-form + :action (url-for "social.unfollow") :csrf csrf :actor-url actor-url + :label "Unfollow" + :cls "border border-stone-300 rounded px-4 py-2 hover:bg-stone-100") + (~federation-follow-form + :action (url-for "social.follow") :csrf csrf :actor-url actor-url + :label "Follow" + :cls "bg-stone-800 text-white rounded px-4 py-2 hover:bg-stone-700")))) + :timeline (~federation-timeline-items + :items items :timeline-type "actor" :actor actor + :next-url (when (not (empty? items)) + (url-for "social.actor_timeline_page" + :id (get remote-actor "id") + :before (get (last items) "before_cursor"))))))) diff --git a/federation/sx/search.sx b/federation/sx/search.sx index ad8d2c8..9ead36d 100644 --- a/federation/sx/search.sx +++ b/federation/sx/search.sx @@ -60,3 +60,99 @@ (h1 :class "text-2xl font-bold mb-6" title " " (span :class "text-stone-400 font-normal" count-str)) (div :id "actor-list" items)) + +;; --------------------------------------------------------------------------- +;; Assembled actor card — replaces Python _actor_card_sx +;; --------------------------------------------------------------------------- + +(defcomp ~federation-actor-card-from-data (&key a actor followed-urls list-type) + (let* ((display-name (or (get a "display_name") (get a "preferred_username") "")) + (username (or (get a "preferred_username") "")) + (domain (or (get a "domain") "")) + (icon-url (get a "icon_url")) + (actor-url (or (get a "actor_url") "")) + (summary (get a "summary")) + (aid (get a "id")) + (safe-id (replace (replace actor-url "/" "_") ":" "_")) + (initial (if (and (not icon-url) (or display-name username)) + (upper (slice (or display-name username) 0 1)) "?")) + (csrf (csrf-token)) + (is-followed (contains? (or followed-urls (list)) actor-url))) + (~federation-actor-card + :cls "bg-white rounded-lg shadow-sm border border-stone-200 p-4 mb-3 flex items-center gap-4" + :id (str "actor-" safe-id) + :avatar (~avatar + :src icon-url + :cls (if icon-url "w-12 h-12 rounded-full" + "w-12 h-12 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold") + :initial (when (not icon-url) initial)) + :name (if (and (or (= list-type "following") (= list-type "search")) aid) + (~federation-actor-name-link + :href (url-for "social.defpage_actor_timeline" :id aid) + :name (escape display-name)) + (~federation-actor-name-link-external + :href (str "https://" domain "/@" username) + :name (escape display-name))) + :username (escape username) + :domain (escape domain) + :summary (when summary (~federation-actor-summary :summary summary)) + :button (when actor + (if (or (= list-type "following") is-followed) + (~federation-unfollow-button + :action (url-for "social.unfollow") :csrf csrf :actor-url actor-url) + (~federation-follow-button + :action (url-for "social.follow") :csrf csrf :actor-url actor-url + :label (if (= list-type "followers") "Follow Back" "Follow"))))))) + +;; Assembled search content — replaces Python _search_content_sx +(defcomp ~federation-search-content (&key query actors total followed-urls actor) + (~federation-search-page + :search-url (url-for "social.defpage_search") + :search-page-url (url-for "social.search_page") + :query (escape (or query "")) + :info (cond + ((and query (> total 0)) + (~federation-search-info + :cls "text-sm text-stone-500 mb-4" + :text (str total " result" (pluralize total) " for " (escape query)))) + (query + (~federation-search-info + :cls "text-stone-500 mb-4" + :text (str "No results found for " (escape query)))) + (true nil)) + :results (when (not (empty? actors)) + (<> + (map (lambda (a) + (~federation-actor-card-from-data + :a a :actor actor :followed-urls followed-urls :list-type "search")) + actors) + (when (>= (len actors) 20) + (~federation-scroll-sentinel + :url (url-for "social.search_page" :q query :page 2))))))) + +;; Assembled following/followers content — replaces Python _following_content_sx etc. +(defcomp ~federation-following-content (&key actors total actor) + (~federation-actor-list-page + :title "Following" :count-str (str "(" total ")") + :items (when (not (empty? actors)) + (<> + (map (lambda (a) + (~federation-actor-card-from-data + :a a :actor actor :followed-urls (list) :list-type "following")) + actors) + (when (>= (len actors) 20) + (~federation-scroll-sentinel + :url (url-for "social.following_list_page" :page 2))))))) + +(defcomp ~federation-followers-content (&key actors total followed-urls actor) + (~federation-actor-list-page + :title "Followers" :count-str (str "(" total ")") + :items (when (not (empty? actors)) + (<> + (map (lambda (a) + (~federation-actor-card-from-data + :a a :actor actor :followed-urls followed-urls :list-type "followers")) + actors) + (when (>= (len actors) 20) + (~federation-scroll-sentinel + :url (url-for "social.followers_list_page" :page 2))))))) diff --git a/federation/sx/social.sx b/federation/sx/social.sx index b7b59ae..1f3fa8c 100644 --- a/federation/sx/social.sx +++ b/federation/sx/social.sx @@ -110,3 +110,129 @@ (option :value "unlisted" "Unlisted") (option :value "followers" "Followers only")) (button :type "submit" :class "bg-stone-800 text-white px-6 py-2 rounded hover:bg-stone-700" "Publish")))) + +;; --------------------------------------------------------------------------- +;; Assembled social nav — replaces Python _social_nav_sx +;; --------------------------------------------------------------------------- + +(defcomp ~federation-social-nav (&key actor) + (if (not actor) + (~federation-nav-choose-username :url (url-for "identity.choose_username_form")) + (let* ((rp (request-path)) + (links (list + (dict :endpoint "social.defpage_home_timeline" :label "Timeline") + (dict :endpoint "social.defpage_public_timeline" :label "Public") + (dict :endpoint "social.defpage_compose_form" :label "Compose") + (dict :endpoint "social.defpage_following_list" :label "Following") + (dict :endpoint "social.defpage_followers_list" :label "Followers") + (dict :endpoint "social.defpage_search" :label "Search")))) + (~federation-nav-bar + :items (<> + (map (lambda (lnk) + (let* ((href (url-for (get lnk "endpoint"))) + (bold (if (= rp href) " font-bold" ""))) + (a :href href + :class (str "px-2 py-1 rounded hover:bg-stone-200" bold) + (get lnk "label")))) + links) + (let* ((notif-url (url-for "social.defpage_notifications")) + (notif-bold (if (= rp notif-url) " font-bold" ""))) + (~federation-nav-notification-link + :href notif-url + :cls (str "px-2 py-1 rounded hover:bg-stone-200 relative" notif-bold) + :count-url (url-for "social.notification_count"))) + (a :href (url-for "activitypub.actor_profile" :username (get actor "preferred_username")) + :class "px-2 py-1 rounded hover:bg-stone-200" + (str "@" (get actor "preferred_username")))))))) + +;; --------------------------------------------------------------------------- +;; Assembled post card — replaces Python _post_card_sx +;; --------------------------------------------------------------------------- + +(defcomp ~federation-post-card-from-data (&key item actor) + (let* ((boosted-by (get item "boosted_by")) + (actor-icon (get item "actor_icon")) + (actor-name (or (get item "actor_name") "?")) + (actor-username (or (get item "actor_username") "")) + (actor-domain (or (get item "actor_domain") "")) + (content (or (get item "content") "")) + (summary (get item "summary")) + (published (or (get item "published") "")) + (url (get item "url")) + (post-type (or (get item "post_type") "")) + (oid (or (get item "object_id") "")) + (safe-id (replace (replace oid "/" "_") ":" "_")) + (initial (if (and (not actor-icon) actor-name) + (upper (slice actor-name 0 1)) "?"))) + (~federation-post-card + :boost (when boosted-by (~federation-boost-label :name (escape boosted-by))) + :avatar (~avatar + :src actor-icon + :cls (if actor-icon "w-10 h-10 rounded-full" + "w-10 h-10 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-sm") + :initial (when (not actor-icon) initial)) + :actor-name (escape actor-name) + :actor-username (escape actor-username) + :domain (if actor-domain (str "@" (escape actor-domain)) "") + :time published + :content (if summary + (~federation-content :content content :summary (escape summary)) + (~federation-content :content content)) + :original (when (and url (= post-type "remote")) + (~federation-original-link :url url)) + :interactions (when actor + (let* ((csrf (csrf-token)) + (liked (get item "liked_by_me")) + (boosted-me (get item "boosted_by_me")) + (lcount (or (get item "like_count") 0)) + (bcount (or (get item "boost_count") 0)) + (ainbox (or (get item "author_inbox") "")) + (target (str "#interactions-" safe-id))) + (div :id (str "interactions-" safe-id) + (~federation-interaction-buttons + :like (~federation-like-form + :action (url-for (if liked "social.unlike" "social.like")) + :target target :oid oid :ainbox ainbox :csrf csrf + :cls (str "flex items-center gap-1 " (if liked "text-red-500 hover:text-red-600" "hover:text-red-500")) + :icon (if liked "\u2665" "\u2661") :count (str lcount)) + :boost (~federation-boost-form + :action (url-for (if boosted-me "social.unboost" "social.boost")) + :target target :oid oid :ainbox ainbox :csrf csrf + :cls (str "flex items-center gap-1 " (if boosted-me "text-green-600 hover:text-green-700" "hover:text-green-600")) + :count (str bcount)) + :reply (when oid + (~federation-reply-link + :url (url-for "social.defpage_compose_form" :reply-to oid)))))))))) + +;; --------------------------------------------------------------------------- +;; Assembled timeline items — replaces Python _timeline_items_sx +;; --------------------------------------------------------------------------- + +(defcomp ~federation-timeline-items (&key items timeline-type actor next-url) + (<> + (map (lambda (item) + (~federation-post-card-from-data :item item :actor actor)) + items) + (when next-url + (~federation-scroll-sentinel :url next-url)))) + +;; Assembled timeline content — replaces Python _timeline_content_sx +(defcomp ~federation-timeline-content (&key items timeline-type actor) + (let* ((label (if (= timeline-type "home") "Home" "Public"))) + (~federation-timeline-page + :label label + :compose (when actor + (~federation-compose-button :url (url-for "social.defpage_compose_form"))) + :timeline (~federation-timeline-items + :items items :timeline-type timeline-type :actor actor + :next-url (when (not (empty? items)) + (url-for (str "social." timeline-type "_timeline_page") + :before (get (last items) "before_cursor"))))))) + +;; Assembled compose content — replaces Python _compose_content_sx +(defcomp ~federation-compose-content (&key reply-to) + (~federation-compose-form + :action (url-for "social.compose_submit") + :csrf (csrf-token) + :reply (when reply-to + (~federation-compose-reply :reply-to (escape reply-to))))) diff --git a/federation/sx/sx_components.py b/federation/sx/sx_components.py index 4435c3d..9f9371d 100644 --- a/federation/sx/sx_components.py +++ b/federation/sx/sx_components.py @@ -1,8 +1,10 @@ """ Federation service s-expression page components. -Renders social timeline, compose, search, following/followers, notifications, -actor profiles, login, and username selection pages. +Page helpers now call assembled defcomps in .sx files. This file contains +only functions still called directly from route handlers: full-page renders +(login, choose-username, profile) and POST fragment renderers (interaction +buttons, actor cards, pagination items). """ from __future__ import annotations @@ -23,63 +25,69 @@ load_service_components(os.path.dirname(os.path.dirname(__file__)), # --------------------------------------------------------------------------- -# Social header nav +# Serialization helpers (shared with pages/__init__.py) # --------------------------------------------------------------------------- -def _social_nav_sx(actor: Any) -> str: - """Build the social header nav bar content.""" - from quart import url_for, request - +def _serialize_actor(actor) -> dict | None: if not actor: - choose_url = url_for("identity.choose_username_form") - return sx_call("federation-nav-choose-username", url=choose_url) - - links = [ - ("social.defpage_home_timeline", "Timeline"), - ("social.defpage_public_timeline", "Public"), - ("social.defpage_compose_form", "Compose"), - ("social.defpage_following_list", "Following"), - ("social.defpage_followers_list", "Followers"), - ("social.defpage_search", "Search"), - ] - - parts = [] - for endpoint, label in links: - href = url_for(endpoint) - bold = " font-bold" if request.path == href else "" - cls = f"px-2 py-1 rounded hover:bg-stone-200{bold}" - parts.append(f'(a :href {serialize(href)} :class {serialize(cls)} {serialize(label)})') - - # Notifications with live badge - notif_url = url_for("social.defpage_notifications") - notif_count_url = url_for("social.notification_count") - notif_bold = " font-bold" if request.path == notif_url else "" - parts.append(sx_call( - "federation-nav-notification-link", - href=notif_url, - cls=f"px-2 py-1 rounded hover:bg-stone-200 relative{notif_bold}", - count_url=notif_count_url, - )) - - # Profile link - profile_url = url_for("activitypub.actor_profile", username=actor.preferred_username) - parts.append(f'(a :href {serialize(profile_url)} :class "px-2 py-1 rounded hover:bg-stone-200" {serialize("@" + actor.preferred_username)})') - - items_sx = "(<> " + " ".join(parts) + ")" - return sx_call("federation-nav-bar", items=SxExpr(items_sx)) + return None + return { + "id": actor.id, + "preferred_username": actor.preferred_username, + "display_name": getattr(actor, "display_name", None), + "icon_url": getattr(actor, "icon_url", None), + "summary": getattr(actor, "summary", None), + "actor_url": getattr(actor, "actor_url", ""), + "domain": getattr(actor, "domain", ""), + } -def _social_header_sx(actor: Any) -> str: - """Build the social section header row.""" - nav_sx = _social_nav_sx(actor) - return sx_call("federation-social-header", nav=SxExpr(nav_sx)) +def _serialize_timeline_item(item) -> dict: + published = getattr(item, "published", None) + return { + "object_id": getattr(item, "object_id", "") or "", + "author_inbox": getattr(item, "author_inbox", "") or "", + "actor_icon": getattr(item, "actor_icon", None), + "actor_name": getattr(item, "actor_name", "?"), + "actor_username": getattr(item, "actor_username", ""), + "actor_domain": getattr(item, "actor_domain", ""), + "content": getattr(item, "content", ""), + "summary": getattr(item, "summary", None), + "published": published.strftime("%b %d, %H:%M") if published else "", + "before_cursor": published.isoformat() if published else "", + "url": getattr(item, "url", None), + "post_type": getattr(item, "post_type", ""), + "boosted_by": getattr(item, "boosted_by", None), + "like_count": getattr(item, "like_count", 0) or 0, + "boost_count": getattr(item, "boost_count", 0) or 0, + "liked_by_me": getattr(item, "liked_by_me", False), + "boosted_by_me": getattr(item, "boosted_by_me", False), + } +def _serialize_remote_actor(a) -> dict: + return { + "id": getattr(a, "id", None), + "display_name": getattr(a, "display_name", None) or getattr(a, "preferred_username", ""), + "preferred_username": getattr(a, "preferred_username", ""), + "domain": getattr(a, "domain", ""), + "icon_url": getattr(a, "icon_url", None), + "actor_url": getattr(a, "actor_url", ""), + "summary": getattr(a, "summary", None), + } + + +# --------------------------------------------------------------------------- +# Social page shell +# --------------------------------------------------------------------------- + def _social_page(ctx: dict, actor: Any, *, content: str, title: str = "Rose Ash", meta_html: str = "") -> str: - """Render a social page with header and content.""" + actor_data = _serialize_actor(actor) + nav = sx_call("federation-social-nav", + actor=SxExpr(serialize(actor_data)) if actor_data else None) + social_hdr = sx_call("federation-social-header", nav=SxExpr(nav)) hdr = root_header_sx(ctx) - social_hdr = _social_header_sx(actor) child = header_child_sx(social_hdr) header_rows = "(<> " + hdr + " " + child + ")" return full_page_sx(ctx, header_rows=header_rows, content=content, @@ -87,562 +95,32 @@ def _social_page(ctx: dict, actor: Any, *, content: str, # --------------------------------------------------------------------------- -# Post card -# --------------------------------------------------------------------------- - -def _interaction_buttons_sx(item: Any, actor: Any) -> str: - """Render like/boost/reply buttons for a post.""" - from shared.browser.app.csrf import generate_csrf_token - from quart import url_for - - oid = getattr(item, "object_id", "") or "" - ainbox = getattr(item, "author_inbox", "") or "" - lcount = getattr(item, "like_count", 0) or 0 - bcount = getattr(item, "boost_count", 0) or 0 - liked = getattr(item, "liked_by_me", False) - boosted = getattr(item, "boosted_by_me", False) - csrf = generate_csrf_token() - - safe_id = oid.replace("/", "_").replace(":", "_") - target = f"#interactions-{safe_id}" - - if liked: - like_action = url_for("social.unlike") - like_cls = "text-red-500 hover:text-red-600" - like_icon = "\u2665" - else: - like_action = url_for("social.like") - like_cls = "hover:text-red-500" - like_icon = "\u2661" - - if boosted: - boost_action = url_for("social.unboost") - boost_cls = "text-green-600 hover:text-green-700" - else: - boost_action = url_for("social.boost") - boost_cls = "hover:text-green-600" - - reply_url = url_for("social.defpage_compose_form", reply_to=oid) if oid else "" - reply_sx = sx_call("federation-reply-link", url=reply_url) if reply_url else "" - - like_form = sx_call( - "federation-like-form", - action=like_action, target=target, oid=oid, ainbox=ainbox, - csrf=csrf, cls=f"flex items-center gap-1 {like_cls}", - icon=like_icon, count=str(lcount), - ) - - boost_form = sx_call( - "federation-boost-form", - action=boost_action, target=target, oid=oid, ainbox=ainbox, - csrf=csrf, cls=f"flex items-center gap-1 {boost_cls}", - count=str(bcount), - ) - - return sx_call( - "federation-interaction-buttons", - like=SxExpr(like_form), - boost=SxExpr(boost_form), - reply=SxExpr(reply_sx) if reply_sx else None, - ) - - -def _post_card_sx(item: Any, actor: Any) -> str: - """Render a single timeline post card.""" - boosted_by = getattr(item, "boosted_by", None) - actor_icon = getattr(item, "actor_icon", None) - actor_name = getattr(item, "actor_name", "?") - actor_username = getattr(item, "actor_username", "") - actor_domain = getattr(item, "actor_domain", "") - content = getattr(item, "content", "") - summary = getattr(item, "summary", None) - published = getattr(item, "published", None) - url = getattr(item, "url", None) - post_type = getattr(item, "post_type", "") - - boost_sx = sx_call( - "federation-boost-label", name=str(escape(boosted_by)), - ) if boosted_by else "" - - initial = actor_name[0].upper() if (not actor_icon and actor_name) else "?" - avatar = sx_call( - "avatar", src=actor_icon or None, - cls="w-10 h-10 rounded-full" if actor_icon else "w-10 h-10 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-sm", - initial=None if actor_icon else initial, - ) - - domain_str = f"@{escape(actor_domain)}" if actor_domain else "" - time_str = published.strftime("%b %d, %H:%M") if published else "" - - if summary: - content_sx = sx_call( - "federation-content", - content=content, summary=str(escape(summary)), - ) - else: - content_sx = sx_call("federation-content", content=content) - - original_sx = "" - if url and post_type == "remote": - original_sx = sx_call("federation-original-link", url=url) - - interactions_sx = "" - if actor: - oid = getattr(item, "object_id", "") or "" - safe_id = oid.replace("/", "_").replace(":", "_") - interactions_sx = f'(div :id {serialize(f"interactions-{safe_id}")} {_interaction_buttons_sx(item, actor)})' - - return sx_call( - "federation-post-card", - boost=SxExpr(boost_sx) if boost_sx else None, - avatar=SxExpr(avatar), - actor_name=str(escape(actor_name)), - actor_username=str(escape(actor_username)), - domain=domain_str, time=time_str, - content=SxExpr(content_sx), - original=SxExpr(original_sx) if original_sx else None, - interactions=SxExpr(interactions_sx) if interactions_sx else None, - ) - - -# --------------------------------------------------------------------------- -# Timeline items (pagination fragment) -# --------------------------------------------------------------------------- - -def _timeline_items_sx(items: list, timeline_type: str, actor: Any, - actor_id: int | None = None) -> str: - """Render timeline items with infinite scroll sentinel.""" - from quart import url_for - - parts = [_post_card_sx(item, actor) for item in items] - - if items: - last = items[-1] - before = last.published.isoformat() if last.published else "" - if timeline_type == "actor" and actor_id is not None: - next_url = url_for("social.actor_timeline_page", id=actor_id, before=before) - else: - next_url = url_for(f"social.{timeline_type}_timeline_page", before=before) - parts.append(sx_call("federation-scroll-sentinel", url=next_url)) - - return "(<> " + " ".join(parts) + ")" if parts else "" - - -# --------------------------------------------------------------------------- -# Search results (pagination fragment) -# --------------------------------------------------------------------------- - -def _actor_card_sx(a: Any, actor: Any, followed_urls: set, - *, list_type: str = "search") -> str: - """Render a single actor card with follow/unfollow button.""" - from shared.browser.app.csrf import generate_csrf_token - from quart import url_for - - csrf = generate_csrf_token() - display_name = getattr(a, "display_name", None) or getattr(a, "preferred_username", "") - username = getattr(a, "preferred_username", "") - domain = getattr(a, "domain", "") - icon_url = getattr(a, "icon_url", None) - actor_url = getattr(a, "actor_url", "") - summary = getattr(a, "summary", None) - aid = getattr(a, "id", None) - - safe_id = actor_url.replace("/", "_").replace(":", "_") - - initial = (display_name or username)[0].upper() if (not icon_url and (display_name or username)) else "?" - avatar = sx_call( - "avatar", src=icon_url or None, - cls="w-12 h-12 rounded-full" if icon_url else "w-12 h-12 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold", - initial=None if icon_url else initial, - ) - - # Name link - if (list_type in ("following", "search")) and aid: - name_sx = sx_call( - "federation-actor-name-link", - href=url_for("social.defpage_actor_timeline", id=aid), - name=str(escape(display_name)), - ) - else: - name_sx = sx_call( - "federation-actor-name-link-external", - href=f"https://{domain}/@{username}", - name=str(escape(display_name)), - ) - - summary_sx = sx_call("federation-actor-summary", summary=summary) if summary else "" - - # Follow/unfollow button - button_sx = "" - if actor: - is_followed = actor_url in (followed_urls or set()) - if list_type == "following" or is_followed: - button_sx = sx_call( - "federation-unfollow-button", - action=url_for("social.unfollow"), csrf=csrf, actor_url=actor_url, - ) - else: - label = "Follow Back" if list_type == "followers" else "Follow" - button_sx = sx_call( - "federation-follow-button", - action=url_for("social.follow"), csrf=csrf, actor_url=actor_url, label=label, - ) - - return sx_call( - "federation-actor-card", - cls="bg-white rounded-lg shadow-sm border border-stone-200 p-4 mb-3 flex items-center gap-4", - id=f"actor-{safe_id}", - avatar=SxExpr(avatar), - name=SxExpr(name_sx), - username=str(escape(username)), domain=str(escape(domain)), - summary=SxExpr(summary_sx) if summary_sx else None, - button=SxExpr(button_sx) if button_sx else None, - ) - - -def _search_results_sx(actors: list, query: str, page: int, - followed_urls: set, actor: Any) -> str: - """Render search results with pagination sentinel.""" - from quart import url_for - - parts = [_actor_card_sx(a, actor, followed_urls, list_type="search") for a in actors] - if len(actors) >= 20: - next_url = url_for("social.search_page", q=query, page=page + 1) - parts.append(sx_call("federation-scroll-sentinel", url=next_url)) - return "(<> " + " ".join(parts) + ")" if parts else "" - - -def _actor_list_items_sx(actors: list, page: int, list_type: str, - followed_urls: set, actor: Any) -> str: - """Render actor list items (following/followers) with pagination sentinel.""" - from quart import url_for - - parts = [_actor_card_sx(a, actor, followed_urls, list_type=list_type) for a in actors] - if len(actors) >= 20: - next_url = url_for(f"social.{list_type}_list_page", page=page + 1) - parts.append(sx_call("federation-scroll-sentinel", url=next_url)) - return "(<> " + " ".join(parts) + ")" if parts else "" - - -# --------------------------------------------------------------------------- -# Notification card -# --------------------------------------------------------------------------- - -def _notification_sx(notif: Any) -> str: - """Render a single notification.""" - from_name = getattr(notif, "from_actor_name", "?") - from_username = getattr(notif, "from_actor_username", "") - from_domain = getattr(notif, "from_actor_domain", "") - from_icon = getattr(notif, "from_actor_icon", None) - ntype = getattr(notif, "notification_type", "") - preview = getattr(notif, "target_content_preview", None) - created = getattr(notif, "created_at", None) - read = getattr(notif, "read", True) - app_domain = getattr(notif, "app_domain", "") - - border = " border-l-4 border-l-stone-400" if not read else "" - - initial = from_name[0].upper() if (not from_icon and from_name) else "?" - avatar = sx_call( - "avatar", src=from_icon or None, - cls="w-8 h-8 rounded-full" if from_icon else "w-8 h-8 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-xs", - initial=None if from_icon else initial, - ) - - domain_str = f"@{escape(from_domain)}" if from_domain else "" - - type_map = { - "follow": "followed you", - "like": "liked your post", - "boost": "boosted your post", - "mention": "mentioned you", - "reply": "replied to your post", - } - action = type_map.get(ntype, "") - if ntype == "follow" and app_domain and app_domain != "federation": - action += f" on {escape(app_domain)}" - - preview_sx = sx_call( - "federation-notification-preview", preview=str(escape(preview)), - ) if preview else "" - time_str = created.strftime("%b %d, %H:%M") if created else "" - - return sx_call( - "federation-notification-card", - cls=f"bg-white rounded-lg shadow-sm border border-stone-200 p-4{border}", - avatar=SxExpr(avatar), - from_name=str(escape(from_name)), - from_username=str(escape(from_username)), - from_domain=domain_str, action_text=action, - preview=SxExpr(preview_sx) if preview_sx else None, - time=time_str, - ) - - -# --------------------------------------------------------------------------- -# Public API: Home page +# Public API: Full page renders # --------------------------------------------------------------------------- async def render_federation_home(ctx: dict) -> str: - """Full page: federation home (minimal).""" hdr = root_header_sx(ctx) return full_page_sx(ctx, header_rows=hdr) -# --------------------------------------------------------------------------- -# Public API: Login -# --------------------------------------------------------------------------- - async def render_login_page(ctx: dict) -> str: - """Full page: federation login form.""" - from shared.browser.app.csrf import generate_csrf_token - from quart import url_for - error = ctx.get("error", "") email = ctx.get("email", "") - action = url_for("auth.start_login") - csrf = generate_csrf_token() - - error_sx = sx_call("auth-error-banner", error=error) if error else "" - - content = sx_call( - "auth-login-form", - error=SxExpr(error_sx) if error_sx else None, - action=action, csrf_token=csrf, - email=str(escape(email)), - ) - - return _social_page(ctx, None, content=content, - title="Login \u2014 Rose Ash") + content = sx_call("account-login-content", + error=error or None, email=str(escape(email))) + return _social_page(ctx, None, content=content, title="Login \u2014 Rose Ash") async def render_check_email_page(ctx: dict) -> str: - """Full page: check email after magic link sent.""" email = ctx.get("email", "") email_error = ctx.get("email_error") - - error_sx = sx_call( - "auth-check-email-error", error=str(escape(email_error)), - ) if email_error else "" - - content = sx_call( - "auth-check-email", - email=str(escape(email)), - error=SxExpr(error_sx) if error_sx else None, - ) - + content = sx_call("account-check-email-content", + email=str(escape(email)), email_error=email_error) return _social_page(ctx, None, content=content, title="Check your email \u2014 Rose Ash") -# --------------------------------------------------------------------------- -# Content builders (used by defpage before_request) -# --------------------------------------------------------------------------- - -def _timeline_content_sx(items: list, timeline_type: str, actor: Any) -> str: - """Build timeline content SX string.""" - from quart import url_for - - label = "Home" if timeline_type == "home" else "Public" - compose_sx = "" - if actor: - compose_url = url_for("social.defpage_compose_form") - compose_sx = sx_call("federation-compose-button", url=compose_url) - - timeline_sx = _timeline_items_sx(items, timeline_type, actor) - - return sx_call( - "federation-timeline-page", - label=label, - compose=SxExpr(compose_sx) if compose_sx else None, - timeline=SxExpr(timeline_sx) if timeline_sx else None, - ) - - -async def render_timeline_items(items: list, timeline_type: str, - actor: Any, actor_id: int | None = None) -> str: - """Pagination fragment: timeline items.""" - return _timeline_items_sx(items, timeline_type, actor, actor_id) - - -def _compose_content_sx(actor: Any, reply_to: str | None) -> str: - """Build compose form content SX string.""" - from shared.browser.app.csrf import generate_csrf_token - from quart import url_for - - csrf = generate_csrf_token() - action = url_for("social.compose_submit") - - reply_sx = "" - if reply_to: - reply_sx = sx_call( - "federation-compose-reply", - reply_to=str(escape(reply_to)), - ) - - return sx_call( - "federation-compose-form", - action=action, csrf=csrf, - reply=SxExpr(reply_sx) if reply_sx else None, - ) - - -def _search_content_sx(query: str, actors: list, total: int, - page: int, followed_urls: set, actor: Any) -> str: - """Build search page content SX string.""" - from quart import url_for - - search_url = url_for("social.defpage_search") - search_page_url = url_for("social.search_page") - - results_sx = _search_results_sx(actors, query, page, followed_urls, actor) - - info_sx = "" - if query and total: - s = "s" if total != 1 else "" - info_sx = sx_call( - "federation-search-info", - cls="text-sm text-stone-500 mb-4", - text=f"{total} result{s} for {escape(query)}", - ) - elif query: - info_sx = sx_call( - "federation-search-info", - cls="text-stone-500 mb-4", - text=f"No results found for {escape(query)}", - ) - - return sx_call( - "federation-search-page", - search_url=search_url, search_page_url=search_page_url, - query=str(escape(query)), - info=SxExpr(info_sx) if info_sx else None, - results=SxExpr(results_sx) if results_sx else None, - ) - - -async def render_search_results(actors: list, query: str, page: int, - followed_urls: set, actor: Any) -> str: - """Pagination fragment: search results.""" - return _search_results_sx(actors, query, page, followed_urls, actor) - - -def _following_content_sx(actors: list, total: int, actor: Any) -> str: - """Build following list content SX string.""" - items_sx = _actor_list_items_sx(actors, 1, "following", set(), actor) - return sx_call( - "federation-actor-list-page", - title="Following", count_str=f"({total})", - items=SxExpr(items_sx) if items_sx else None, - ) - - -async def render_following_items(actors: list, page: int, actor: Any) -> str: - """Pagination fragment: following items.""" - return _actor_list_items_sx(actors, page, "following", set(), actor) - - -def _followers_content_sx(actors: list, total: int, - followed_urls: set, actor: Any) -> str: - """Build followers list content SX string.""" - items_sx = _actor_list_items_sx(actors, 1, "followers", followed_urls, actor) - return sx_call( - "federation-actor-list-page", - title="Followers", count_str=f"({total})", - items=SxExpr(items_sx) if items_sx else None, - ) - - -async def render_followers_items(actors: list, page: int, - followed_urls: set, actor: Any) -> str: - """Pagination fragment: followers items.""" - return _actor_list_items_sx(actors, page, "followers", followed_urls, actor) - - -def _actor_timeline_content_sx(remote_actor: Any, items: list, - is_following: bool, actor: Any) -> str: - """Build actor timeline content SX string.""" - from shared.browser.app.csrf import generate_csrf_token - from quart import url_for - - csrf = generate_csrf_token() - display_name = remote_actor.display_name or remote_actor.preferred_username - icon_url = getattr(remote_actor, "icon_url", None) - summary = getattr(remote_actor, "summary", None) - actor_url = getattr(remote_actor, "actor_url", "") - - initial = display_name[0].upper() if (not icon_url and display_name) else "?" - avatar = sx_call( - "avatar", src=icon_url or None, - cls="w-16 h-16 rounded-full" if icon_url else "w-16 h-16 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-xl", - initial=None if icon_url else initial, - ) - - summary_sx = sx_call("federation-profile-summary", summary=summary) if summary else "" - - follow_sx = "" - if actor: - if is_following: - follow_sx = sx_call( - "federation-follow-form", - action=url_for("social.unfollow"), csrf=csrf, actor_url=actor_url, - label="Unfollow", - cls="border border-stone-300 rounded px-4 py-2 hover:bg-stone-100", - ) - else: - follow_sx = sx_call( - "federation-follow-form", - action=url_for("social.follow"), csrf=csrf, actor_url=actor_url, - label="Follow", - cls="bg-stone-800 text-white rounded px-4 py-2 hover:bg-stone-700", - ) - - timeline_sx = _timeline_items_sx(items, "actor", actor, remote_actor.id) - - header_sx = sx_call( - "federation-actor-profile-header", - avatar=SxExpr(avatar), - display_name=str(escape(display_name)), - username=str(escape(remote_actor.preferred_username)), - domain=str(escape(remote_actor.domain)), - summary=SxExpr(summary_sx) if summary_sx else None, - follow=SxExpr(follow_sx) if follow_sx else None, - ) - - return sx_call( - "federation-actor-timeline-layout", - header=SxExpr(header_sx), - timeline=SxExpr(timeline_sx) if timeline_sx else None, - ) - - -async def render_actor_timeline_items(items: list, actor_id: int, - actor: Any) -> str: - """Pagination fragment: actor timeline items.""" - return _timeline_items_sx(items, "actor", actor, actor_id) - - -def _notifications_content_sx(notifications: list) -> str: - """Build notifications content SX string.""" - if not notifications: - notif_sx = sx_call("empty-state", message="No notifications yet.", - cls="text-stone-500") - else: - items_sx = "(<> " + " ".join(_notification_sx(n) for n in notifications) + ")" - notif_sx = sx_call( - "federation-notifications-list", - items=SxExpr(items_sx), - ) - - return sx_call("federation-notifications-page", notifs=SxExpr(notif_sx)) - - -# --------------------------------------------------------------------------- -# Public API: Choose username -# --------------------------------------------------------------------------- - async def render_choose_username_page(ctx: dict) -> str: - """Full page: choose username form.""" from shared.browser.app.csrf import generate_csrf_token from quart import url_for from shared.config import config @@ -655,7 +133,6 @@ async def render_choose_username_page(ctx: dict) -> str: actor = ctx.get("actor") error_sx = sx_call("auth-error-banner", error=error) if error else "" - content = sx_call( "federation-choose-username", domain=str(escape(ap_domain)), @@ -663,18 +140,12 @@ async def render_choose_username_page(ctx: dict) -> str: csrf=csrf, username=str(escape(username)), check_url=check_url, ) - return _social_page(ctx, actor, content=content, title="Choose Username \u2014 Rose Ash") -# --------------------------------------------------------------------------- -# Public API: Actor profile -# --------------------------------------------------------------------------- - async def render_profile_page(ctx: dict, actor: Any, activities: list, total: int) -> str: - """Full page: actor profile.""" from shared.config import config ap_domain = config().get("ap_domain", "rose-ash.com") @@ -710,11 +181,95 @@ async def render_profile_page(ctx: dict, actor: Any, activities: list, activities_heading=f"Activities ({total})", activities=SxExpr(activities_sx), ) - return _social_page(ctx, actor, content=content, title=f"@{actor.preferred_username} \u2014 Rose Ash") +# --------------------------------------------------------------------------- +# Public API: Pagination fragment renderers +# --------------------------------------------------------------------------- + +async def render_timeline_items(items: list, timeline_type: str, + actor: Any, actor_id: int | None = None) -> str: + from quart import url_for + item_dicts = [_serialize_timeline_item(i) for i in items] + actor_data = _serialize_actor(actor) + + # Build next URL + next_url = None + if items: + last = items[-1] + before = last.published.isoformat() if last.published else "" + if timeline_type == "actor" and actor_id is not None: + next_url = url_for("social.actor_timeline_page", id=actor_id, before=before) + else: + next_url = url_for(f"social.{timeline_type}_timeline_page", before=before) + + return sx_call("federation-timeline-items", + items=SxExpr(serialize(item_dicts)), + timeline_type=timeline_type, + actor=SxExpr(serialize(actor_data)) if actor_data else None, + next_url=next_url) + + +async def render_search_results(actors: list, query: str, page: int, + followed_urls: set, actor: Any) -> str: + from quart import url_for + actor_dicts = [_serialize_remote_actor(a) for a in actors] + actor_data = _serialize_actor(actor) + parts = [] + for ad in actor_dicts: + parts.append(sx_call("federation-actor-card-from-data", + a=SxExpr(serialize(ad)), + actor=SxExpr(serialize(actor_data)) if actor_data else None, + followed_urls=SxExpr(serialize(list(followed_urls))), + list_type="search")) + if len(actors) >= 20: + next_url = url_for("social.search_page", q=query, page=page + 1) + parts.append(sx_call("federation-scroll-sentinel", url=next_url)) + return "(<> " + " ".join(parts) + ")" if parts else "" + + +async def render_following_items(actors: list, page: int, actor: Any) -> str: + from quart import url_for + actor_dicts = [_serialize_remote_actor(a) for a in actors] + actor_data = _serialize_actor(actor) + parts = [] + for ad in actor_dicts: + parts.append(sx_call("federation-actor-card-from-data", + a=SxExpr(serialize(ad)), + actor=SxExpr(serialize(actor_data)) if actor_data else None, + followed_urls=SxExpr(serialize([])), + list_type="following")) + if len(actors) >= 20: + next_url = url_for("social.following_list_page", page=page + 1) + parts.append(sx_call("federation-scroll-sentinel", url=next_url)) + return "(<> " + " ".join(parts) + ")" if parts else "" + + +async def render_followers_items(actors: list, page: int, + followed_urls: set, actor: Any) -> str: + from quart import url_for + actor_dicts = [_serialize_remote_actor(a) for a in actors] + actor_data = _serialize_actor(actor) + parts = [] + for ad in actor_dicts: + parts.append(sx_call("federation-actor-card-from-data", + a=SxExpr(serialize(ad)), + actor=SxExpr(serialize(actor_data)) if actor_data else None, + followed_urls=SxExpr(serialize(list(followed_urls))), + list_type="followers")) + if len(actors) >= 20: + next_url = url_for("social.followers_list_page", page=page + 1) + parts.append(sx_call("federation-scroll-sentinel", url=next_url)) + return "(<> " + " ".join(parts) + ")" if parts else "" + + +async def render_actor_timeline_items(items: list, actor_id: int, + actor: Any) -> str: + return await render_timeline_items(items, "actor", actor, actor_id) + + # --------------------------------------------------------------------------- # Public API: POST handler fragment renderers # --------------------------------------------------------------------------- @@ -723,20 +278,56 @@ def render_interaction_buttons(object_id: str, author_inbox: str, like_count: int, boost_count: int, liked_by_me: bool, boosted_by_me: bool, actor: Any) -> str: - """Render interaction buttons fragment for HTMX POST response.""" - from types import SimpleNamespace - item = SimpleNamespace( - object_id=object_id, - author_inbox=author_inbox, - like_count=like_count, - boost_count=boost_count, - liked_by_me=liked_by_me, - boosted_by_me=boosted_by_me, - ) - return _interaction_buttons_sx(item, actor) + """Render interaction buttons fragment for POST response.""" + from shared.browser.app.csrf import generate_csrf_token + from quart import url_for + + csrf = generate_csrf_token() + safe_id = object_id.replace("/", "_").replace(":", "_") + target = f"#interactions-{safe_id}" + + if liked_by_me: + like_action = url_for("social.unlike") + like_cls = "text-red-500 hover:text-red-600" + like_icon = "\u2665" + else: + like_action = url_for("social.like") + like_cls = "hover:text-red-500" + like_icon = "\u2661" + + if boosted_by_me: + boost_action = url_for("social.unboost") + boost_cls = "text-green-600 hover:text-green-700" + else: + boost_action = url_for("social.boost") + boost_cls = "hover:text-green-600" + + reply_url = url_for("social.defpage_compose_form", reply_to=object_id) if object_id else "" + reply_sx = sx_call("federation-reply-link", url=reply_url) if reply_url else "" + + like_form = sx_call("federation-like-form", + action=like_action, target=target, oid=object_id, ainbox=author_inbox, + csrf=csrf, cls=f"flex items-center gap-1 {like_cls}", + icon=like_icon, count=str(like_count)) + + boost_form = sx_call("federation-boost-form", + action=boost_action, target=target, oid=object_id, ainbox=author_inbox, + csrf=csrf, cls=f"flex items-center gap-1 {boost_cls}", + count=str(boost_count)) + + return sx_call("federation-interaction-buttons", + like=SxExpr(like_form), + boost=SxExpr(boost_form), + reply=SxExpr(reply_sx) if reply_sx else None) def render_actor_card(actor_dto: Any, actor: Any, followed_urls: set, *, list_type: str = "following") -> str: - """Render a single actor card fragment for HTMX POST response.""" - return _actor_card_sx(actor_dto, actor, followed_urls, list_type=list_type) + """Render a single actor card fragment for POST response.""" + actor_data = _serialize_actor(actor) + ad = _serialize_remote_actor(actor_dto) + return sx_call("federation-actor-card-from-data", + a=SxExpr(serialize(ad)), + actor=SxExpr(serialize(actor_data)) if actor_data else None, + followed_urls=SxExpr(serialize(list(followed_urls))), + list_type=list_type) diff --git a/federation/sxc/pages/__init__.py b/federation/sxc/pages/__init__.py index 4d30f0c..5d5fd3d 100644 --- a/federation/sxc/pages/__init__.py +++ b/federation/sxc/pages/__init__.py @@ -27,22 +27,28 @@ def _register_federation_layouts() -> None: def _social_full(ctx: dict, **kw: Any) -> str: - from shared.sx.helpers import root_header_sx, header_child_sx - from sx.sx_components import _social_header_sx + from shared.sx.helpers import root_header_sx, header_child_sx, sx_call, SxExpr + from shared.sx.parser import serialize actor = ctx.get("actor") + actor_data = _serialize_actor(actor) if actor else None + nav = sx_call("federation-social-nav", + actor=SxExpr(serialize(actor_data)) if actor_data else None) + social_hdr = sx_call("federation-social-header", nav=SxExpr(nav)) root_hdr = root_header_sx(ctx) - social_hdr = _social_header_sx(actor) child = header_child_sx(social_hdr) return "(<> " + root_hdr + " " + child + ")" def _social_oob(ctx: dict, **kw: Any) -> str: from shared.sx.helpers import root_header_sx, sx_call, SxExpr - from sx.sx_components import _social_header_sx + from shared.sx.parser import serialize actor = ctx.get("actor") - social_hdr = _social_header_sx(actor) + actor_data = _serialize_actor(actor) if actor else None + nav = sx_call("federation-social-nav", + actor=SxExpr(serialize(actor_data)) if actor_data else None) + social_hdr = sx_call("federation-social-header", nav=SxExpr(nav)) child_oob = sx_call("oob-header-sx", parent_id="root-header-child", row=SxExpr(social_hdr)) @@ -69,6 +75,58 @@ def _register_federation_helpers() -> None: }) +def _serialize_actor(actor) -> dict | None: + """Serialize an actor profile to a dict for sx defcomps.""" + if not actor: + return None + return { + "id": actor.id, + "preferred_username": actor.preferred_username, + "display_name": getattr(actor, "display_name", None), + "icon_url": getattr(actor, "icon_url", None), + "summary": getattr(actor, "summary", None), + "actor_url": getattr(actor, "actor_url", ""), + "domain": getattr(actor, "domain", ""), + } + + +def _serialize_timeline_item(item) -> dict: + """Serialize a timeline item DTO to a dict for sx defcomps.""" + published = getattr(item, "published", None) + return { + "object_id": getattr(item, "object_id", "") or "", + "author_inbox": getattr(item, "author_inbox", "") or "", + "actor_icon": getattr(item, "actor_icon", None), + "actor_name": getattr(item, "actor_name", "?"), + "actor_username": getattr(item, "actor_username", ""), + "actor_domain": getattr(item, "actor_domain", ""), + "content": getattr(item, "content", ""), + "summary": getattr(item, "summary", None), + "published": published.strftime("%b %d, %H:%M") if published else "", + "before_cursor": published.isoformat() if published else "", + "url": getattr(item, "url", None), + "post_type": getattr(item, "post_type", ""), + "boosted_by": getattr(item, "boosted_by", None), + "like_count": getattr(item, "like_count", 0) or 0, + "boost_count": getattr(item, "boost_count", 0) or 0, + "liked_by_me": getattr(item, "liked_by_me", False), + "boosted_by_me": getattr(item, "boosted_by_me", False), + } + + +def _serialize_remote_actor(a) -> dict: + """Serialize a remote actor DTO to a dict for sx defcomps.""" + return { + "id": getattr(a, "id", None), + "display_name": getattr(a, "display_name", None) or getattr(a, "preferred_username", ""), + "preferred_username": getattr(a, "preferred_username", ""), + "domain": getattr(a, "domain", ""), + "icon_url": getattr(a, "icon_url", None), + "actor_url": getattr(a, "actor_url", ""), + "summary": getattr(a, "summary", None), + } + + def _get_actor(): """Return current user's actor or None.""" from quart import g @@ -87,32 +145,43 @@ def _require_actor(): async def _h_home_timeline_content(**kw): from quart import g from shared.services.registry import services + from shared.sx.helpers import sx_call, SxExpr + from shared.sx.parser import serialize actor = _require_actor() items = await services.federation.get_home_timeline(g.s, actor.id) - from sx.sx_components import _timeline_content_sx - return _timeline_content_sx(items, "home", actor) + return sx_call("federation-timeline-content", + items=SxExpr(serialize([_serialize_timeline_item(i) for i in items])), + timeline_type="home", + actor=SxExpr(serialize(_serialize_actor(actor)))) async def _h_public_timeline_content(**kw): from quart import g from shared.services.registry import services + from shared.sx.helpers import sx_call, SxExpr + from shared.sx.parser import serialize actor = _get_actor() items = await services.federation.get_public_timeline(g.s) - from sx.sx_components import _timeline_content_sx - return _timeline_content_sx(items, "public", actor) + return sx_call("federation-timeline-content", + items=SxExpr(serialize([_serialize_timeline_item(i) for i in items])), + timeline_type="public", + actor=SxExpr(serialize(_serialize_actor(actor))) if actor else None) async def _h_compose_content(**kw): from quart import request - actor = _require_actor() - from sx.sx_components import _compose_content_sx + from shared.sx.helpers import sx_call + _require_actor() reply_to = request.args.get("reply_to") - return _compose_content_sx(actor, reply_to) + return sx_call("federation-compose-content", + reply_to=reply_to or None) async def _h_search_content(**kw): from quart import g, request from shared.services.registry import services + from shared.sx.helpers import sx_call, SxExpr + from shared.sx.parser import serialize actor = _get_actor() query = request.args.get("q", "").strip() actors_list = [] @@ -125,24 +194,34 @@ async def _h_search_content(**kw): g.s, actor.preferred_username, page=1, per_page=1000, ) followed_urls = {a.actor_url for a in following} - from sx.sx_components import _search_content_sx - return _search_content_sx(query, actors_list, total, 1, followed_urls, actor) + return sx_call("federation-search-content", + query=query, + actors=SxExpr(serialize([_serialize_remote_actor(a) for a in actors_list])), + total=total, + followed_urls=SxExpr(serialize(list(followed_urls))), + actor=SxExpr(serialize(_serialize_actor(actor))) if actor else None) async def _h_following_content(**kw): from quart import g from shared.services.registry import services + from shared.sx.helpers import sx_call, SxExpr + from shared.sx.parser import serialize actor = _require_actor() actors_list, total = await services.federation.get_following( g.s, actor.preferred_username, ) - from sx.sx_components import _following_content_sx - return _following_content_sx(actors_list, total, actor) + return sx_call("federation-following-content", + actors=SxExpr(serialize([_serialize_remote_actor(a) for a in actors_list])), + total=total, + actor=SxExpr(serialize(_serialize_actor(actor)))) async def _h_followers_content(**kw): from quart import g from shared.services.registry import services + from shared.sx.helpers import sx_call, SxExpr + from shared.sx.parser import serialize actor = _require_actor() actors_list, total = await services.federation.get_followers_paginated( g.s, actor.preferred_username, @@ -151,13 +230,18 @@ async def _h_followers_content(**kw): g.s, actor.preferred_username, page=1, per_page=1000, ) followed_urls = {a.actor_url for a in following} - from sx.sx_components import _followers_content_sx - return _followers_content_sx(actors_list, total, followed_urls, actor) + return sx_call("federation-followers-content", + actors=SxExpr(serialize([_serialize_remote_actor(a) for a in actors_list])), + total=total, + followed_urls=SxExpr(serialize(list(followed_urls))), + actor=SxExpr(serialize(_serialize_actor(actor)))) async def _h_actor_timeline_content(id=None, **kw): from quart import g, abort from shared.services.registry import services + from shared.sx.helpers import sx_call, SxExpr + from shared.sx.parser import serialize actor = _get_actor() actor_id = id from shared.models.federation import RemoteActor @@ -184,15 +268,35 @@ async def _h_actor_timeline_content(id=None, **kw): ) ).scalar_one_or_none() is_following = existing is not None - from sx.sx_components import _actor_timeline_content_sx - return _actor_timeline_content_sx(remote_dto, items, is_following, actor) + return sx_call("federation-actor-timeline-content", + remote_actor=SxExpr(serialize(_serialize_remote_actor(remote_dto))), + items=SxExpr(serialize([_serialize_timeline_item(i) for i in items])), + is_following=is_following, + actor=SxExpr(serialize(_serialize_actor(actor))) if actor else None) async def _h_notifications_content(**kw): from quart import g from shared.services.registry import services + from shared.sx.helpers import sx_call, SxExpr + from shared.sx.parser import serialize actor = _require_actor() items = await services.federation.get_notifications(g.s, actor.id) await services.federation.mark_notifications_read(g.s, actor.id) - from sx.sx_components import _notifications_content_sx - return _notifications_content_sx(items) + + notif_dicts = [] + for n in items: + created = getattr(n, "created_at", None) + notif_dicts.append({ + "from_actor_name": getattr(n, "from_actor_name", "?"), + "from_actor_username": getattr(n, "from_actor_username", ""), + "from_actor_domain": getattr(n, "from_actor_domain", ""), + "from_actor_icon": getattr(n, "from_actor_icon", None), + "notification_type": getattr(n, "notification_type", ""), + "target_content_preview": getattr(n, "target_content_preview", None), + "created_at_formatted": created.strftime("%b %d, %H:%M") if created else "", + "read": getattr(n, "read", True), + "app_domain": getattr(n, "app_domain", ""), + }) + return sx_call("federation-notifications-content", + notifications=SxExpr(serialize(notif_dicts))) diff --git a/orders/bp/orders/routes.py b/orders/bp/orders/routes.py index 2967d56..762d11a 100644 --- a/orders/bp/orders/routes.py +++ b/orders/bp/orders/routes.py @@ -73,11 +73,41 @@ def register(url_prefix: str) -> Blueprint: result = await g.s.execute(stmt) orders = result.scalars().all() - from sx.sx_components import _orders_rows_sx - from shared.sx.helpers import sx_response + from shared.sx.helpers import sx_response, sx_call, SxExpr + from shared.sx.parser import serialize + from shared.utils import route_prefix + pfx = route_prefix() + order_dicts = [] + for o in orders: + order_dicts.append({ + "id": o.id, + "status": o.status or "pending", + "created_at_formatted": o.created_at.strftime("%-d %b %Y, %H:%M") if o.created_at else "\u2014", + "description": o.description or "", + "currency": o.currency or "GBP", + "total_formatted": f"{o.total_amount or 0:.2f}", + }) + + detail_prefix = pfx + url_for("orders.defpage_order_detail", order_id=0).rsplit("0/", 1)[0] qs_fn = makeqs_factory() - sx_src = _orders_rows_sx(orders, page, total_pages, url_for, qs_fn) + rows_url = pfx + url_for("orders.orders_rows") + + # Build just the rows fragment (not full table) for infinite scroll + parts = [] + for od in order_dicts: + parts.append(sx_call("order-row-pair", + order=SxExpr(serialize(od)), + detail_url_prefix=detail_prefix)) + if page < total_pages: + parts.append(sx_call("infinite-scroll", + url=rows_url + qs_fn(page=page + 1), + page=page, total_pages=total_pages, + id_prefix="orders", colspan=5)) + else: + parts.append(sx_call("order-end-row")) + sx_src = "(<> " + " ".join(parts) + ")" + resp = sx_response(sx_src) resp.headers["Hx-Push-Url"] = _current_url_without_page() return _vary(resp) diff --git a/orders/sx/sx_components.py b/orders/sx/sx_components.py index d3615eb..d628723 100644 --- a/orders/sx/sx_components.py +++ b/orders/sx/sx_components.py @@ -1,9 +1,9 @@ """ Orders service s-expression page components. -Each function renders a complete page section (full page, OOB, or pagination) -using shared s-expression components. Called from route handlers in place -of ``render_template()``. +Checkout error/return pages are still rendered from Python because they +use ``full_page_sx()`` with custom layouts. All other order rendering +is now handled by .sx defcomps. """ from __future__ import annotations @@ -12,8 +12,8 @@ from typing import Any from shared.sx.jinja_bridge import load_service_components from shared.sx.helpers import ( - call_url, - sx_call, SxExpr, + call_url, sx_call, SxExpr, + root_header_sx, full_page_sx, header_child_sx, ) from shared.infrastructure.urls import market_product_url, cart_url @@ -22,260 +22,30 @@ load_service_components(os.path.dirname(os.path.dirname(__file__)), service_name="orders") -# --------------------------------------------------------------------------- -# Header helpers (shared auth + orders-specific) — sx-native -# --------------------------------------------------------------------------- - -def _auth_nav_sx(ctx: dict) -> str: - """Auth section desktop nav items as sx.""" - parts = [ - sx_call("nav-link", - href=call_url(ctx, "account_url", "/newsletters/"), - label="newsletters", - select_colours=ctx.get("select_colours", ""), - ), - ] - account_nav = ctx.get("account_nav") - if account_nav: - parts.append(str(account_nav)) - return "(<> " + " ".join(parts) + ")" - - -def _auth_header_sx(ctx: dict, *, oob: bool = False) -> str: - """Build the account section header row as sx.""" - return sx_call( - "menu-row-sx", - id="auth-row", level=1, colour="sky", - link_href=call_url(ctx, "account_url", "/"), - link_label="account", icon="fa-solid fa-user", - nav=SxExpr(_auth_nav_sx(ctx)), - child_id="auth-header-child", oob=oob, - ) - - -def _orders_header_sx(ctx: dict, list_url: str) -> str: - """Build the orders section header row as sx.""" - return sx_call( - "menu-row-sx", - id="orders-row", level=2, colour="sky", - link_href=list_url, link_label="Orders", icon="fa fa-gbp", - child_id="orders-header-child", - ) - - -# --------------------------------------------------------------------------- -# Orders list rendering -# --------------------------------------------------------------------------- - -def _status_pill_cls(status: str) -> str: - """Return Tailwind classes for order status pill.""" - sl = status.lower() - if sl == "paid": - return "border-emerald-300 bg-emerald-50 text-emerald-700" - if sl in ("failed", "cancelled"): - return "border-rose-300 bg-rose-50 text-rose-700" - return "border-stone-300 bg-stone-50 text-stone-700" - - -def _order_row_data(order: Any, detail_url: str) -> dict: - """Extract display data from an order model object.""" - status = order.status or "pending" - pill = _status_pill_cls(status) - created = order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else "\u2014" - total = f"{order.currency or 'GBP'} {order.total_amount or 0:.2f}" - return dict( - oid=f"#{order.id}", created=created, - desc=order.description or "", total=total, - pill_desktop=f"inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] sm:text-xs {pill}", - pill_mobile=f"inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] {pill}", - status=status, url=detail_url, - ) - - - -def _orders_rows_sx(orders: list, page: int, total_pages: int, - url_for_fn: Any, qs_fn: Any) -> str: - """S-expression wire format for order rows (client renders).""" - from shared.utils import route_prefix - pfx = route_prefix() - - parts = [] - for o in orders: - d = _order_row_data(o, pfx + url_for_fn("orders.defpage_order_detail", order_id=o.id)) - parts.append(sx_call("order-row-desktop", - oid=d["oid"], created=d["created"], - desc=d["desc"], total=d["total"], - pill=d["pill_desktop"], status=d["status"], - url=d["url"])) - parts.append(sx_call("order-row-mobile", - oid=d["oid"], created=d["created"], - total=d["total"], pill=d["pill_mobile"], - status=d["status"], url=d["url"])) - - if page < total_pages: - next_url = pfx + url_for_fn("orders.orders_rows") + qs_fn(page=page + 1) - parts.append(sx_call("infinite-scroll", - url=next_url, page=page, - total_pages=total_pages, - id_prefix="orders", colspan=5)) - else: - parts.append(sx_call("order-end-row")) - - return "(<> " + " ".join(parts) + ")" - - -def _orders_main_panel_sx(orders: list, rows_sx: str) -> str: - """Main panel with table or empty state (sx).""" - if not orders: - return sx_call("order-empty-state") - return sx_call("order-table", rows=SxExpr(rows_sx)) - - -def _orders_summary_sx(ctx: dict) -> str: - """Filter section for orders list (sx).""" - return sx_call("order-list-header", search_mobile=SxExpr(search_mobile_sx(ctx))) - - - -# --------------------------------------------------------------------------- -# Public API: orders list -# --------------------------------------------------------------------------- - - - - - -# --------------------------------------------------------------------------- -# Single order detail -# --------------------------------------------------------------------------- - -def _order_items_sx(order: Any) -> str: - """Render order items list as sx.""" - if not order or not order.items: - return "" - items = [] - for item in order.items: - prod_url = market_product_url(item.product_slug) - if item.product_image: - img = sx_call( - "order-item-image", - src=item.product_image, alt=item.product_title or "Product image", - ) - else: - img = sx_call("order-item-no-image") - - items.append(sx_call( - "order-item-row", - href=prod_url, img=SxExpr(img), - title=item.product_title or "Unknown product", - pid=f"Product ID: {item.product_id}", - qty=f"Qty: {item.quantity}", - price=f"{item.currency or order.currency or 'GBP'} {item.unit_price or 0:.2f}", - )) - - items_sx = "(<> " + " ".join(items) + ")" - return sx_call("order-items-panel", items=SxExpr(items_sx)) - - -def _calendar_items_sx(calendar_entries: list | None) -> str: - """Render calendar bookings for an order as sx.""" - if not calendar_entries: - return "" - items = [] - for e in calendar_entries: - st = e.state or "" - pill = ( - "bg-emerald-100 text-emerald-800" if st == "confirmed" - else "bg-amber-100 text-amber-800" if st == "provisional" - else "bg-blue-100 text-blue-800" if st == "ordered" - else "bg-stone-100 text-stone-700" - ) - ds = e.start_at.strftime("%-d %b %Y, %H:%M") if e.start_at else "" - if e.end_at: - ds += f" \u2013 {e.end_at.strftime('%-d %b %Y, %H:%M')}" - items.append(sx_call( - "order-calendar-entry", - name=e.name, - pill=f"inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium {pill}", - status=st.capitalize(), date_str=ds, - cost=f"\u00a3{e.cost or 0:.2f}", - )) - - items_sx = "(<> " + " ".join(items) + ")" - return sx_call("order-calendar-section", items=SxExpr(items_sx)) - - -def _order_main_sx(order: Any, calendar_entries: list | None) -> str: - """Main panel for single order detail (sx).""" - summary = sx_call( - "order-summary-card", - order_id=order.id, - created_at=order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else None, - description=order.description, status=order.status, currency=order.currency, - total_amount=f"{order.total_amount:.2f}" if order.total_amount else None, - ) - items = _order_items_sx(order) - calendar = _calendar_items_sx(calendar_entries) - return sx_call( - "order-detail-panel", - summary=SxExpr(summary), - items=SxExpr(items) if items else None, - calendar=SxExpr(calendar) if calendar else None, - ) - - -def _order_filter_sx(order: Any, list_url: str, recheck_url: str, - pay_url: str, csrf_token: str) -> str: - """Filter section for single order detail (sx).""" - created = order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else "\u2014" - status = order.status or "pending" - - pay = "" - if status != "paid": - pay = sx_call("order-pay-btn", url=pay_url) - - return sx_call( - "order-detail-filter", - info=f"Placed {created} \u00b7 Status: {status}", - list_url=list_url, recheck_url=recheck_url, - csrf=csrf_token, - pay=SxExpr(pay) if pay else None, - ) - - - - - - # --------------------------------------------------------------------------- # Public API: Checkout error # --------------------------------------------------------------------------- -def _checkout_error_filter_sx() -> str: - return sx_call("checkout-error-header") +async def render_checkout_error_page(ctx: dict, error: str | None = None, order: Any | None = None) -> str: + """Full page: checkout error (sx wire format).""" + account_url = call_url(ctx, "account_url", "") + auth_hdr = sx_call("auth-header-row", account_url=account_url) + hdr = root_header_sx(ctx) + hdr = "(<> " + hdr + " " + header_child_sx(auth_hdr) + ")" + filt = sx_call("checkout-error-header") - -def _checkout_error_content_sx(error: str | None, order: Any | None) -> str: err_msg = error or "Unexpected error while creating the hosted checkout session." order_sx = "" if order: order_sx = sx_call("checkout-error-order-id", oid=f"#{order.id}") back_url = cart_url("/") - return sx_call( + content = sx_call( "checkout-error-content", msg=err_msg, order=SxExpr(order_sx) if order_sx else None, back_url=back_url, ) - -async def render_checkout_error_page(ctx: dict, error: str | None = None, order: Any | None = None) -> str: - """Full page: checkout error (sx wire format).""" - hdr = root_header_sx(ctx) - inner = _auth_header_sx(ctx) - hdr = "(<> " + hdr + " " + header_child_sx(inner) + ")" - filt = _checkout_error_filter_sx() - content = _checkout_error_content_sx(error, order) return full_page_sx(ctx, header_rows=hdr, filter=filt, content=content) @@ -283,67 +53,127 @@ async def render_checkout_error_page(ctx: dict, error: str | None = None, order: # Public API: Checkout return # --------------------------------------------------------------------------- -def _ticket_items_sx(order_tickets: list | None) -> str: - """Render ticket items for an order as sx.""" - if not order_tickets: - return "" - items = [] - for tk in order_tickets: - st = tk.state or "" - pill = ( - "bg-emerald-100 text-emerald-800" if st == "confirmed" - else "bg-amber-100 text-amber-800" if st == "reserved" - else "bg-blue-100 text-blue-800" if st == "checked_in" - else "bg-stone-100 text-stone-700" - ) - pill_cls = f"inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium {pill}" - ds = tk.entry_start_at.strftime("%-d %b %Y, %H:%M") if tk.entry_start_at else "" - if tk.entry_end_at: - ds += f" – {tk.entry_end_at.strftime('%-d %b %Y, %H:%M')}" - items.append(sx_call( - "checkout-return-ticket", - name=tk.entry_name, - pill=pill_cls, - state=st.replace("_", " ").capitalize(), - type_name=tk.ticket_type_name or None, - date_str=ds, - code=tk.code, - price=f"£{tk.price or 0:.2f}", - )) - items_sx = "(<> " + " ".join(items) + ")" - return sx_call("checkout-return-tickets", items=SxExpr(items_sx)) - - async def render_checkout_return_page(ctx: dict, order: Any | None, status: str, calendar_entries: list | None = None, order_tickets: list | None = None) -> str: """Full page: checkout return after SumUp payment (sx wire format).""" + from shared.sx.parser import serialize + filt = sx_call("checkout-return-header", status=status) if not order: content = sx_call("checkout-return-missing") else: - summary = sx_call( - "order-summary-card", + # Serialize order data for defcomp + order_dict = { + "id": order.id, + "status": order.status or "pending", + "created_at_formatted": order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else None, + "description": order.description, + "currency": order.currency, + "total_formatted": f"{order.total_amount:.2f}" if order.total_amount else None, + "items": [ + { + "product_url": market_product_url(item.product_slug), + "product_image": item.product_image, + "product_title": item.product_title, + "product_id": item.product_id, + "quantity": item.quantity, + "currency": item.currency, + "unit_price_formatted": f"{item.unit_price or 0:.2f}", + } + for item in (order.items or []) + ], + } + + summary = sx_call("order-summary-card", order_id=order.id, - created_at=order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else None, + created_at=order_dict["created_at_formatted"], description=order.description, status=order.status, currency=order.currency, - total_amount=f"{order.total_amount:.2f}" if order.total_amount else None, + total_amount=order_dict["total_formatted"], ) - items = _order_items_sx(order) - calendar = _calendar_items_sx(calendar_entries) - tickets = _ticket_items_sx(order_tickets) + # Items + items = "" + if order.items: + item_parts = [] + for item_d in order_dict["items"]: + if item_d["product_image"]: + img = sx_call("order-item-image", + src=item_d["product_image"], + alt=item_d["product_title"] or "Product image") + else: + img = sx_call("order-item-no-image") + item_parts.append(sx_call("order-item-row", + href=item_d["product_url"], img=SxExpr(img), + title=item_d["product_title"] or "Unknown product", + pid=f"Product ID: {item_d['product_id']}", + qty=f"Qty: {item_d['quantity']}", + price=f"{item_d['currency'] or order.currency or 'GBP'} {item_d['unit_price_formatted']}", + )) + items = sx_call("order-items-panel", + items=SxExpr("(<> " + " ".join(item_parts) + ")")) + + # Calendar entries + calendar = "" + if calendar_entries: + cal_parts = [] + for e in calendar_entries: + st = e.state or "" + pill = ( + "bg-emerald-100 text-emerald-800" if st == "confirmed" + else "bg-amber-100 text-amber-800" if st == "provisional" + else "bg-blue-100 text-blue-800" if st == "ordered" + else "bg-stone-100 text-stone-700" + ) + ds = e.start_at.strftime("%-d %b %Y, %H:%M") if e.start_at else "" + if e.end_at: + ds += f" \u2013 {e.end_at.strftime('%-d %b %Y, %H:%M')}" + cal_parts.append(sx_call("order-calendar-entry", + name=e.name, + pill=f"inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium {pill}", + status=st.capitalize(), date_str=ds, + cost=f"\u00a3{e.cost or 0:.2f}", + )) + calendar = sx_call("order-calendar-section", + items=SxExpr("(<> " + " ".join(cal_parts) + ")")) + + # Tickets + tickets = "" + if order_tickets: + tk_parts = [] + for tk in order_tickets: + st = tk.state or "" + pill = ( + "bg-emerald-100 text-emerald-800" if st == "confirmed" + else "bg-amber-100 text-amber-800" if st == "reserved" + else "bg-blue-100 text-blue-800" if st == "checked_in" + else "bg-stone-100 text-stone-700" + ) + pill_cls = f"inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium {pill}" + ds = tk.entry_start_at.strftime("%-d %b %Y, %H:%M") if tk.entry_start_at else "" + if tk.entry_end_at: + ds += f" \u2013 {tk.entry_end_at.strftime('%-d %b %Y, %H:%M')}" + tk_parts.append(sx_call("checkout-return-ticket", + name=tk.entry_name, pill=pill_cls, + state=st.replace("_", " ").capitalize(), + type_name=tk.ticket_type_name or None, + date_str=ds, code=tk.code, + price=f"\u00a3{tk.price or 0:.2f}", + )) + tickets = sx_call("checkout-return-tickets", + items=SxExpr("(<> " + " ".join(tk_parts) + ")")) + + # Status message status_msg = "" if order.status == "failed": status_msg = sx_call("checkout-return-failed", order_id=order.id) elif order.status == "paid": status_msg = sx_call("checkout-return-paid") - content = sx_call( - "checkout-return-content", + content = sx_call("checkout-return-content", summary=SxExpr(summary), items=SxExpr(items) if items else None, calendar=SxExpr(calendar) if calendar else None, @@ -351,8 +181,9 @@ async def render_checkout_return_page(ctx: dict, order: Any | None, status_message=SxExpr(status_msg) if status_msg else None, ) + account_url = call_url(ctx, "account_url", "") + auth_hdr = sx_call("auth-header-row", account_url=account_url) hdr = root_header_sx(ctx) - inner = _auth_header_sx(ctx) - hdr = "(<> " + hdr + " " + header_child_sx(inner) + ")" + hdr = "(<> " + hdr + " " + header_child_sx(auth_hdr) + ")" return full_page_sx(ctx, header_rows=hdr, filter=filt, content=content) diff --git a/orders/sxc/pages/__init__.py b/orders/sxc/pages/__init__.py index b1ea93b..4c57038 100644 --- a/orders/sxc/pages/__init__.py +++ b/orders/sxc/pages/__init__.py @@ -29,24 +29,35 @@ def _register_orders_layouts() -> None: def _orders_full(ctx: dict, **kw: Any) -> str: - from shared.sx.helpers import root_header_sx, header_child_sx - from sx.sx_components import _auth_header_sx, _orders_header_sx + from shared.sx.helpers import root_header_sx, header_child_sx, call_url, sx_call, SxExpr list_url = kw.get("list_url", "/") + account_url = call_url(ctx, "account_url", "") root_hdr = root_header_sx(ctx) - inner = "(<> " + _auth_header_sx(ctx) + " " + _orders_header_sx(ctx, list_url) + ")" + auth_hdr = sx_call("auth-header-row", + account_url=account_url, + select_colours=ctx.get("select_colours", ""), + account_nav=_as_sx_nav(ctx), + ) + orders_hdr = sx_call("orders-header-row", list_url=list_url) + inner = "(<> " + auth_hdr + " " + orders_hdr + ")" return "(<> " + root_hdr + " " + header_child_sx(inner) + ")" def _orders_oob(ctx: dict, **kw: Any) -> str: - from shared.sx.helpers import root_header_sx, sx_call, SxExpr - from sx.sx_components import _auth_header_sx, _orders_header_sx + from shared.sx.helpers import root_header_sx, sx_call, SxExpr, call_url list_url = kw.get("list_url", "/") - auth_hdr = _auth_header_sx(ctx, oob=True) + account_url = call_url(ctx, "account_url", "") + auth_hdr = sx_call("auth-header-row", + account_url=account_url, + select_colours=ctx.get("select_colours", ""), + account_nav=_as_sx_nav(ctx), + oob=True, + ) auth_child_oob = sx_call("oob-header-sx", parent_id="auth-header-child", - row=SxExpr(_orders_header_sx(ctx, list_url))) + row=SxExpr(sx_call("orders-header-row", list_url=list_url))) root_hdr = root_header_sx(ctx, oob=True) return "(<> " + auth_hdr + " " + auth_child_oob + " " + root_hdr + ")" @@ -57,21 +68,26 @@ def _orders_mobile(ctx: dict, **kw: Any) -> str: def _order_detail_full(ctx: dict, **kw: Any) -> str: - from shared.sx.helpers import root_header_sx, sx_call, SxExpr - from sx.sx_components import _auth_header_sx, _orders_header_sx + from shared.sx.helpers import root_header_sx, sx_call, SxExpr, call_url list_url = kw.get("list_url", "/") detail_url = kw.get("detail_url", "/") + account_url = call_url(ctx, "account_url", "") root_hdr = root_header_sx(ctx) order_row = sx_call( "menu-row-sx", id="order-row", level=3, colour="sky", link_href=detail_url, link_label="Order", icon="fa fa-gbp", ) + auth_hdr = sx_call("auth-header-row", + account_url=account_url, + select_colours=ctx.get("select_colours", ""), + account_nav=_as_sx_nav(ctx), + ) detail_header = sx_call( "order-detail-header-stack", - auth=SxExpr(_auth_header_sx(ctx)), - orders=SxExpr(_orders_header_sx(ctx, list_url)), + auth=SxExpr(auth_hdr), + orders=SxExpr(sx_call("orders-header-row", list_url=list_url)), order=SxExpr(order_row), ) return "(<> " + root_hdr + " " + detail_header + ")" @@ -98,6 +114,12 @@ def _order_detail_mobile(ctx: dict, **kw: Any) -> str: return mobile_menu_sx(mobile_root_nav_sx(ctx)) +def _as_sx_nav(ctx: dict) -> Any: + """Convert account_nav fragment to SxExpr for use in sx_call.""" + from shared.sx.helpers import _as_sx + return _as_sx(ctx.get("account_nav")) + + # --------------------------------------------------------------------------- # Page helpers — Python functions callable from defpage expressions # --------------------------------------------------------------------------- @@ -257,14 +279,38 @@ async def _ensure_order_detail(order_id): async def _h_orders_list_content(**kw): await _ensure_orders_list() from quart import g + from shared.sx.helpers import sx_call, SxExpr + from shared.sx.parser import serialize d = getattr(g, "orders_page_data", None) if not d: - from shared.sx.helpers import sx_call return sx_call("order-empty-state") - from sx.sx_components import _orders_rows_sx, _orders_main_panel_sx - rows = _orders_rows_sx(d["orders"], d["page"], d["total_pages"], - d["url_for_fn"], d["qs_fn"]) - return _orders_main_panel_sx(d["orders"], rows) + + orders = d["orders"] + url_for_fn = d["url_for_fn"] + pfx = d.get("list_url", "/").rsplit("/", 1)[0] if d.get("list_url") else "" + + order_dicts = [] + for o in orders: + order_dicts.append({ + "id": o.id, + "status": o.status or "pending", + "created_at_formatted": o.created_at.strftime("%-d %b %Y, %H:%M") if o.created_at else "\u2014", + "description": o.description or "", + "currency": o.currency or "GBP", + "total_formatted": f"{o.total_amount or 0:.2f}", + }) + + from shared.utils import route_prefix + rpfx = route_prefix() + detail_prefix = rpfx + url_for_fn("orders.defpage_order_detail", order_id=0).rsplit("0/", 1)[0] + rows_url = rpfx + url_for_fn("orders.orders_rows") + + return sx_call("orders-list-content", + orders=SxExpr(serialize(order_dicts)), + page=d["page"], + total_pages=d["total_pages"], + rows_url=rows_url, + detail_url_prefix=detail_prefix) async def _h_orders_list_filter(**kw): @@ -312,22 +358,76 @@ async def _h_orders_list_url(**kw): async def _h_order_detail_content(order_id=None, **kw): await _ensure_order_detail(order_id) from quart import g + from shared.sx.helpers import sx_call, SxExpr + from shared.sx.parser import serialize + from shared.infrastructure.urls import market_product_url d = getattr(g, "order_detail_data", None) if not d: return "" - from sx.sx_components import _order_main_sx - return _order_main_sx(d["order"], d["calendar_entries"]) + + order = d["order"] + order_dict = { + "id": order.id, + "status": order.status or "pending", + "created_at_formatted": order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else None, + "description": order.description, + "currency": order.currency, + "total_formatted": f"{order.total_amount:.2f}" if order.total_amount else None, + "items": [ + { + "product_url": market_product_url(item.product_slug), + "product_image": item.product_image, + "product_title": item.product_title, + "product_id": item.product_id, + "quantity": item.quantity, + "currency": item.currency, + "unit_price_formatted": f"{item.unit_price or 0:.2f}", + } + for item in (order.items or []) + ], + } + + cal_entries = d["calendar_entries"] + cal_dicts = None + if cal_entries: + cal_dicts = [] + for e in cal_entries: + ds = e.start_at.strftime("%-d %b %Y, %H:%M") if e.start_at else "" + if e.end_at: + ds += f" \u2013 {e.end_at.strftime('%-d %b %Y, %H:%M')}" + cal_dicts.append({ + "name": e.name, + "state": e.state or "", + "date_str": ds, + "cost_formatted": f"{e.cost or 0:.2f}", + }) + + return sx_call("order-detail-content", + order=SxExpr(serialize(order_dict)), + calendar_entries=SxExpr(serialize(cal_dicts)) if cal_dicts else None) async def _h_order_detail_filter(order_id=None, **kw): await _ensure_order_detail(order_id) from quart import g + from shared.sx.helpers import sx_call, SxExpr + from shared.sx.parser import serialize d = getattr(g, "order_detail_data", None) if not d: return "" - from sx.sx_components import _order_filter_sx - return _order_filter_sx(d["order"], d["list_url"], d["recheck_url"], - d["pay_url"], d["csrf_token"]) + + order = d["order"] + order_dict = { + "status": order.status or "pending", + "created_at_formatted": order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else "\u2014", + } + + return sx_call("order-detail-filter-content", + order=SxExpr(serialize(order_dict)), + list_url=d["list_url"], + recheck_url=d["recheck_url"], + pay_url=d["pay_url"], + csrf=d["csrf_token"]) async def _h_order_detail_url(order_id=None, **kw): diff --git a/shared/sx/primitives.py b/shared/sx/primitives.py index 559db89..68bceac 100644 --- a/shared/sx/primitives.py +++ b/shared/sx/primitives.py @@ -498,6 +498,15 @@ def prim_format_date(date_str: Any, fmt: str) -> str: return str(date_str) if date_str else "" +@register_primitive("format-decimal") +def prim_format_decimal(val: Any, places: Any = 2) -> str: + """``(format-decimal val places)`` → formatted decimal string.""" + try: + return f"{float(val):.{int(places)}f}" + except (ValueError, TypeError): + return "0." + "0" * int(places) + + @register_primitive("parse-int") def prim_parse_int(val: Any, default: Any = 0) -> int | Any: """``(parse-int val default?)`` → int(val) with fallback.""" @@ -516,3 +525,34 @@ def prim_assert(condition: Any, message: str = "Assertion failed") -> bool: if not condition: raise RuntimeError(f"Assertion error: {message}") return True + + +# --------------------------------------------------------------------------- +# Text helpers +# --------------------------------------------------------------------------- + +@register_primitive("pluralize") +def prim_pluralize(count: Any, singular: str = "", plural: str = "s") -> str: + """``(pluralize count)`` → "s" if count != 1, else "". + ``(pluralize count "item" "items")`` → "item" or "items".""" + try: + n = int(count) + except (ValueError, TypeError): + n = 0 + if singular or plural != "s": + return singular if n == 1 else plural + return "" if n == 1 else "s" + + +@register_primitive("escape") +def prim_escape(s: Any) -> str: + """``(escape val)`` → HTML-escaped string.""" + from markupsafe import escape as _escape + return str(_escape(str(s) if s is not None and s is not NIL else "")) + + +@register_primitive("route-prefix") +def prim_route_prefix() -> str: + """``(route-prefix)`` → service URL prefix for dev/prod routing.""" + from shared.utils import route_prefix + return route_prefix() diff --git a/shared/sx/templates/auth.sx b/shared/sx/templates/auth.sx index 9e012a9..084a2b9 100644 --- a/shared/sx/templates/auth.sx +++ b/shared/sx/templates/auth.sx @@ -1,5 +1,44 @@ -;; Shared auth components — login flow, check email -;; Used by account and federation services. +;; Shared auth components — login flow, check email, header rows +;; Used by account, orders, cart, and federation services. + +;; --------------------------------------------------------------------------- +;; Auth / orders header rows — DRY extraction from per-service Python +;; --------------------------------------------------------------------------- + +;; Auth section nav items (newsletters link + account_nav slot) +(defcomp ~auth-nav-items (&key account-url select-colours account-nav) + (<> + (~nav-link :href (str (or account-url "") "/newsletters/") + :label "newsletters" + :select-colours (or select-colours "")) + (when account-nav account-nav))) + +;; Auth header row — wraps ~menu-row-sx for account section +(defcomp ~auth-header-row (&key account-url select-colours account-nav oob) + (~menu-row-sx :id "auth-row" :level 1 :colour "sky" + :link-href (str (or account-url "") "/") + :link-label "account" :icon "fa-solid fa-user" + :nav (~auth-nav-items :account-url account-url + :select-colours select-colours + :account-nav account-nav) + :child-id "auth-header-child" :oob oob)) + +;; Auth header row without nav (for cart service) +(defcomp ~auth-header-row-simple (&key account-url oob) + (~menu-row-sx :id "auth-row" :level 1 :colour "sky" + :link-href (str (or account-url "") "/") + :link-label "account" :icon "fa-solid fa-user" + :child-id "auth-header-child" :oob oob)) + +;; Orders header row +(defcomp ~orders-header-row (&key list-url) + (~menu-row-sx :id "orders-row" :level 2 :colour "sky" + :link-href list-url :link-label "Orders" :icon "fa fa-gbp" + :child-id "orders-header-child")) + +;; --------------------------------------------------------------------------- +;; Auth forms — login flow, check email +;; --------------------------------------------------------------------------- (defcomp ~auth-error-banner (&key error) (when error diff --git a/shared/sx/templates/orders.sx b/shared/sx/templates/orders.sx index 3c43794..a05f97b 100644 --- a/shared/sx/templates/orders.sx +++ b/shared/sx/templates/orders.sx @@ -124,6 +124,155 @@ ;; Checkout error screens ;; --------------------------------------------------------------------------- +;; --------------------------------------------------------------------------- +;; Assembled order list content — replaces Python _orders_rows_sx / _orders_main_panel_sx +;; --------------------------------------------------------------------------- + +;; Status pill class mapping +(defcomp ~order-status-pill-cls (&key status) + (let* ((sl (lower (or status "")))) + (cond + ((= sl "paid") "border-emerald-300 bg-emerald-50 text-emerald-700") + ((or (= sl "failed") (= sl "cancelled")) "border-rose-300 bg-rose-50 text-rose-700") + (true "border-stone-300 bg-stone-50 text-stone-700")))) + +;; Single order row pair (desktop + mobile) — takes serialized order data dict +(defcomp ~order-row-pair (&key order detail-url-prefix) + (let* ((status (or (get order "status") "pending")) + (pill-base (~order-status-pill-cls :status status)) + (oid (str "#" (get order "id"))) + (created (or (get order "created_at_formatted") "\u2014")) + (desc (or (get order "description") "")) + (total (str (or (get order "currency") "GBP") " " (or (get order "total_formatted") "0.00"))) + (url (str detail-url-prefix (get order "id") "/"))) + (<> + (~order-row-desktop + :oid oid :created created :desc desc :total total + :pill (str "inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] sm:text-xs " pill-base) + :status status :url url) + (~order-row-mobile + :oid oid :created created :total total + :pill (str "inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] " pill-base) + :status status :url url)))) + +;; Assembled orders list content +(defcomp ~orders-list-content (&key orders page total-pages rows-url detail-url-prefix) + (if (empty? orders) + (~order-empty-state) + (~order-table + :rows (<> + (map (lambda (order) + (~order-row-pair :order order :detail-url-prefix detail-url-prefix)) + orders) + (if (< page total-pages) + (~infinite-scroll + :url (str rows-url "?page=" (inc page)) + :page page :total-pages total-pages + :id-prefix "orders" :colspan 5) + (~order-end-row)))))) + +;; Assembled order detail content — replaces Python _order_main_sx +(defcomp ~order-detail-content (&key order calendar-entries) + (let* ((items (get order "items"))) + (~order-detail-panel + :summary (~order-summary-card + :order-id (get order "id") + :created-at (get order "created_at_formatted") + :description (get order "description") + :status (get order "status") + :currency (get order "currency") + :total-amount (get order "total_formatted")) + :items (when (not (empty? (or items (list)))) + (~order-items-panel + :items (map (lambda (item) + (~order-item-row + :href (get item "product_url") + :img (if (get item "product_image") + (~order-item-image :src (get item "product_image") + :alt (or (get item "product_title") "Product image")) + (~order-item-no-image)) + :title (or (get item "product_title") "Unknown product") + :pid (str "Product ID: " (get item "product_id")) + :qty (str "Qty: " (get item "quantity")) + :price (str (or (get item "currency") (get order "currency") "GBP") " " (or (get item "unit_price_formatted") "0.00")))) + items))) + :calendar (when (not (empty? (or calendar-entries (list)))) + (~order-calendar-section + :items (map (lambda (e) + (let* ((st (or (get e "state") "")) + (pill (cond + ((= st "confirmed") "bg-emerald-100 text-emerald-800") + ((= st "provisional") "bg-amber-100 text-amber-800") + ((= st "ordered") "bg-blue-100 text-blue-800") + (true "bg-stone-100 text-stone-700")))) + (~order-calendar-entry + :name (get e "name") + :pill (str "inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium " pill) + :status (upper (slice st 0 1)) + :date-str (get e "date_str") + :cost (str "\u00a3" (or (get e "cost_formatted") "0.00"))))) + calendar-entries)))))) + +;; Assembled order detail filter — replaces Python _order_filter_sx +(defcomp ~order-detail-filter-content (&key order list-url recheck-url pay-url csrf) + (let* ((status (or (get order "status") "pending")) + (created (or (get order "created_at_formatted") "\u2014"))) + (~order-detail-filter + :info (str "Placed " created " \u00b7 Status: " status) + :list-url list-url + :recheck-url recheck-url + :csrf csrf + :pay (when (!= status "paid") + (~order-pay-btn :url pay-url))))) + +;; --------------------------------------------------------------------------- +;; Checkout return components +;; --------------------------------------------------------------------------- + +(defcomp ~checkout-return-header (&key status) + (header :class "mb-6 sm:mb-8" + (h1 :class "text-xl sm:text-2xl md:text-3xl font-semibold tracking-tight" "Payment complete") + (p :class "text-xs sm:text-sm text-stone-600" + (str "Your checkout session is " status ".")))) + +(defcomp ~checkout-return-missing () + (div :class "max-w-full px-3 py-3 space-y-4" + (p :class "text-sm text-stone-600" "Order not found."))) + +(defcomp ~checkout-return-ticket (&key name pill state type-name date-str code price) + (li :class "px-4 py-3 flex items-start justify-between text-sm" + (div + (div :class "font-medium flex items-center gap-2" + name (span :class pill state)) + (when type-name (div :class "text-xs text-stone-500" type-name)) + (div :class "text-xs text-stone-500" date-str) + (when code (div :class "font-mono text-xs text-stone-400" code))) + (div :class "ml-4 font-medium" price))) + +(defcomp ~checkout-return-tickets (&key items) + (section :class "mt-6 space-y-3" + (h2 :class "text-base sm:text-lg font-semibold" "Tickets") + (ul :class "divide-y divide-stone-200 rounded-2xl border border-stone-200 bg-white/80" items))) + +(defcomp ~checkout-return-failed (&key order-id) + (div :class "rounded-lg border border-rose-200 bg-rose-50 p-4 text-sm text-rose-900" + (p :class "font-medium" "Payment failed") + (p "Please try again or contact support." + (when order-id (span " Order #" (str order-id)))))) + +(defcomp ~checkout-return-paid () + (div :class "rounded-lg border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-900" + (p :class "font-medium" "Payment successful!") + (p "Your order has been confirmed."))) + +(defcomp ~checkout-return-content (&key summary items calendar tickets status-message) + (div :class "max-w-full px-3 py-3 space-y-4" + status-message summary items calendar tickets)) + +;; --------------------------------------------------------------------------- +;; Checkout error screens +;; --------------------------------------------------------------------------- + (defcomp ~checkout-error-header () (header :class "mb-6 sm:mb-8" (h1 :class "text-xl sm:text-2xl md:text-3xl font-semibold tracking-tight" "Checkout error")