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")