From 09b5a5b4f60f752f9b12edbf1ba4984fd036d2bc Mon Sep 17 00:00:00 2001 From: giles Date: Sat, 28 Feb 2026 14:15:17 +0000 Subject: [PATCH] Convert account, orders, and federation sexp_components.py to pure sexp() calls Eliminates all f-string HTML from the remaining three services, completing the migration of all sexp_components.py files to the s-expression rendering system. Co-Authored-By: Claude Opus 4.6 --- account/sexp/sexp_components.py | 289 ++++++++------ federation/sexp/sexp_components.py | 615 +++++++++++++++++++---------- orders/sexp/sexp_components.py | 278 +++++++------ 3 files changed, 739 insertions(+), 443 deletions(-) diff --git a/account/sexp/sexp_components.py b/account/sexp/sexp_components.py index 53a3ae8..846a683 100644 --- a/account/sexp/sexp_components.py +++ b/account/sexp/sexp_components.py @@ -69,43 +69,59 @@ def _account_main_panel_html(ctx: dict) -> str: user = getattr(g, "user", None) error = ctx.get("error", "") - parts = ['
', - '
'] + error_html = sexp( + '(div :class "rounded-lg border border-red-200 bg-red-50 text-red-800 px-4 py-3 text-sm" (raw! e))', + e=error, + ) if error else "" - if error: - parts.append( - f'
{error}
' + user_email_html = "" + user_name_html = "" + if user: + user_email_html = sexp( + '(p :class "text-sm text-stone-500 mt-1" (raw! e))', + e=user.email, + ) + if user.name: + user_name_html = sexp( + '(p :class "text-sm text-stone-600" (raw! n))', + n=user.name, + ) + + logout_html = sexp( + '(form :action "/auth/logout/" :method "post"' + ' (input :type "hidden" :name "csrf_token" :value csrf)' + ' (button :type "submit"' + ' :class "inline-flex items-center gap-2 rounded-full border border-stone-300 px-4 py-2 text-sm font-medium text-stone-700 hover:bg-stone-50 transition"' + ' (i :class "fa-solid fa-right-from-bracket text-xs") " Sign out"))', + csrf=generate_csrf_token(), + ) + + labels_html = "" + if user and hasattr(user, "labels") and user.labels: + label_items = "".join( + sexp( + '(span :class "inline-flex items-center rounded-full border border-stone-200 px-3 py-1 text-xs font-medium bg-white/60" (raw! n))', + n=label.name, + ) + for label in user.labels + ) + labels_html = sexp( + '(div (h2 :class "text-base font-semibold tracking-tight mb-3" "Labels")' + ' (div :class "flex flex-wrap gap-2" (raw! items)))', + items=label_items, ) - # Account header with logout - parts.append('
') - parts.append('

Account

') - if user: - parts.append(f'

{user.email}

') - if user.name: - parts.append(f'

{user.name}

') - parts.append('
') - parts.append( - f'
' - f'' - f'
' + return sexp( + '(div :class "w-full max-w-3xl mx-auto px-4 py-6"' + ' (div :class "bg-white/70 backdrop-blur rounded-2xl shadow border border-stone-200 p-6 sm:p-8 space-y-8"' + ' (raw! err)' + ' (div :class "flex items-center justify-between"' + ' (div (h1 :class "text-xl font-semibold tracking-tight" "Account") (raw! email) (raw! name))' + ' (raw! logout))' + ' (raw! labels)))', + err=error_html, email=user_email_html, name=user_name_html, + logout=logout_html, labels=labels_html, ) - parts.append('
') - - # Labels - if user and hasattr(user, "labels") and user.labels: - parts.append('

Labels

') - parts.append('
') - for label in user.labels: - parts.append( - f'' - f'{label.name}' - ) - parts.append('
') - - parts.append('
') - return "".join(parts) # --------------------------------------------------------------------------- @@ -124,16 +140,31 @@ def _newsletter_toggle_html(un: Any, account_url_fn: Any, csrf_token: str) -> st bg = "bg-stone-300" translate = "translate-x-1" checked = "false" - return ( - f'
' - f'
' + return sexp( + '(div :id id :class "flex items-center"' + ' (button :hx-post url :hx-headers hdrs :hx-target tgt :hx-swap "outerHTML"' + ' :class cls :role "switch" :aria-checked checked' + ' (span :class knob)))', + id=f"nl-{nid}", url=toggle_url, + hdrs=f'{{"X-CSRFToken": "{csrf_token}"}}', + tgt=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, + knob=f"inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform {translate}", + ) + + +def _newsletter_toggle_off_html(nid: int, toggle_url: str, csrf_token: str) -> str: + """Render an unsubscribed newsletter toggle (no subscription record yet).""" + return sexp( + '(div :id id :class "flex items-center"' + ' (button :hx-post url :hx-headers hdrs :hx-target tgt :hx-swap "outerHTML"' + ' :class "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"' + ' :role "switch" :aria-checked "false"' + ' (span :class "inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform translate-x-1")))', + id=f"nl-{nid}", url=toggle_url, + hdrs=f'{{"X-CSRFToken": "{csrf_token}"}}', + tgt=f"#nl-{nid}", ) @@ -144,44 +175,45 @@ def _newsletters_panel_html(ctx: dict, newsletter_list: list) -> str: account_url_fn = ctx.get("account_url") or (lambda p: p) csrf = generate_csrf_token() - parts = ['
', - '
', - '

Newsletters

'] - if newsletter_list: - parts.append('
') + items = [] for item in newsletter_list: nl = item["newsletter"] un = item.get("un") - parts.append('
') - parts.append(f'

{nl.name}

') - if nl.description: - parts.append(f'

{nl.description}

') - parts.append('
') + + desc_html = sexp( + '(p :class "text-xs text-stone-500 mt-0.5 truncate" (raw! d))', + d=nl.description, + ) if nl.description else "" if un: - parts.append(_newsletter_toggle_html(un, account_url_fn, csrf)) + toggle = _newsletter_toggle_html(un, account_url_fn, csrf) else: - # No subscription yet — show off toggle toggle_url = account_url_fn(f"/newsletter/{nl.id}/toggle/") - parts.append( - f'
' - f'
' - ) - parts.append('
') - parts.append('
') - else: - parts.append('

No newsletters available.

') + toggle = _newsletter_toggle_off_html(nl.id, toggle_url, csrf) - parts.append('
') - return "".join(parts) + items.append(sexp( + '(div :class "flex items-center justify-between py-4 first:pt-0 last:pb-0"' + ' (div :class "min-w-0 flex-1"' + ' (p :class "text-sm font-medium text-stone-800" (raw! name))' + ' (raw! desc))' + ' (div :class "ml-4 flex-shrink-0" (raw! toggle)))', + name=nl.name, desc=desc_html, toggle=toggle, + )) + list_html = sexp( + '(div :class "divide-y divide-stone-100" (raw! items))', + items="".join(items), + ) + else: + list_html = sexp('(p :class "text-sm text-stone-500" "No newsletters available.")') + + return sexp( + '(div :class "w-full max-w-3xl mx-auto px-4 py-6"' + ' (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")' + ' (raw! list)))', + list=list_html, + ) # --------------------------------------------------------------------------- @@ -195,25 +227,29 @@ def _login_page_content(ctx: dict) -> str: error = ctx.get("error", "") email = ctx.get("email", "") - - parts = ['
', - '

Sign in

'] - if error: - parts.append( - f'
{error}
' - ) action = url_for("auth.start_login") - parts.append( - f'
' - f'' - f'
' - f'
' - f'
' + + error_html = sexp( + '(div :class "bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4" (raw! e))', + e=error, + ) if error else "" + + return sexp( + '(div :class "py-8 max-w-md mx-auto"' + ' (h1 :class "text-2xl font-bold mb-6" "Sign in")' + ' (raw! err)' + ' (form :method "post" :action action :class "space-y-4"' + ' (input :type "hidden" :name "csrf_token" :value csrf)' + ' (div' + ' (label :for "email" :class "block text-sm font-medium mb-1" "Email address")' + ' (input :type "email" :name "email" :id "email" :value email :required true :autofocus true' + ' :class "w-full border border-stone-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-stone-500"))' + ' (button :type "submit"' + ' :class "w-full bg-stone-800 text-white py-2 px-4 rounded hover:bg-stone-700 transition"' + ' "Send magic link")))', + err=error_html, action=action, + csrf=generate_csrf_token(), email=email, ) - parts.append('
') - return "".join(parts) def _device_page_content(ctx: dict) -> str: @@ -223,36 +259,39 @@ def _device_page_content(ctx: dict) -> str: error = ctx.get("error", "") code = ctx.get("code", "") - - parts = ['
', - '

Authorize device

', - '

Enter the code shown in your terminal to sign in.

'] - if error: - parts.append( - f'
{error}
' - ) action = url_for("auth.device_submit") - parts.append( - f'
' - f'' - f'
' - f'
' - f'
' + + error_html = sexp( + '(div :class "bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4" (raw! e))', + e=error, + ) if error else "" + + return sexp( + '(div :class "py-8 max-w-md mx-auto"' + ' (h1 :class "text-2xl font-bold mb-6" "Authorize device")' + ' (p :class "text-stone-600 mb-4" "Enter the code shown in your terminal to sign in.")' + ' (raw! err)' + ' (form :method "post" :action action :class "space-y-4"' + ' (input :type "hidden" :name "csrf_token" :value csrf)' + ' (div' + ' (label :for "code" :class "block text-sm font-medium mb-1" "Device code")' + ' (input :type "text" :name "code" :id "code" :value code :placeholder "XXXX-XXXX"' + ' :required true :autofocus true :maxlength "9" :autocomplete "off" :spellcheck "false"' + ' :class "w-full border border-stone-300 rounded px-3 py-3 text-center text-2xl tracking-widest font-mono uppercase focus:outline-none focus:ring-2 focus:ring-stone-500"))' + ' (button :type "submit"' + ' :class "w-full bg-stone-800 text-white py-2 px-4 rounded hover:bg-stone-700 transition"' + ' "Authorize")))', + err=error_html, action=action, + csrf=generate_csrf_token(), code=code, ) - parts.append('
') - return "".join(parts) def _device_approved_content() -> str: """Device approved success content.""" - return ( - '
' - '

Device authorized

' - '

You can close this window and return to your terminal.

' - '
' + return sexp( + '(div :class "py-8 max-w-md mx-auto text-center"' + ' (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."))', ) @@ -387,18 +426,18 @@ def _check_email_content(email: str, email_error: str | None = None) -> str: """Check email confirmation content.""" from markupsafe import escape - error_html = "" - if email_error: - error_html = ( - f'
' - f'{escape(email_error)}
' - ) - return ( - '
' - '

Check your email

' - f'

We sent a sign-in link to {escape(email)}.

' - '

Click the link in the email to sign in. The link expires in 15 minutes.

' - f'{error_html}
' + error_html = sexp( + '(div :class "bg-yellow-50 border border-yellow-200 text-yellow-700 p-3 rounded mt-4" (raw! e))', + e=str(escape(email_error)), + ) if email_error else "" + + return sexp( + '(div :class "py-8 max-w-md mx-auto text-center"' + ' (h1 :class "text-2xl font-bold mb-4" "Check your email")' + ' (p :class "text-stone-600 mb-2" "We sent a sign-in link to " (strong (raw! email)) ".")' + ' (p :class "text-stone-500 text-sm" "Click the link in the email to sign in. The link expires in 15 minutes.")' + ' (raw! err))', + email=str(escape(email)), err=error_html, ) diff --git a/federation/sexp/sexp_components.py b/federation/sexp/sexp_components.py index 8057ca6..e416cca 100644 --- a/federation/sexp/sexp_components.py +++ b/federation/sexp/sexp_components.py @@ -23,10 +23,10 @@ def _social_nav_html(actor: Any) -> str: if not actor: choose_url = url_for("identity.choose_username_form") - return ( - '' + return sexp( + '(nav :class "flex gap-3 text-sm items-center"' + ' (a :href url :class "px-2 py-1 rounded hover:bg-stone-200 font-bold" "Choose username"))', + url=choose_url, ) links = [ @@ -38,27 +38,40 @@ def _social_nav_html(actor: Any) -> str: ("social.search", "Search"), ] - parts = ['') - return "".join(parts) + parts.append(sexp( + '(a :href href :class "px-2 py-1 rounded hover:bg-stone-200" (raw! label))', + href=profile_url, label=f"@{actor.preferred_username}", + )) + + return sexp( + '(nav :class "flex gap-3 text-sm items-center flex-wrap" (raw! items))', + items="".join(parts), + ) def _social_header_html(actor: Any) -> str: @@ -80,7 +93,7 @@ def _social_page(ctx: dict, actor: Any, *, content_html: str, sh=_social_header_html(actor), ) return full_page(ctx, header_rows_html=hdr, content_html=content_html, - meta_html=meta_html or f'{escape(title)}') + meta_html=meta_html or sexp('(title (raw! t))', t=escape(title))) # --------------------------------------------------------------------------- @@ -106,11 +119,11 @@ def _interaction_buttons_html(item: Any, actor: Any) -> str: if liked: like_action = url_for("social.unlike") like_cls = "text-red-500 hover:text-red-600" - like_icon = "♥" + like_icon = "\u2665" else: like_action = url_for("social.like") like_cls = "hover:text-red-500" - like_icon = "♡" + like_icon = "\u2661" if boosted: boost_action = url_for("social.unboost") @@ -120,21 +133,37 @@ def _interaction_buttons_html(item: Any, actor: Any) -> str: boost_cls = "hover:text-green-600" reply_url = url_for("social.compose_form", reply_to=oid) if oid else "" - reply_html = f'Reply' if reply_url else "" + reply_html = sexp( + '(a :href url :class "hover:text-stone-700" "Reply")', + url=reply_url, + ) if reply_url else "" - return ( - f'
' - f'
' - f'' - f'' - f'' - f'
' - f'
' - f'' - f'' - f'' - f'
' - f'{reply_html}
' + like_form = sexp( + '(form :hx-post action :hx-target target :hx-swap "innerHTML"' + ' (input :type "hidden" :name "object_id" :value oid)' + ' (input :type "hidden" :name "author_inbox" :value ainbox)' + ' (input :type "hidden" :name "csrf_token" :value csrf)' + ' (button :type "submit" :class cls (span (raw! icon)) " " (raw! count)))', + 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 = sexp( + '(form :hx-post action :hx-target target :hx-swap "innerHTML"' + ' (input :type "hidden" :name "object_id" :value oid)' + ' (input :type "hidden" :name "author_inbox" :value ainbox)' + ' (input :type "hidden" :name "csrf_token" :value csrf)' + ' (button :type "submit" :class cls (span "\u21bb") " " (raw! count)))', + action=boost_action, target=target, oid=oid, ainbox=ainbox, + csrf=csrf, cls=f"flex items-center gap-1 {boost_cls}", + count=str(bcount), + ) + + return sexp( + '(div :class "flex items-center gap-4 mt-3 text-sm text-stone-500"' + ' (raw! like) (raw! boost) (raw! reply))', + like=like_form, boost=boost_form, reply=reply_html, ) @@ -151,45 +180,74 @@ def _post_card_html(item: Any, actor: Any) -> str: url = getattr(item, "url", None) post_type = getattr(item, "post_type", "") - boost_html = f'
Boosted by {escape(boosted_by)}
' if boosted_by else "" + boost_html = sexp( + '(div :class "text-sm text-stone-500 mb-2" "Boosted by " (raw! name))', + name=str(escape(boosted_by)), + ) if boosted_by else "" if actor_icon: - avatar = f'' + avatar = sexp( + '(img :src src :alt "" :class "w-10 h-10 rounded-full")', + src=actor_icon, + ) else: initial = actor_name[0].upper() if actor_name else "?" - avatar = f'
{initial}
' + avatar = sexp( + '(div :class "w-10 h-10 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-sm" (raw! i))', + i=initial, + ) domain_html = f"@{escape(actor_domain)}" if actor_domain else "" time_html = published.strftime("%b %d, %H:%M") if published else "" if summary: - content_html = ( - f'
CW: {escape(summary)}' - f'
{content}
' + content_html = sexp( + '(details :class "mt-2"' + ' (summary :class "text-stone-500 cursor-pointer" "CW: " (raw! s))' + ' (div :class "mt-2 prose prose-sm prose-stone max-w-none" (raw! c)))', + s=str(escape(summary)), c=content, ) else: - content_html = f'
{content}
' + content_html = sexp( + '(div :class "mt-2 prose prose-sm prose-stone max-w-none" (raw! c))', + c=content, + ) original_html = "" if url and post_type == "remote": - original_html = f'original' + original_html = sexp( + '(a :href url :target "_blank" :rel "noopener"' + ' :class "text-sm text-stone-400 hover:underline mt-1 inline-block" "original")', + url=url, + ) interactions_html = "" if actor: oid = getattr(item, "object_id", "") or "" safe_id = oid.replace("/", "_").replace(":", "_") - interactions_html = f'
{_interaction_buttons_html(item, actor)}
' + interactions_html = sexp( + '(div :id id (raw! buttons))', + id=f"interactions-{safe_id}", + buttons=_interaction_buttons_html(item, actor), + ) - return ( - f'
' - f'{boost_html}' - f'
{avatar}' - f'
' - f'
' - f'{escape(actor_name)}' - f'@{escape(actor_username)}{domain_html}' - f'{time_html}
' - f'{content_html}{original_html}{interactions_html}
' + return sexp( + '(article :class "bg-white rounded-lg shadow-sm border border-stone-200 p-4 mb-4"' + ' (raw! boost)' + ' (div :class "flex items-start gap-3"' + ' (raw! avatar)' + ' (div :class "flex-1 min-w-0"' + ' (div :class "flex items-baseline gap-2"' + ' (span :class "font-semibold text-stone-900" (raw! aname))' + ' (span :class "text-sm text-stone-500" "@" (raw! ausername) (raw! domain))' + ' (span :class "text-sm text-stone-400 ml-auto" (raw! time)))' + ' (raw! content) (raw! original) (raw! interactions))))', + boost=boost_html, avatar=avatar, + aname=str(escape(actor_name)), + ausername=str(escape(actor_username)), + domain=domain_html, time=time_html, + content=content_html, original=original_html, + interactions=interactions_html, ) @@ -211,7 +269,10 @@ def _timeline_items_html(items: list, timeline_type: str, actor: Any, 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(f'
') + parts.append(sexp( + '(div :hx-get url :hx-trigger "revealed" :hx-swap "outerHTML")', + url=next_url, + )) return "".join(parts) @@ -238,48 +299,75 @@ def _actor_card_html(a: Any, actor: Any, followed_urls: set, safe_id = actor_url.replace("/", "_").replace(":", "_") if icon_url: - avatar = f'' + avatar = sexp( + '(img :src src :alt "" :class "w-12 h-12 rounded-full")', + src=icon_url, + ) else: initial = (display_name or username)[0].upper() if (display_name or username) else "?" - avatar = f'
{initial}
' + avatar = sexp( + '(div :class "w-12 h-12 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold" (raw! i))', + i=initial, + ) # Name link - if list_type == "following" and aid: - name_html = f'{escape(display_name)}' - elif list_type == "search" and aid: - name_html = f'{escape(display_name)}' + if (list_type in ("following", "search")) and aid: + name_html = sexp( + '(a :href href :class "font-semibold text-stone-900 hover:underline" (raw! name))', + href=url_for("social.actor_timeline", id=aid), + name=str(escape(display_name)), + ) else: - name_html = f'{escape(display_name)}' + name_html = sexp( + '(a :href href :target "_blank" :rel "noopener"' + ' :class "font-semibold text-stone-900 hover:underline" (raw! name))', + href=f"https://{domain}/@{username}", + name=str(escape(display_name)), + ) - summary_html = f'
{summary}
' if summary else "" + summary_html = sexp( + '(div :class "text-sm text-stone-600 mt-1 truncate" (raw! s))', + s=summary, + ) if summary else "" # Follow/unfollow button button_html = "" if actor: is_followed = actor_url in (followed_urls or set()) if list_type == "following" or is_followed: - button_html = ( - f'
' - f'' - f'' - f'
' + button_html = sexp( + '(div :class "flex-shrink-0"' + ' (form :method "post" :action action :hx-post action :hx-target "closest article" :hx-swap "outerHTML"' + ' (input :type "hidden" :name "csrf_token" :value csrf)' + ' (input :type "hidden" :name "actor_url" :value aurl)' + ' (button :type "submit" :class "text-sm border border-stone-300 rounded px-3 py-1 hover:bg-stone-100" "Unfollow")))', + action=url_for("social.unfollow"), csrf=csrf, aurl=actor_url, ) else: label = "Follow Back" if list_type == "followers" else "Follow" - button_html = ( - f'
' - f'' - f'' - f'
' + button_html = sexp( + '(div :class "flex-shrink-0"' + ' (form :method "post" :action action :hx-post action :hx-target "closest article" :hx-swap "outerHTML"' + ' (input :type "hidden" :name "csrf_token" :value csrf)' + ' (input :type "hidden" :name "actor_url" :value aurl)' + ' (button :type "submit" :class "text-sm bg-stone-800 text-white rounded px-3 py-1 hover:bg-stone-700" (raw! label))))', + action=url_for("social.follow"), csrf=csrf, aurl=actor_url, label=label, ) - return ( - f'
' - f'{avatar}
{name_html}' - f'
@{escape(username)}@{escape(domain)}
' - f'{summary_html}
{button_html}
' + return sexp( + '(article :class cls :id id' + ' (raw! avatar)' + ' (div :class "flex-1 min-w-0"' + ' (raw! name-link)' + ' (div :class "text-sm text-stone-500" "@" (raw! username) "@" (raw! domain))' + ' (raw! summary))' + ' (raw! button))', + 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=avatar, + **{"name-link": name_html}, + username=str(escape(username)), domain=str(escape(domain)), + summary=summary_html, button=button_html, ) @@ -291,7 +379,10 @@ def _search_results_html(actors: list, query: str, page: int, parts = [_actor_card_html(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(f'
') + parts.append(sexp( + '(div :hx-get url :hx-trigger "revealed" :hx-swap "outerHTML")', + url=next_url, + )) return "".join(parts) @@ -303,7 +394,10 @@ def _actor_list_items_html(actors: list, page: int, list_type: str, parts = [_actor_card_html(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(f'
') + parts.append(sexp( + '(div :hx-get url :hx-trigger "revealed" :hx-swap "outerHTML")', + url=next_url, + )) return "".join(parts) @@ -326,10 +420,16 @@ def _notification_html(notif: Any) -> str: border = " border-l-4 border-l-stone-400" if not read else "" if from_icon: - avatar = f'' + avatar = sexp( + '(img :src src :alt "" :class "w-8 h-8 rounded-full")', + src=from_icon, + ) else: initial = from_name[0].upper() if from_name else "?" - avatar = f'
{initial}
' + avatar = sexp( + '(div :class "w-8 h-8 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-xs" (raw! i))', + i=initial, + ) domain_html = f"@{escape(from_domain)}" if from_domain else "" @@ -344,16 +444,29 @@ def _notification_html(notif: Any) -> str: if ntype == "follow" and app_domain and app_domain != "federation": action += f" on {escape(app_domain)}" - preview_html = f'
{escape(preview)}
' if preview else "" + preview_html = sexp( + '(div :class "text-sm text-stone-500 mt-1 truncate" (raw! p))', + p=str(escape(preview)), + ) if preview else "" time_html = created.strftime("%b %d, %H:%M") if created else "" - return ( - f'
' - f'
{avatar}
' - f'
{escape(from_name)}' - f' @{escape(from_username)}{domain_html}' - f' {action}
' - f'{preview_html}
{time_html}
' + return sexp( + '(div :class cls' + ' (div :class "flex items-start gap-3"' + ' (raw! avatar)' + ' (div :class "flex-1"' + ' (div :class "text-sm"' + ' (span :class "font-semibold" (raw! fname))' + ' " " (span :class "text-stone-500" "@" (raw! fusername) (raw! fdomain))' + ' " " (span :class "text-stone-600" (raw! action)))' + ' (raw! preview)' + ' (div :class "text-xs text-stone-400 mt-1" (raw! time)))))', + cls=f"bg-white rounded-lg shadow-sm border border-stone-200 p-4{border}", + avatar=avatar, + fname=str(escape(from_name)), + fusername=str(escape(from_username)), + fdomain=domain_html, action=action, + preview=preview_html, time=time_html, ) @@ -381,21 +494,31 @@ async def render_login_page(ctx: dict) -> str: action = url_for("auth.start_login") csrf = generate_csrf_token() - error_html = f'
{error}
' if error else "" - content = ( - f'

Sign in

{error_html}' - f'
' - f'' - f'
' - f'
' - f'
' + error_html = sexp( + '(div :class "bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4" (raw! e))', + e=error, + ) if error else "" + + content = sexp( + '(div :class "py-8 max-w-md mx-auto"' + ' (h1 :class "text-2xl font-bold mb-6" "Sign in")' + ' (raw! err)' + ' (form :method "post" :action action :class "space-y-4"' + ' (input :type "hidden" :name "csrf_token" :value csrf)' + ' (div' + ' (label :for "email" :class "block text-sm font-medium mb-1" "Email address")' + ' (input :type "email" :name "email" :id "email" :value email :required true :autofocus true' + ' :class "w-full border border-stone-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-stone-500"))' + ' (button :type "submit"' + ' :class "w-full bg-stone-800 text-white py-2 px-4 rounded hover:bg-stone-700 transition"' + ' "Send magic link")))', + err=error_html, action=action, csrf=csrf, + email=str(escape(email)), ) hdr = root_header_html(ctx) return full_page(ctx, header_rows_html=hdr, content_html=content, - meta_html="Login \u2014 Rose Ash") + meta_html='Login \u2014 Rose Ash') async def render_check_email_page(ctx: dict) -> str: @@ -403,18 +526,18 @@ async def render_check_email_page(ctx: dict) -> str: email = ctx.get("email", "") email_error = ctx.get("email_error") - error_html = "" - if email_error: - error_html = ( - f'
' - f'{escape(email_error)}
' - ) - content = ( - '
' - '

Check your email

' - f'

We sent a sign-in link to {escape(email)}.

' - '

Click the link in the email to sign in. The link expires in 15 minutes.

' - f'{error_html}
' + error_html = sexp( + '(div :class "bg-yellow-50 border border-yellow-200 text-yellow-700 p-3 rounded mt-4" (raw! e))', + e=str(escape(email_error)), + ) if email_error else "" + + content = sexp( + '(div :class "py-8 max-w-md mx-auto text-center"' + ' (h1 :class "text-2xl font-bold mb-4" "Check your email")' + ' (p :class "text-stone-600 mb-2" "We sent a sign-in link to " (strong (raw! email)) ".")' + ' (p :class "text-stone-500 text-sm" "Click the link in the email to sign in. The link expires in 15 minutes.")' + ' (raw! err))', + email=str(escape(email)), err=error_html, ) hdr = root_header_html(ctx) @@ -435,14 +558,19 @@ async def render_timeline_page(ctx: dict, items: list, timeline_type: str, compose_html = "" if actor: compose_url = url_for("social.compose_form") - compose_html = f'Compose' + compose_html = sexp( + '(a :href url :class "bg-stone-800 text-white px-4 py-2 rounded hover:bg-stone-700" "Compose")', + url=compose_url, + ) timeline_html = _timeline_items_html(items, timeline_type, actor) - content = ( - f'
' - f'

{label} Timeline

{compose_html}
' - f'
{timeline_html}
' + content = sexp( + '(div :class "flex items-center justify-between mb-6"' + ' (h1 :class "text-2xl font-bold" (raw! label) " Timeline")' + ' (raw! compose))' + '(div :id "timeline" (raw! tl))', + label=label, compose=compose_html, tl=timeline_html, ) return _social_page(ctx, actor, content_html=content, @@ -469,23 +597,27 @@ async def render_compose_page(ctx: dict, actor: Any, reply_to: str | None) -> st reply_html = "" if reply_to: - reply_html = ( - f'' - f'
Replying to {escape(reply_to)}
' + reply_html = sexp( + '(input :type "hidden" :name "in_reply_to" :value val)' + '(div :class "text-sm text-stone-500" "Replying to " (span :class "font-mono" (raw! rt)))', + val=str(escape(reply_to)), rt=str(escape(reply_to)), ) - content = ( - f'

Compose

' - f'
' - f'{reply_html}' - f'' - f'
' - f'' - f'
' + content = sexp( + '(h1 :class "text-2xl font-bold mb-6" "Compose")' + '(form :method "post" :action action :class "space-y-4"' + ' (input :type "hidden" :name "csrf_token" :value csrf)' + ' (raw! reply)' + ' (textarea :name "content" :rows "6" :maxlength "5000" :required true' + ' :class "w-full border border-stone-300 rounded-lg p-3 focus:outline-none focus:ring-2 focus:ring-stone-500"' + ' :placeholder "What\'s on your mind?")' + ' (div :class "flex items-center justify-between"' + ' (select :name "visibility" :class "border border-stone-300 rounded px-3 py-1.5 text-sm"' + ' (option :value "public" "Public")' + ' (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")))', + action=action, csrf=csrf, reply=reply_html, ) return _social_page(ctx, actor, content_html=content, @@ -509,19 +641,30 @@ async def render_search_page(ctx: dict, query: str, actors: list, total: int, info_html = "" if query and total: s = "s" if total != 1 else "" - info_html = f'

{total} result{s} for {escape(query)}

' + info_html = sexp( + '(p :class "text-sm text-stone-500 mb-4" (raw! t))', + t=f"{total} result{s} for {escape(query)}", + ) elif query: - info_html = f'

No results found for {escape(query)}

' + info_html = sexp( + '(p :class "text-stone-500 mb-4" (raw! t))', + t=f"No results found for {escape(query)}", + ) - content = ( - f'

Search

' - f'
' - f'
' - f'
' - f'{info_html}
{results_html}
' + content = sexp( + '(h1 :class "text-2xl font-bold mb-6" "Search")' + '(form :method "get" :action search-url :class "mb-6"' + ' :hx-get search-page-url :hx-target "#search-results" :hx-push-url search-url' + ' (div :class "flex gap-2"' + ' (input :type "text" :name "q" :value query' + ' :class "flex-1 border border-stone-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-stone-500"' + ' :placeholder "Search users or @user@instance.tld")' + ' (button :type "submit" :class "bg-stone-800 text-white px-6 py-2 rounded hover:bg-stone-700" "Search")))' + '(raw! info)' + '(div :id "search-results" (raw! results))', + **{"search-url": search_url, "search-page-url": search_page_url}, + query=str(escape(query)), + info=info_html, results=results_html, ) return _social_page(ctx, actor, content_html=content, @@ -542,9 +685,11 @@ async def render_following_page(ctx: dict, actors: list, total: int, actor: Any) -> str: """Full page: following list.""" items_html = _actor_list_items_html(actors, 1, "following", set(), actor) - content = ( - f'

Following ({total})

' - f'
{items_html}
' + content = sexp( + '(h1 :class "text-2xl font-bold mb-6" "Following "' + ' (span :class "text-stone-400 font-normal" "(" (raw! total) ")"))' + '(div :id "actor-list" (raw! items))', + total=str(total), items=items_html, ) return _social_page(ctx, actor, content_html=content, title="Following \u2014 Rose Ash") @@ -559,9 +704,11 @@ async def render_followers_page(ctx: dict, actors: list, total: int, followed_urls: set, actor: Any) -> str: """Full page: followers list.""" items_html = _actor_list_items_html(actors, 1, "followers", followed_urls, actor) - content = ( - f'

Followers ({total})

' - f'
{items_html}
' + content = sexp( + '(h1 :class "text-2xl font-bold mb-6" "Followers "' + ' (span :class "text-stone-400 font-normal" "(" (raw! total) ")"))' + '(div :id "actor-list" (raw! items))', + total=str(total), items=items_html, ) return _social_page(ctx, actor, content_html=content, title="Followers \u2014 Rose Ash") @@ -590,39 +737,61 @@ async def render_actor_timeline_page(ctx: dict, remote_actor: Any, items: list, actor_url = getattr(remote_actor, "actor_url", "") if icon_url: - avatar = f'' + avatar = sexp( + '(img :src src :alt "" :class "w-16 h-16 rounded-full")', + src=icon_url, + ) else: initial = display_name[0].upper() if display_name else "?" - avatar = f'
{initial}
' + avatar = sexp( + '(div :class "w-16 h-16 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-xl" (raw! i))', + i=initial, + ) - summary_html = f'
{summary}
' if summary else "" + summary_html = sexp( + '(div :class "text-sm text-stone-600 mt-2" (raw! s))', + s=summary, + ) if summary else "" follow_html = "" if actor: if is_following: - follow_html = ( - f'
' - f'' - f'' - f'
' + follow_html = sexp( + '(div :class "flex-shrink-0"' + ' (form :method "post" :action action' + ' (input :type "hidden" :name "csrf_token" :value csrf)' + ' (input :type "hidden" :name "actor_url" :value aurl)' + ' (button :type "submit" :class "border border-stone-300 rounded px-4 py-2 hover:bg-stone-100" "Unfollow")))', + action=url_for("social.unfollow"), csrf=csrf, aurl=actor_url, ) else: - follow_html = ( - f'
' - f'' - f'' - f'
' + follow_html = sexp( + '(div :class "flex-shrink-0"' + ' (form :method "post" :action action' + ' (input :type "hidden" :name "csrf_token" :value csrf)' + ' (input :type "hidden" :name "actor_url" :value aurl)' + ' (button :type "submit" :class "bg-stone-800 text-white rounded px-4 py-2 hover:bg-stone-700" "Follow")))', + action=url_for("social.follow"), csrf=csrf, aurl=actor_url, ) timeline_html = _timeline_items_html(items, "actor", actor, remote_actor.id) - content = ( - f'
' - f'
{avatar}' - f'

{escape(display_name)}

' - f'
@{escape(remote_actor.preferred_username)}@{escape(remote_actor.domain)}
' - f'{summary_html}
{follow_html}
' - f'
{timeline_html}
' + content = sexp( + '(div :class "bg-white rounded-lg shadow-sm border border-stone-200 p-6 mb-6"' + ' (div :class "flex items-center gap-4"' + ' (raw! avatar)' + ' (div :class "flex-1"' + ' (h1 :class "text-xl font-bold" (raw! dname))' + ' (div :class "text-stone-500" "@" (raw! username) "@" (raw! domain))' + ' (raw! summary))' + ' (raw! follow)))' + '(div :id "timeline" (raw! tl))', + avatar=avatar, + dname=str(escape(display_name)), + username=str(escape(remote_actor.preferred_username)), + domain=str(escape(remote_actor.domain)), + summary=summary_html, follow=follow_html, + tl=timeline_html, ) return _social_page(ctx, actor, content_html=content, @@ -643,11 +812,17 @@ async def render_notifications_page(ctx: dict, notifications: list, actor: Any) -> str: """Full page: notifications.""" if not notifications: - notif_html = '

No notifications yet.

' + notif_html = sexp('(p :class "text-stone-500" "No notifications yet.")') else: - notif_html = '
' + "".join(_notification_html(n) for n in notifications) + '
' + notif_html = sexp( + '(div :class "space-y-2" (raw! items))', + items="".join(_notification_html(n) for n in notifications), + ) - content = f'

Notifications

{notif_html}' + content = sexp( + '(h1 :class "text-2xl font-bold mb-6" "Notifications") (raw! notifs)', + notifs=notif_html, + ) return _social_page(ctx, actor, content_html=content, title="Notifications \u2014 Rose Ash") @@ -669,25 +844,37 @@ async def render_choose_username_page(ctx: dict) -> str: check_url = url_for("identity.check_username") actor = ctx.get("actor") - error_html = f'
{error}
' if error else "" + error_html = sexp( + '(div :class "bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4" (raw! e))', + e=error, + ) if error else "" - content = ( - f'
' - f'

Choose your username

' - f'

This will be your identity on the fediverse: ' - f'@username@{escape(ap_domain)}

' - f'{error_html}' - f'
' - f'' - f'
' - f'
@' - f'' - f'
' - f'

3-32 characters. Lowercase letters, numbers, underscores. Must start with a letter.

' - f'
' + content = sexp( + '(div :class "py-8 max-w-md mx-auto"' + ' (h1 :class "text-2xl font-bold mb-2" "Choose your username")' + ' (p :class "text-stone-600 mb-6" "This will be your identity on the fediverse: "' + ' (strong "@username@" (raw! domain)))' + ' (raw! err)' + ' (form :method "post" :class "space-y-4"' + ' (input :type "hidden" :name "csrf_token" :value csrf)' + ' (div' + ' (label :for "username" :class "block text-sm font-medium mb-1" "Username")' + ' (div :class "flex items-center"' + ' (span :class "text-stone-400 mr-1" "@")' + ' (input :type "text" :name "username" :id "username" :value uname' + ' :pattern "[a-z][a-z0-9_]{2,31}" :minlength "3" :maxlength "32"' + ' :required true :autocomplete "off"' + ' :class "flex-1 border border-stone-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-stone-500"' + ' :hx-get check-url :hx-trigger "keyup changed delay:300ms" :hx-target "#username-status"' + ' :hx-include "[name=\'username\']"))' + ' (div :id "username-status" :class "text-sm mt-1")' + ' (p :class "text-xs text-stone-400 mt-1" "3-32 characters. Lowercase letters, numbers, underscores. Must start with a letter."))' + ' (button :type "submit"' + ' :class "w-full bg-stone-800 text-white py-2 px-4 rounded hover:bg-stone-700 transition"' + ' "Claim username")))', + domain=str(escape(ap_domain)), err=error_html, + csrf=csrf, uname=str(escape(username)), + **{"check-url": check_url}, ) return _social_page(ctx, actor, content_html=content, @@ -705,29 +892,49 @@ async def render_profile_page(ctx: dict, actor: Any, activities: list, ap_domain = config().get("ap_domain", "rose-ash.com") display_name = actor.display_name or actor.preferred_username - summary_html = f'

{escape(actor.summary)}

' if actor.summary else "" + summary_html = sexp( + '(p :class "mt-2" (raw! s))', + s=str(escape(actor.summary)), + ) if actor.summary else "" activities_html = "" if activities: parts = [] for a in activities: published = a.published.strftime("%Y-%m-%d %H:%M") if a.published else "" - obj_type = f'{a.object_type}' if a.object_type else "" - parts.append( - f'
' - f'{a.activity_type}' - f'{published}
{obj_type}
' - ) - activities_html = '
' + "".join(parts) + '
' + obj_type_html = sexp( + '(span :class "text-sm text-stone-500" (raw! t))', + t=a.object_type, + ) if a.object_type else "" + parts.append(sexp( + '(div :class "bg-white rounded-lg shadow p-4"' + ' (div :class "flex justify-between items-start"' + ' (span :class "font-medium" (raw! atype))' + ' (span :class "text-sm text-stone-400" (raw! pub)))' + ' (raw! otype))', + atype=a.activity_type, pub=published, + otype=obj_type_html, + )) + activities_html = sexp( + '(div :class "space-y-4" (raw! items))', + items="".join(parts), + ) else: - activities_html = '

No activities yet.

' + activities_html = sexp('(p :class "text-stone-500" "No activities yet.")') - content = ( - f'
' - f'

{escape(display_name)}

' - f'

@{escape(actor.preferred_username)}@{escape(ap_domain)}

' - f'{summary_html}
' - f'

Activities ({total})

{activities_html}
' + content = sexp( + '(div :class "py-8"' + ' (div :class "bg-white rounded-lg shadow p-6 mb-6"' + ' (h1 :class "text-2xl font-bold" (raw! dname))' + ' (p :class "text-stone-500" "@" (raw! username) "@" (raw! domain))' + ' (raw! summary))' + ' (h2 :class "text-xl font-bold mb-4" "Activities (" (raw! total) ")")' + ' (raw! activities))', + dname=str(escape(display_name)), + username=str(escape(actor.preferred_username)), + domain=str(escape(ap_domain)), + summary=summary_html, + total=str(total), activities=activities_html, ) return _social_page(ctx, actor, content_html=content, diff --git a/orders/sexp/sexp_components.py b/orders/sexp/sexp_components.py index 058266f..d0e00eb 100644 --- a/orders/sexp/sexp_components.py +++ b/orders/sexp/sexp_components.py @@ -69,36 +69,56 @@ def _orders_header_html(ctx: dict, list_url: str) -> str: # 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_html(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 = _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 ( - # Desktop row - f'' - f'#{order.id}' - f'{created}' - f'{order.description or ""}' - f'{total}' - f'{status}' - f'View' - # Mobile row - f'
' - f'
#{order.id}' - f'{status}
' - f'
{created}
' - f'
{total}
' - f'View
' + desktop = sexp( + '(tr :class "hidden sm:table-row border-t border-stone-100 hover:bg-stone-50/60"' + ' (td :class "px-3 py-2 align-top" (span :class "font-mono text-[11px] sm:text-xs" (raw! oid)))' + ' (td :class "px-3 py-2 align-top text-stone-700 text-xs sm:text-sm" (raw! created))' + ' (td :class "px-3 py-2 align-top text-stone-700 text-xs sm:text-sm" (raw! desc))' + ' (td :class "px-3 py-2 align-top text-stone-700 text-xs sm:text-sm" (raw! total))' + ' (td :class "px-3 py-2 align-top" (span :class pill (raw! status)))' + ' (td :class "px-3 py-0.5 align-top text-right"' + ' (a :href url :class "inline-flex items-center px-3 py-1.5 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition" "View")))', + oid=f"#{order.id}", created=created, + desc=order.description or "", total=total, + pill=f"inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] sm:text-xs {pill}", + status=status, url=detail_url, ) + mobile = sexp( + '(tr :class "sm:hidden border-t border-stone-100"' + ' (td :colspan "5" :class "px-3 py-3"' + ' (div :class "flex flex-col gap-2 text-xs"' + ' (div :class "flex items-center justify-between gap-2"' + ' (span :class "font-mono text-[11px] text-stone-700" (raw! oid))' + ' (span :class pill (raw! status)))' + ' (div :class "text-[11px] text-stone-500 break-words" (raw! created))' + ' (div :class "flex items-center justify-between gap-2"' + ' (div :class "font-medium text-stone-800" (raw! total))' + ' (a :href url :class "inline-flex items-center px-2 py-1 text-[11px] rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition shrink-0" "View")))))', + oid=f"#{order.id}", created=created, total=total, + pill=f"inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] {pill}", + status=status, url=detail_url, + ) + + return desktop + mobile + def _orders_rows_html(orders: list, page: int, total_pages: int, url_for_fn: Any, qs_fn: Any) -> str: @@ -118,7 +138,9 @@ def _orders_rows_html(orders: list, page: int, total_pages: int, u=next_url, p=page, **{"total-pages": total_pages}, )) else: - parts.append('End of results') + parts.append(sexp( + '(tr (td :colspan "5" :class "px-3 py-4 text-center text-xs text-stone-400" "End of results"))', + )) return "".join(parts) @@ -126,33 +148,35 @@ def _orders_rows_html(orders: list, page: int, total_pages: int, def _orders_main_panel_html(orders: list, rows_html: str) -> str: """Main panel with table or empty state.""" if not orders: - return ( - '
' - '
' - 'No orders yet.
' + return sexp( + '(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-4 sm:p-6 text-sm text-stone-700"' + ' "No orders yet."))', ) - return ( - '
' - '
' - '' - '' - '' - '' - '' - '' - '' - '' - f'{rows_html}
OrderCreatedDescriptionTotalStatus
' + return sexp( + '(div :class "max-w-full px-3 py-3 space-y-3"' + ' (div :class "overflow-x-auto rounded-2xl border border-stone-200 bg-white/80"' + ' (table :class "min-w-full text-xs sm:text-sm"' + ' (thead :class "bg-stone-50 border-b border-stone-200 text-stone-600"' + ' (tr' + ' (th :class "px-3 py-2 text-left font-medium" "Order")' + ' (th :class "px-3 py-2 text-left font-medium" "Created")' + ' (th :class "px-3 py-2 text-left font-medium" "Description")' + ' (th :class "px-3 py-2 text-left font-medium" "Total")' + ' (th :class "px-3 py-2 text-left font-medium" "Status")' + ' (th :class "px-3 py-2 text-left font-medium" "")))' + ' (tbody (raw! rows)))))', + rows=rows_html, ) def _orders_summary_html(ctx: dict) -> str: """Filter section for orders list.""" - return ( - '
' - '

Recent orders placed via the checkout.

' - f'
{search_mobile_html(ctx)}
' - '
' + return sexp( + '(header :class "mb-6 sm:mb-8 flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4"' + ' (div :class "space-y-1" (p :class "text-xs sm:text-sm text-stone-600" "Recent orders placed via the checkout."))' + ' (div :class "md:hidden" (raw! sm)))', + sm=search_mobile_html(ctx), ) @@ -234,26 +258,36 @@ def _order_items_html(order: Any) -> str: items = [] for item in order.items: prod_url = market_product_url(item.product_slug) - img = ( - f'{item.product_title or ' - if item.product_image else - '
No image
' - ) - items.append( - f'
  • ' - f'
    {img}
    ' - f'
    ' - f'

    {item.product_title or "Unknown product"}

    ' - f'

    Product ID: {item.product_id}

    ' - f'

    Qty: {item.quantity}

    ' - f'

    {item.currency or order.currency or "GBP"} {item.unit_price or 0:.2f}

    ' - f'
  • ' - ) - return ( - '
    ' - '

    Items

    ' - f'
      {"".join(items)}
    ' + if item.product_image: + img = sexp( + '(img :src src :alt alt :class "w-full h-full object-contain object-center" :loading "lazy" :decoding "async")', + src=item.product_image, alt=item.product_title or "Product image", + ) + else: + img = sexp('(div :class "w-full h-full flex items-center justify-center text-[9px] text-stone-400" "No image")') + + items.append(sexp( + '(li (a :class "w-full py-2 flex gap-3" :href href' + ' (div :class "w-12 h-12 sm:w-14 sm:h-14 rounded-md bg-stone-100 flex-shrink-0 overflow-hidden" (raw! img))' + ' (div :class "flex-1 flex justify-between gap-3"' + ' (div' + ' (p :class "font-medium" (raw! title))' + ' (p :class "text-[11px] text-stone-500" "Product ID: " (raw! pid)))' + ' (div :class "text-right whitespace-nowrap"' + ' (p "Qty: " (raw! qty))' + ' (p (raw! price))))))', + href=prod_url, img=img, + title=item.product_title or "Unknown product", + pid=str(item.product_id), + qty=str(item.quantity), + price=f"{item.currency or order.currency or 'GBP'} {item.unit_price or 0:.2f}", + )) + + return sexp( + '(div :class "rounded-2xl border border-stone-200 bg-white/80 p-4 sm:p-6"' + ' (h2 :class "text-sm sm:text-base font-semibold mb-3" "Items")' + ' (ul :class "divide-y divide-stone-100 text-xs sm:text-sm" (raw! items)))', + items="".join(items), ) @@ -273,18 +307,25 @@ def _calendar_items_html(calendar_entries: list | None) -> str: 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( - f'
  • ' - f'
    {e.name}' - f'' - f'{st.capitalize()}
    ' - f'
    {ds}
    ' - f'
    \u00a3{e.cost or 0:.2f}
  • ' - ) - return ( - '
    ' - '

    Calendar bookings in this order

    ' - f'
      {"".join(items)}
    ' + items.append(sexp( + '(li :class "px-4 py-3 flex items-start justify-between text-sm"' + ' (div' + ' (div :class "font-medium flex items-center gap-2"' + ' (raw! name)' + ' (span :class pill (raw! state)))' + ' (div :class "text-xs text-stone-500" (raw! ds)))' + ' (div :class "ml-4 font-medium" (raw! cost)))', + name=e.name, + pill=f"inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium {pill}", + state=st.capitalize(), ds=ds, + cost=f"\u00a3{e.cost or 0:.2f}", + )) + + return sexp( + '(section :class "mt-6 space-y-3"' + ' (h2 :class "text-base sm:text-lg font-semibold" "Calendar bookings in this order")' + ' (ul :class "divide-y divide-stone-200 rounded-2xl border border-stone-200 bg-white/80" (raw! items)))', + items="".join(items), ) @@ -297,7 +338,11 @@ def _order_main_html(order: Any, calendar_entries: list | None) -> str: d=order.description, s=order.status, c=order.currency, ta=f"{order.total_amount:.2f}" if order.total_amount else None, ) - return f'
    {summary}{_order_items_html(order)}{_calendar_items_html(calendar_entries)}
    ' + return sexp( + '(div :class "max-w-full px-3 py-3 space-y-4" (raw! summary) (raw! items) (raw! cal))', + summary=summary, items=_order_items_html(order), + cal=_calendar_items_html(calendar_entries), + ) def _order_filter_html(order: Any, list_url: str, recheck_url: str, @@ -305,20 +350,31 @@ def _order_filter_html(order: Any, list_url: str, recheck_url: 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 = ( - f'' - f'Open payment page' - ) if status != "paid" else "" - return ( - '
    ' - f'

    Placed {created} · Status: {status}

    ' - '
    ' - f'All orders' - f'
    ' - f'
    ' - f'{pay}
    ' + pay_html = "" + if status != "paid": + pay_html = sexp( + '(a :href url :class "inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-emerald-600 bg-emerald-600 text-white hover:bg-emerald-700 transition"' + ' (i :class "fa fa-credit-card mr-2" :aria-hidden "true") "Open payment page")', + url=pay_url, + ) + + return sexp( + '(header :class "mb-6 sm:mb-8 flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4"' + ' (div :class "space-y-1"' + ' (p :class "text-xs sm:text-sm text-stone-600" "Placed " (raw! created) " \u00b7 Status: " (raw! status)))' + ' (div :class "flex w-full sm:w-auto justify-start sm:justify-end gap-2"' + ' (a :href list-url :class "inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition"' + ' (i :class "fa-solid fa-list mr-2" :aria-hidden "true") "All orders")' + ' (form :method "post" :action recheck-url :class "inline"' + ' (input :type "hidden" :name "csrf_token" :value csrf)' + ' (button :type "submit"' + ' :class "inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition"' + ' (i :class "fa-solid fa-rotate mr-2" :aria-hidden "true") "Re-check status"))' + ' (raw! pay)))', + created=created, status=status, + **{"list-url": list_url, "recheck-url": recheck_url}, + csrf=csrf_token, pay=pay_html, ) @@ -389,13 +445,10 @@ async def render_order_oob(ctx: dict, order: Any, # --------------------------------------------------------------------------- def _checkout_error_filter_html() -> str: - return ( - '
    ' - '

    ' - 'Checkout error

    ' - '

    ' - 'We tried to start your payment with SumUp but hit a problem.

    ' - '
    ' + return sexp( + '(header :class "mb-6 sm:mb-8"' + ' (h1 :class "text-xl sm:text-2xl md:text-3xl font-semibold tracking-tight" "Checkout error")' + ' (p :class "text-xs sm:text-sm text-stone-600" "We tried to start your payment with SumUp but hit a problem."))', ) @@ -403,25 +456,22 @@ def _checkout_error_content_html(error: str | None, order: Any | None) -> str: err_msg = error or "Unexpected error while creating the hosted checkout session." order_html = "" if order: - order_html = ( - f'

    ' - f'Order ID: #{order.id}

    ' + order_html = sexp( + '(p :class "text-xs text-rose-800/80" "Order ID: " (span :class "font-mono" (raw! oid)))', + oid=f"#{order.id}", ) back_url = cart_url("/") - return ( - '
    ' - '
    ' - f'

    Something went wrong.

    ' - f'

    {err_msg}

    ' - f'{order_html}' - '
    ' - '
    ' - f'' - '' - 'Back to cart' - '
    ' - '
    ' + return sexp( + '(div :class "max-w-full px-3 py-3 space-y-4"' + ' (div :class "rounded-2xl border border-rose-200 bg-rose-50/80 p-4 sm:p-6 text-sm text-rose-900 space-y-2"' + ' (p :class "font-medium" "Something went wrong.")' + ' (p (raw! msg))' + ' (raw! order-html))' + ' (div' + ' (a :href back-url' + ' :class "inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition"' + ' (i :class "fa fa-shopping-cart mr-2" :aria-hidden "true") "Back to cart")))', + msg=err_msg, **{"order-html": order_html, "back-url": back_url}, )