Move SX construction from Python to .sx defcomps (phases 0-4)

Eliminate Python s-expression string building across account, orders,
federation, and cart services. Visual rendering logic now lives entirely
in .sx defcomp components; Python files contain only data serialization,
header/layout wiring, and thin wrappers that call defcomps.

Phase 0: Shared DRY extraction — auth/orders header defcomps, format-decimal/
pluralize/escape/route-prefix primitives.
Phase 1: Account — dashboard, newsletters, login/device/check-email content.
Phase 2: Orders — order list, detail, filter, checkout return assembled defcomps.
Phase 3: Federation — social nav, post cards, timeline, search, actors,
notifications, compose, profile assembled defcomps.
Phase 4: Cart — overview, page cart items/calendar/tickets/summary, admin,
payments assembled defcomps; orders rendering reuses Phase 2 shared defcomps.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-03 22:36:34 +00:00
parent 03f0929fdf
commit 193578ef88
23 changed files with 1824 additions and 1795 deletions

View File

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

View File

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

View File

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

View File

@@ -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='<title>Login \u2014 Rose Ash</title>')
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='<title>Authorize Device \u2014 Rose Ash</title>')
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='<title>Device Authorized \u2014 Rose Ash</title>')
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='<title>Check your email \u2014 Rose Ash</title>')
# ---------------------------------------------------------------------------
# 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='<title>Login \u2014 Rose Ash</title>')
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='<title>Authorize Device \u2014 Rose Ash</title>')
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='<title>Device Authorized \u2014 Rose Ash</title>')
# ---------------------------------------------------------------------------
# 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='<title>Check your email \u2014 Rose Ash</title>')
# ---------------------------------------------------------------------------
# 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('"', '\\"')

View File

@@ -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):

View File

@@ -8,7 +8,7 @@
:path "/"
:auth :login
:layout :account
:content (account-content))
:content (~account-dashboard-content))
;; ---------------------------------------------------------------------------
;; Newsletters

View File

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

View File

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

View File

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

View File

@@ -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 (/<page_slug>/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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 <strong>{escape(query)}</strong>",
)
elif query:
info_sx = sx_call(
"federation-search-info",
cls="text-stone-500 mb-4",
text=f"No results found for <strong>{escape(query)}</strong>",
)
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)

View File

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

View File

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

View File

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

View File

@@ -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):

View File

@@ -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()

View File

@@ -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

View File

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