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:
@@ -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)))))
|
||||
|
||||
|
||||
@@ -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")))))))
|
||||
|
||||
@@ -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))))))
|
||||
|
||||
@@ -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('"', '\\"')
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
:path "/"
|
||||
:auth :login
|
||||
:layout :account
|
||||
:content (account-content))
|
||||
:content (~account-dashboard-content))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Newsletters
|
||||
|
||||
111
cart/sx/items.sx
111
cart/sx/items.sx
@@ -52,3 +52,114 @@
|
||||
(div :id "cart"
|
||||
(div (section :class "space-y-3 sm:space-y-4" items cal tickets)
|
||||
summary))))
|
||||
|
||||
;; Assembled cart item from serialized data — replaces Python _cart_item_sx
|
||||
(defcomp ~cart-item-from-data (&key item)
|
||||
(let* ((slug (or (get item "slug") ""))
|
||||
(title (or (get item "title") ""))
|
||||
(image (get item "image"))
|
||||
(brand (get item "brand"))
|
||||
(is-deleted (get item "is_deleted"))
|
||||
(unit-price (get item "unit_price"))
|
||||
(special-price (get item "special_price"))
|
||||
(regular-price (get item "regular_price"))
|
||||
(currency (or (get item "currency") "GBP"))
|
||||
(symbol (if (= currency "GBP") "\u00a3" currency))
|
||||
(quantity (or (get item "quantity") 1))
|
||||
(product-id (get item "product_id"))
|
||||
(prod-url (or (get item "product_url") ""))
|
||||
(qty-url (or (get item "qty_url") ""))
|
||||
(csrf (csrf-token))
|
||||
(line-total (when unit-price (* unit-price quantity))))
|
||||
(~cart-item
|
||||
:id (str "cart-item-" slug)
|
||||
:img (if image
|
||||
(~cart-item-img :src image :alt title)
|
||||
(~img-or-placeholder :src nil
|
||||
:size-cls "w-24 h-24 sm:w-32 sm:h-28 rounded-xl border border-dashed border-stone-300"
|
||||
:placeholder-text "No image"))
|
||||
:prod-url prod-url
|
||||
:title title
|
||||
:brand (when brand (~cart-item-brand :brand brand))
|
||||
:deleted (when is-deleted (~cart-item-deleted))
|
||||
:price (if unit-price
|
||||
(<>
|
||||
(~cart-item-price :text (str symbol (format-decimal unit-price 2)))
|
||||
(when (and special-price (!= special-price regular-price))
|
||||
(~cart-item-price-was :text (str symbol (format-decimal regular-price 2)))))
|
||||
(~cart-item-no-price))
|
||||
:qty-url qty-url :csrf csrf
|
||||
:minus (str (- quantity 1))
|
||||
:qty (str quantity)
|
||||
:plus (str (+ quantity 1))
|
||||
:line-total (when line-total
|
||||
(~cart-item-line-total :text (str "Line total: " symbol (format-decimal line-total 2)))))))
|
||||
|
||||
;; Assembled calendar entries section — replaces Python _calendar_entries_sx
|
||||
(defcomp ~cart-cal-section-from-data (&key entries)
|
||||
(when (not (empty? entries))
|
||||
(~cart-cal-section
|
||||
:items (map (lambda (e)
|
||||
(let* ((name (or (get e "name") ""))
|
||||
(date-str (or (get e "date_str") "")))
|
||||
(~cart-cal-entry
|
||||
:name name :date-str date-str
|
||||
:cost (str "\u00a3" (format-decimal (or (get e "cost") 0) 2)))))
|
||||
entries))))
|
||||
|
||||
;; Assembled ticket groups section — replaces Python _ticket_groups_sx
|
||||
(defcomp ~cart-tickets-section-from-data (&key ticket-groups)
|
||||
(when (not (empty? ticket-groups))
|
||||
(let* ((csrf (csrf-token))
|
||||
(qty-url (url-for "cart_global.update_ticket_quantity")))
|
||||
(~cart-tickets-section
|
||||
:items (map (lambda (tg)
|
||||
(let* ((name (or (get tg "entry_name") ""))
|
||||
(tt-name (get tg "ticket_type_name"))
|
||||
(price (or (get tg "price") 0))
|
||||
(quantity (or (get tg "quantity") 0))
|
||||
(line-total (or (get tg "line_total") 0))
|
||||
(entry-id (str (or (get tg "entry_id") "")))
|
||||
(tt-id (get tg "ticket_type_id"))
|
||||
(date-str (or (get tg "date_str") "")))
|
||||
(~cart-ticket-article
|
||||
:name name
|
||||
:type-name (when tt-name (~cart-ticket-type-name :name tt-name))
|
||||
:date-str date-str
|
||||
:price (str "\u00a3" (format-decimal price 2))
|
||||
:qty-url qty-url :csrf csrf
|
||||
:entry-id entry-id
|
||||
:type-hidden (when tt-id (~cart-ticket-type-hidden :value (str tt-id)))
|
||||
:minus (str (max (- quantity 1) 0))
|
||||
:qty (str quantity)
|
||||
:plus (str (+ quantity 1))
|
||||
:line-total (str "Line total: \u00a3" (format-decimal line-total 2)))))
|
||||
ticket-groups)))))
|
||||
|
||||
;; Assembled cart summary — replaces Python _cart_summary_sx
|
||||
(defcomp ~cart-summary-from-data (&key item-count grand-total symbol is-logged-in checkout-action login-href user-email)
|
||||
(~cart-summary-panel
|
||||
:item-count (str item-count)
|
||||
:subtotal (str symbol (format-decimal grand-total 2))
|
||||
:checkout (if is-logged-in
|
||||
(~cart-checkout-form
|
||||
:action checkout-action :csrf (csrf-token)
|
||||
:label (str " Checkout as " user-email))
|
||||
(~cart-checkout-signin :href login-href))))
|
||||
|
||||
;; Assembled page cart content — replaces Python _page_cart_main_panel_sx
|
||||
(defcomp ~cart-page-cart-content (&key cart-items cal-entries ticket-groups summary)
|
||||
(if (and (empty? (or cart-items (list)))
|
||||
(empty? (or cal-entries (list)))
|
||||
(empty? (or ticket-groups (list))))
|
||||
(div :class "max-w-full px-3 py-3 space-y-3"
|
||||
(div :id "cart"
|
||||
(div :class "rounded-2xl border border-dashed border-stone-300 bg-white/80 p-6 sm:p-8 text-center"
|
||||
(~empty-state :icon "fa fa-shopping-cart" :message "Your cart is empty" :cls "text-center"))))
|
||||
(~cart-page-panel
|
||||
:items (map (lambda (item) (~cart-item-from-data :item item)) (or cart-items (list)))
|
||||
:cal (when (not (empty? (or cal-entries (list))))
|
||||
(~cart-cal-section-from-data :entries cal-entries))
|
||||
:tickets (when (not (empty? (or ticket-groups (list))))
|
||||
(~cart-tickets-section-from-data :ticket-groups ticket-groups))
|
||||
:summary summary)))
|
||||
|
||||
@@ -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))))
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)))))
|
||||
|
||||
@@ -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")))))))
|
||||
|
||||
@@ -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)))))))
|
||||
|
||||
@@ -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)))))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user