5 Commits

Author SHA1 Message Date
a70d3648ec Add sexp protocol spec and tiered client architecture plan
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m16s
Defines three client tiers (browser HTML, browser extension with
sexpr.js, Rust native client) served from the same route handlers
via content negotiation. Includes native sexp:// protocol design
over QUIC, content-addressed caching, bidirectional streaming,
self-describing schema, and implementation plan from Phase 1
(Quart content negotiation) through Phase 7 (fallback gateway).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 14:40:18 +00:00
0d1ce92e52 Fix sexp parse errors: avoid literal parentheses in sexp string args
The sexp parser doesn't handle "(" and ")" as string literals
inside expressions. Use raw! with pre-formatted strings instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 14:20:41 +00:00
09b5a5b4f6 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 <noreply@anthropic.com>
2026-02-28 14:15:17 +00:00
f0a100fd77 Convert cart sexp_components.py from f-string HTML to pure sexp() calls
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 14:08:36 +00:00
16da08ff05 Fix market and calendar URL routing
Market: blog links now use market_url('/{slug}/') instead of
events_url('/{slug}/markets/'), matching the market service's
actual route structure /<page_slug>/<market_slug>/.

Calendar: flatten route from /<slug>/calendars/<calendar_slug>/
to /<slug>/<calendar_slug>/ by changing the events app blueprint
prefix and moving listing routes to explicit /calendars/ paths.
Update all hardcoded calendar URL paths across blog and events
services (Python + Jinja templates).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 13:58:05 +00:00
21 changed files with 1607 additions and 741 deletions

View File

@@ -69,43 +69,59 @@ def _account_main_panel_html(ctx: dict) -> str:
user = getattr(g, "user", None)
error = ctx.get("error", "")
parts = ['<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">']
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'<div class="rounded-lg border border-red-200 bg-red-50 text-red-800 px-4 py-3 text-sm">{error}</div>'
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('<div class="flex items-center justify-between"><div>')
parts.append('<h1 class="text-xl font-semibold tracking-tight">Account</h1>')
if user:
parts.append(f'<p class="text-sm text-stone-500 mt-1">{user.email}</p>')
if user.name:
parts.append(f'<p class="text-sm text-stone-600">{user.name}</p>')
parts.append('</div>')
parts.append(
f'<form action="/auth/logout/" method="post">'
f'<input type="hidden" name="csrf_token" value="{generate_csrf_token()}">'
f'<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">'
f'<i class="fa-solid fa-right-from-bracket text-xs"></i> Sign out</button></form>'
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('</div>')
# Labels
if user and hasattr(user, "labels") and user.labels:
parts.append('<div><h2 class="text-base font-semibold tracking-tight mb-3">Labels</h2>')
parts.append('<div class="flex flex-wrap gap-2">')
for label in user.labels:
parts.append(
f'<span class="inline-flex items-center rounded-full border border-stone-200 px-3 py-1 text-xs font-medium bg-white/60">'
f'{label.name}</span>'
)
parts.append('</div></div>')
parts.append('</div></div>')
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'<div id="nl-{nid}" class="flex items-center">'
f'<button hx-post="{toggle_url}"'
f' hx-headers=\'{{"X-CSRFToken": "{csrf_token}"}}\''
f' hx-target="#nl-{nid}" hx-swap="outerHTML"'
f' class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors'
f' focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 {bg}"'
f' role="switch" aria-checked="{checked}">'
f'<span class="inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform {translate}"></span>'
f'</button></div>'
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 = ['<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</h1>']
if newsletter_list:
parts.append('<div class="divide-y divide-stone-100">')
items = []
for item in newsletter_list:
nl = item["newsletter"]
un = item.get("un")
parts.append('<div class="flex items-center justify-between py-4 first:pt-0 last:pb-0">')
parts.append(f'<div class="min-w-0 flex-1"><p class="text-sm font-medium text-stone-800">{nl.name}</p>')
if nl.description:
parts.append(f'<p class="text-xs text-stone-500 mt-0.5 truncate">{nl.description}</p>')
parts.append('</div><div class="ml-4 flex-shrink-0">')
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'<div id="nl-{nl.id}" class="flex items-center">'
f'<button hx-post="{toggle_url}"'
f' hx-headers=\'{{"X-CSRFToken": "{csrf}"}}\''
f' hx-target="#nl-{nl.id}" hx-swap="outerHTML"'
f' class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors'
f' focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 bg-stone-300"'
f' role="switch" aria-checked="false">'
f'<span class="inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform translate-x-1"></span>'
f'</button></div>'
)
parts.append('</div></div>')
parts.append('</div>')
else:
parts.append('<p class="text-sm text-stone-500">No newsletters available.</p>')
toggle = _newsletter_toggle_off_html(nl.id, toggle_url, csrf)
parts.append('</div></div>')
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 = ['<div class="py-8 max-w-md mx-auto">',
'<h1 class="text-2xl font-bold mb-6">Sign in</h1>']
if error:
parts.append(
f'<div class="bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4">{error}</div>'
)
action = url_for("auth.start_login")
parts.append(
f'<form method="post" action="{action}" class="space-y-4">'
f'<input type="hidden" name="csrf_token" value="{generate_csrf_token()}">'
f'<div><label for="email" class="block text-sm font-medium mb-1">Email address</label>'
f'<input type="email" name="email" id="email" value="{email}" required autofocus'
f' class="w-full border border-stone-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-stone-500"></div>'
f'<button type="submit" class="w-full bg-stone-800 text-white py-2 px-4 rounded hover:bg-stone-700 transition">'
f'Send magic link</button></form>'
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('</div>')
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 = ['<div class="py-8 max-w-md mx-auto">',
'<h1 class="text-2xl font-bold mb-6">Authorize device</h1>',
'<p class="text-stone-600 mb-4">Enter the code shown in your terminal to sign in.</p>']
if error:
parts.append(
f'<div class="bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4">{error}</div>'
)
action = url_for("auth.device_submit")
parts.append(
f'<form method="post" action="{action}" class="space-y-4">'
f'<input type="hidden" name="csrf_token" value="{generate_csrf_token()}">'
f'<div><label for="code" class="block text-sm font-medium mb-1">Device code</label>'
f'<input type="text" name="code" id="code" value="{code}" placeholder="XXXX-XXXX"'
f' required autofocus maxlength="9" autocomplete="off" spellcheck="false"'
f' 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"></div>'
f'<button type="submit" class="w-full bg-stone-800 text-white py-2 px-4 rounded hover:bg-stone-700 transition">'
f'Authorize</button></form>'
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('</div>')
return "".join(parts)
def _device_approved_content() -> str:
"""Device approved success content."""
return (
'<div class="py-8 max-w-md mx-auto text-center">'
'<h1 class="text-2xl font-bold mb-4">Device authorized</h1>'
'<p class="text-stone-600">You can close this window and return to your terminal.</p>'
'</div>'
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'<div class="bg-yellow-50 border border-yellow-200 text-yellow-700 p-3 rounded mt-4">'
f'{escape(email_error)}</div>'
)
return (
'<div class="py-8 max-w-md mx-auto text-center">'
'<h1 class="text-2xl font-bold mb-4">Check your email</h1>'
f'<p class="text-stone-600 mb-2">We sent a sign-in link to <strong>{escape(email)}</strong>.</p>'
'<p class="text-stone-500 text-sm">Click the link in the email to sign in. The link expires in 15 minutes.</p>'
f'{error_html}</div>'
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,
)

View File

@@ -165,15 +165,18 @@ def _post_admin_nav_html(ctx: dict) -> str:
parts = []
# External links to events service
# External links to events / market services
events_url_fn = ctx.get("events_url")
market_url_fn = ctx.get("market_url")
if callable(events_url_fn):
for path, label in [
(f"/{slug}/calendars/", "calendars"),
(f"/{slug}/markets/", "markets"),
(f"/{slug}/payments/", "payments"),
for url_fn, path, label in [
(events_url_fn, f"/{slug}/calendar/", "calendar"),
(market_url_fn, f"/{slug}/", "markets"),
(events_url_fn, f"/{slug}/payments/", "payments"),
]:
href = events_url_fn(path)
if not callable(url_fn):
continue
href = url_fn(path)
parts.append(sexp(
'(div :class "relative nav-group" (a :href h :class c l))',
h=href, c=nav_btn, l=label,
@@ -2573,7 +2576,7 @@ def render_nav_entries_oob(associated_entries, calendars, post: dict, ctx: dict
if e_start:
entry_path = (
f"/{post_slug}/calendars/{cal_slug}/"
f"/{post_slug}/{cal_slug}/"
f"{e_start.year}/{e_start.month}/{e_start.day}"
f"/entries/{getattr(entry, 'id', '')}/"
)
@@ -2581,7 +2584,7 @@ def render_nav_entries_oob(associated_entries, calendars, post: dict, ctx: dict
if e_end:
date_str += f" \u2013 {e_end.strftime('%H:%M')}"
else:
entry_path = f"/{post_slug}/calendars/{cal_slug}/"
entry_path = f"/{post_slug}/{cal_slug}/"
date_str = ""
href = events_url_fn(entry_path) if events_url_fn else entry_path
@@ -2599,7 +2602,7 @@ def render_nav_entries_oob(associated_entries, calendars, post: dict, ctx: dict
for calendar in (calendars or []):
cal_name = getattr(calendar, "name", "")
cal_slug = getattr(calendar, "slug", "")
cal_path = f"/{post_slug}/calendars/{cal_slug}/"
cal_path = f"/{post_slug}/{cal_slug}/"
href = events_url_fn(cal_path) if events_url_fn else cal_path
item_parts.append(sexp(

View File

@@ -4,7 +4,7 @@
{% set has_more_entries = has_more if has_more is defined else (associated_entries.has_more if associated_entries is defined else False) %}
{% for entry in entry_list %}
{% set _entry_path = '/' + post.slug + '/calendars/' + entry.calendar_slug + '/' + entry.start_at.year|string + '/' + entry.start_at.month|string + '/' + entry.start_at.day|string + '/entries/' + entry.id|string + '/' %}
{% set _entry_path = '/' + post.slug + '/' + entry.calendar_slug + '/' + entry.start_at.year|string + '/' + entry.start_at.month|string + '/' + entry.start_at.day|string + '/entries/' + entry.id|string + '/' %}
<a
href="{{ events_url(_entry_path) }}"
class="{{styles.nav_button_less_pad}}"

View File

@@ -1,11 +1,11 @@
{% import 'macros/links.html' as links %}
<div class="relative nav-group">
<a href="{{ events_url('/' + post.slug + '/calendars/') }}" class="{{styles.nav_button}}">
calendars
<a href="{{ events_url('/' + post.slug + '/calendar/') }}" class="{{styles.nav_button}}">
calendar
</a>
</div>
<div class="relative nav-group">
<a href="{{ events_url('/' + post.slug + '/markets/') }}" class="{{styles.nav_button}}">
<a href="{{ market_url('/' + post.slug + '/') }}" class="{{styles.nav_button}}">
markets
</a>
</div>

View File

@@ -5,7 +5,7 @@
{% call nav_entries_oob(has_items) %}
{% if associated_entries and associated_entries.entries %}
{% for entry in associated_entries.entries %}
{% set _entry_path = '/' + post.slug + '/calendars/' + entry.calendar_slug + '/' + entry.start_at.year|string + '/' + entry.start_at.month|string + '/' + entry.start_at.day|string + '/entries/' + entry.id|string + '/' %}
{% set _entry_path = '/' + post.slug + '/' +entry.calendar_slug + '/' + entry.start_at.year|string + '/' + entry.start_at.month|string + '/' + entry.start_at.day|string + '/entries/' + entry.id|string + '/' %}
<a
href="{{ events_url(_entry_path) }}"
class="{{styles.nav_button_less_pad}}">
@@ -22,7 +22,7 @@
{% endif %}
{% if calendars %}
{% for calendar in calendars %}
{% set local_href=events_url('/' + post.slug + '/calendars/' + calendar.slug + '/') %}
{% set local_href=events_url('/' + post.slug + '/' +calendar.slug + '/') %}
<a
href="{{ local_href }}"
class="{{styles.nav_button_less_pad}}">

View File

@@ -1,7 +1,7 @@
{% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %}
{% call links.menu_row(id='post_data-row', oob=oob) %}
<a href="{{ events_url('/' + post.slug + '/calendars/') }}" class="flex gap-2 px-3 py-2 rounded whitespace-normal text-center break-words leading-snug">
<a href="{{ events_url('/' + post.slug + '/calendar/') }}" class="flex gap-2 px-3 py-2 rounded whitespace-normal text-center break-words leading-snug">
<i class="fa fa-database" aria-hidden="true"></i>
<div>data</div>
</a>

View File

@@ -35,18 +35,17 @@ def _page_cart_header_html(ctx: dict, page_post: Any, *, oob: bool = False) -> s
"""Build the per-page cart header row."""
slug = page_post.slug if page_post else ""
title = ((page_post.title if page_post else None) or "")[:160]
img_html = ""
label_html = ""
if page_post and page_post.feature_image:
img_html = (
f'<img src="{page_post.feature_image}"'
f' class="h-8 w-8 rounded-full object-cover border border-stone-300 flex-shrink-0">'
label_html += sexp(
'(img :src fi :class "h-8 w-8 rounded-full object-cover border border-stone-300 flex-shrink-0")',
fi=page_post.feature_image,
)
label_html = f'{img_html}<span>{title}</span>'
label_html += sexp('(span t)', t=title)
nav_html = sexp(
'(a :href h :class "inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition"'
' (raw! i) "All carts")',
' (i :class "fa fa-arrow-left text-xs" :aria-hidden "true") "All carts")',
h=call_url(ctx, "cart_url", "/"),
i='<i class="fa fa-arrow-left text-xs" aria-hidden="true"></i>',
)
return sexp(
'(~menu-row :id "page-cart-row" :level 2 :colour "sky"'
@@ -83,6 +82,16 @@ def _orders_header_html(ctx: dict, list_url: str) -> str:
# Cart overview
# ---------------------------------------------------------------------------
def _badge_html(icon: str, count: int, label: str) -> str:
"""Render a count badge."""
s = "s" if count != 1 else ""
return sexp(
'(span :class "inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-stone-100"'
' (i :class ic :aria-hidden "true") txt)',
ic=icon, txt=f"{count} {label}{s}",
)
def _page_group_card_html(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)
@@ -99,88 +108,102 @@ def _page_group_card_html(grp: Any, ctx: dict) -> str:
return ""
# Count badges
badges = []
badges = ""
if product_count > 0:
s = "s" if product_count != 1 else ""
badges.append(
f'<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-stone-100">'
f'<i class="fa fa-box-open" aria-hidden="true"></i> {product_count} item{s}</span>'
)
badges += _badge_html("fa fa-box-open", product_count, "item")
if calendar_count > 0:
s = "s" if calendar_count != 1 else ""
badges.append(
f'<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-stone-100">'
f'<i class="fa fa-calendar" aria-hidden="true"></i> {calendar_count} booking{s}</span>'
)
badges += _badge_html("fa fa-calendar", calendar_count, "booking")
if ticket_count > 0:
s = "s" if ticket_count != 1 else ""
badges.append(
f'<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-stone-100">'
f'<i class="fa fa-ticket" aria-hidden="true"></i> {ticket_count} ticket{s}</span>'
)
badges_html = '<div class="mt-1 flex flex-wrap gap-2 text-xs text-stone-600">' + "".join(badges) + '</div>'
badges += _badge_html("fa fa-ticket", ticket_count, "ticket")
badges_html = sexp(
'(div :class "mt-1 flex flex-wrap gap-2 text-xs text-stone-600" (raw! b))',
b=badges,
)
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_url = call_url(ctx, "cart_url", f"/{slug}/")
cart_href = call_url(ctx, "cart_url", f"/{slug}/")
if feature_image:
img = f'<img src="{feature_image}" alt="{title}" class="h-16 w-16 rounded-xl object-cover border border-stone-200 flex-shrink-0">'
img = sexp(
'(img :src fi :alt t :class "h-16 w-16 rounded-xl object-cover border border-stone-200 flex-shrink-0")',
fi=feature_image, t=title,
)
else:
img = '<div class="h-16 w-16 rounded-xl bg-stone-100 flex items-center justify-center flex-shrink-0"><i class="fa fa-store text-stone-400 text-xl" aria-hidden="true"></i></div>'
img = sexp(
'(div :class "h-16 w-16 rounded-xl bg-stone-100 flex items-center justify-center flex-shrink-0"'
' (i :class "fa fa-store text-stone-400 text-xl" :aria-hidden "true"))',
)
mp_name = ""
mp_sub = ""
if market_place:
mp_name = market_place.name if hasattr(market_place, "name") else market_place.get("name", "")
mp_sub = f'<p class="text-xs text-stone-500 truncate">{title}</p>'
mp_sub = sexp('(p :class "text-xs text-stone-500 truncate" t)', t=title)
display_title = mp_name or title
return (
f'<a href="{cart_url}" class="block rounded-2xl border border-stone-200 bg-white shadow-sm hover:shadow-md hover:border-stone-300 transition p-4 sm:p-5">'
f'<div class="flex items-start gap-4">{img}'
f'<div class="flex-1 min-w-0"><h3 class="text-base sm:text-lg font-semibold text-stone-900 truncate">{display_title}</h3>{mp_sub}{badges_html}</div>'
f'<div class="text-right flex-shrink-0"><div class="text-lg font-bold text-stone-900">&pound;{total:.2f}</div>'
f'<div class="mt-1 text-xs text-emerald-700 font-medium">View cart &rarr;</div></div></div></a>'
return sexp(
'(a :href ch :class "block rounded-2xl border border-stone-200 bg-white shadow-sm hover:shadow-md hover:border-stone-300 transition p-4 sm:p-5"'
' (div :class "flex items-start gap-4"'
' (raw! img)'
' (div :class "flex-1 min-w-0"'
' (h3 :class "text-base sm:text-lg font-semibold text-stone-900 truncate" dt)'
' (raw! ms) (raw! bh))'
' (div :class "text-right flex-shrink-0"'
' (div :class "text-lg font-bold text-stone-900" tt)'
' (div :class "mt-1 text-xs text-emerald-700 font-medium" "View cart \u2192"))))',
ch=cart_href, img=img, dt=display_title, ms=mp_sub, bh=badges_html,
tt=f"\u00a3{total:.2f}",
)
else:
# Orphan items
badges_html_amber = badges_html.replace("bg-stone-100", "bg-amber-100")
return (
f'<div class="rounded-2xl border border-dashed border-amber-300 bg-amber-50/60 p-4 sm:p-5">'
f'<div class="flex items-start gap-4">'
f'<div class="h-16 w-16 rounded-xl bg-amber-100 flex items-center justify-center flex-shrink-0">'
f'<i class="fa fa-shopping-cart text-amber-500 text-xl" aria-hidden="true"></i></div>'
f'<div class="flex-1 min-w-0"><h3 class="text-base sm:text-lg font-semibold text-stone-900">Other items</h3>{badges_html_amber}</div>'
f'<div class="text-right flex-shrink-0"><div class="text-lg font-bold text-stone-900">&pound;{total:.2f}</div></div></div></div>'
# Orphan items — use amber badges
badges_amber = badges.replace("bg-stone-100", "bg-amber-100")
badges_html_amber = sexp(
'(div :class "mt-1 flex flex-wrap gap-2 text-xs text-stone-600" (raw! b))',
b=badges_amber,
)
return sexp(
'(div :class "rounded-2xl border border-dashed border-amber-300 bg-amber-50/60 p-4 sm:p-5"'
' (div :class "flex items-start gap-4"'
' (div :class "h-16 w-16 rounded-xl bg-amber-100 flex items-center justify-center flex-shrink-0"'
' (i :class "fa fa-shopping-cart text-amber-500 text-xl" :aria-hidden "true"))'
' (div :class "flex-1 min-w-0"'
' (h3 :class "text-base sm:text-lg font-semibold text-stone-900" "Other items")'
' (raw! bh))'
' (div :class "text-right flex-shrink-0"'
' (div :class "text-lg font-bold text-stone-900" tt))))',
bh=badges_html_amber, tt=f"\u00a3{total:.2f}",
)
def _empty_cart_html() -> str:
"""Empty cart state."""
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-6 sm:p-8 text-center"'
' (div :class "inline-flex h-10 w-10 sm:h-12 sm:w-12 items-center justify-center rounded-full bg-stone-100 mb-3"'
' (i :class "fa fa-shopping-cart text-stone-500 text-sm sm:text-base" :aria-hidden "true"))'
' (p :class "text-base sm:text-lg font-medium text-stone-800" "Your cart is empty")))',
)
def _overview_main_panel_html(page_groups: list, ctx: dict) -> str:
"""Cart overview main panel."""
if not page_groups:
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">'
'<div class="inline-flex h-10 w-10 sm:h-12 sm:w-12 items-center justify-center rounded-full bg-stone-100 mb-3">'
'<i class="fa fa-shopping-cart text-stone-500 text-sm sm:text-base" aria-hidden="true"></i></div>'
'<p class="text-base sm:text-lg font-medium text-stone-800">Your cart is empty</p></div></div>'
)
return _empty_cart_html()
cards = [_page_group_card_html(grp, ctx) for grp in page_groups]
has_items = any(c for c in cards)
if not has_items:
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">'
'<div class="inline-flex h-10 w-10 sm:h-12 sm:w-12 items-center justify-center rounded-full bg-stone-100 mb-3">'
'<i class="fa fa-shopping-cart text-stone-500 text-sm sm:text-base" aria-hidden="true"></i></div>'
'<p class="text-base sm:text-lg font-medium text-stone-800">Your cart is empty</p></div></div>'
)
return _empty_cart_html()
return '<div class="max-w-full px-3 py-3 space-y-3"><div class="space-y-4">' + "".join(cards) + '</div></div>'
return sexp(
'(div :class "max-w-full px-3 py-3 space-y-3"'
' (div :class "space-y-4" (raw! c)))',
c="".join(cards),
)
# ---------------------------------------------------------------------------
@@ -202,52 +225,78 @@ def _cart_item_html(item: Any, ctx: dict) -> str:
prod_url = market_product_url(slug)
if p.image:
img = f'<img src="{p.image}" alt="{p.title}" class="w-24 h-24 sm:w-32 sm:h-28 object-cover rounded-xl border border-stone-100" loading="lazy">'
img = sexp(
'(img :src im :alt t :class "w-24 h-24 sm:w-32 sm:h-28 object-cover rounded-xl border border-stone-100" :loading "lazy")',
im=p.image, t=p.title,
)
else:
img = '<div class="w-24 h-24 sm:w-32 sm:h-28 rounded-xl border border-dashed border-stone-300 flex items-center justify-center text-xs text-stone-400">No image</div>'
img = sexp(
'(div :class "w-24 h-24 sm:w-32 sm:h-28 rounded-xl border border-dashed border-stone-300 flex items-center justify-center text-xs text-stone-400"'
' "No image")',
)
price_html = ""
if unit_price:
price_html = f'<p class="text-sm sm:text-base font-semibold text-stone-900">{symbol}{unit_price:.2f}</p>'
price_html = sexp(
'(p :class "text-sm sm:text-base font-semibold text-stone-900" ps)',
ps=f"{symbol}{unit_price:.2f}",
)
if p.special_price and p.special_price != p.regular_price:
price_html += f'<p class="text-xs text-stone-400 line-through">{symbol}{p.regular_price:.2f}</p>'
price_html += sexp(
'(p :class "text-xs text-stone-400 line-through" ps)',
ps=f"{symbol}{p.regular_price:.2f}",
)
else:
price_html = '<p class="text-xs text-stone-500">No price</p>'
price_html = sexp('(p :class "text-xs text-stone-500" "No price")')
deleted_html = ""
if getattr(item, "is_deleted", False):
deleted_html = (
'<p class="mt-2 inline-flex items-center gap-1 text-[0.65rem] sm:text-xs font-medium text-amber-700 bg-amber-50 border border-amber-200 rounded-full px-2 py-0.5">'
'<i class="fa-solid fa-triangle-exclamation text-[0.6rem]" aria-hidden="true"></i>'
' This item is no longer available or price has changed</p>'
deleted_html = sexp(
'(p :class "mt-2 inline-flex items-center gap-1 text-[0.65rem] sm:text-xs font-medium text-amber-700 bg-amber-50 border border-amber-200 rounded-full px-2 py-0.5"'
' (i :class "fa-solid fa-triangle-exclamation text-[0.6rem]" :aria-hidden "true")'
' " This item is no longer available or price has changed")',
)
brand_html = f'<p class="mt-0.5 text-[0.7rem] sm:text-xs text-stone-500">{p.brand}</p>' if getattr(p, "brand", None) else ""
brand_html = ""
if getattr(p, "brand", None):
brand_html = sexp('(p :class "mt-0.5 text-[0.7rem] sm:text-xs text-stone-500" br)', br=p.brand)
line_total_html = ""
if unit_price:
lt = unit_price * item.quantity
line_total_html = f'<p class="text-sm sm:text-base font-semibold text-stone-900">Line total: {symbol}{lt:.2f}</p>'
line_total_html = sexp(
'(p :class "text-sm sm:text-base font-semibold text-stone-900" lt)',
lt=f"Line total: {symbol}{lt:.2f}",
)
return (
f'<article id="cart-item-{slug}" class="flex flex-col sm:flex-row gap-3 sm:gap-4 rounded-2xl bg-white shadow-sm border border-stone-200 p-3 sm:p-4 md:p-5">'
f'<div class="w-full sm:w-32 shrink-0 flex justify-center sm:block">{img}</div>'
f'<div class="flex-1 min-w-0">'
f'<div class="flex flex-col sm:flex-row sm:items-start justify-between gap-2 sm:gap-3">'
f'<div class="min-w-0"><h2 class="text-sm sm:text-base md:text-lg font-semibold text-stone-900">'
f'<a href="{prod_url}" class="hover:text-emerald-700">{p.title}</a></h2>{brand_html}{deleted_html}</div>'
f'<div class="text-left sm:text-right">{price_html}</div></div>'
f'<div class="mt-3 flex flex-col sm:flex-row sm:items-center justify-between gap-2 sm:gap-4">'
f'<div class="flex items-center gap-2 text-xs sm:text-sm text-stone-700">'
f'<span class="text-[0.65rem] sm:text-xs uppercase tracking-wide text-stone-500">Quantity</span>'
f'<form action="{qty_url}" method="post" hx-post="{qty_url}" hx-swap="none">'
f'<input type="hidden" name="csrf_token" value="{csrf}"><input type="hidden" name="count" value="{item.quantity - 1}">'
f'<button type="submit" class="inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl">-</button></form>'
f'<span class="inline-flex items-center justify-center px-2 py-1 rounded-full bg-stone-100 text-[0.7rem] sm:text-xs font-medium">{item.quantity}</span>'
f'<form action="{qty_url}" method="post" hx-post="{qty_url}" hx-swap="none">'
f'<input type="hidden" name="csrf_token" value="{csrf}"><input type="hidden" name="count" value="{item.quantity + 1}">'
f'<button type="submit" class="inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl">+</button></form></div>'
f'<div class="flex items-center justify-between sm:justify-end gap-3">{line_total_html}</div></div></div></article>'
return sexp(
'(article :id aid :class "flex flex-col sm:flex-row gap-3 sm:gap-4 rounded-2xl bg-white shadow-sm border border-stone-200 p-3 sm:p-4 md:p-5"'
' (div :class "w-full sm:w-32 shrink-0 flex justify-center sm:block" (raw! img))'
' (div :class "flex-1 min-w-0"'
' (div :class "flex flex-col sm:flex-row sm:items-start justify-between gap-2 sm:gap-3"'
' (div :class "min-w-0"'
' (h2 :class "text-sm sm:text-base md:text-lg font-semibold text-stone-900"'
' (a :href pu :class "hover:text-emerald-700" pt))'
' (raw! brh) (raw! dh))'
' (div :class "text-left sm:text-right" (raw! ph)))'
' (div :class "mt-3 flex flex-col sm:flex-row sm:items-center justify-between gap-2 sm:gap-4"'
' (div :class "flex items-center gap-2 text-xs sm:text-sm text-stone-700"'
' (span :class "text-[0.65rem] sm:text-xs uppercase tracking-wide text-stone-500" "Quantity")'
' (form :action qu :method "post" :hx-post qu :hx-swap "none"'
' (input :type "hidden" :name "csrf_token" :value csrf)'
' (input :type "hidden" :name "count" :value minus)'
' (button :type "submit" :class "inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl" "-"))'
' (span :class "inline-flex items-center justify-center px-2 py-1 rounded-full bg-stone-100 text-[0.7rem] sm:text-xs font-medium" qty)'
' (form :action qu :method "post" :hx-post qu :hx-swap "none"'
' (input :type "hidden" :name "csrf_token" :value csrf)'
' (input :type "hidden" :name "count" :value plus)'
' (button :type "submit" :class "inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl" "+")))'
' (div :class "flex items-center justify-between sm:justify-end gap-3" (raw! lth)))))',
aid=f"cart-item-{slug}", img=img, pu=prod_url, pt=p.title,
brh=brand_html, dh=deleted_html, ph=price_html,
qu=qty_url, csrf=csrf, minus=str(item.quantity - 1),
qty=str(item.quantity), plus=str(item.quantity + 1),
lth=line_total_html,
)
@@ -255,23 +304,25 @@ def _calendar_entries_html(entries: list) -> str:
"""Render calendar booking entries in cart."""
if not entries:
return ""
items = []
items = ""
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_html = f" \u2013 {end}" if end else ""
items.append(
f'<li class="flex items-start justify-between text-sm">'
f'<div><div class="font-medium">{name}</div>'
f'<div class="text-xs text-stone-500">{start}{end_html}</div></div>'
f'<div class="ml-4 font-medium">\u00a3{cost:.2f}</div></li>'
end_str = f" \u2013 {end}" if end else ""
items += sexp(
'(li :class "flex items-start justify-between text-sm"'
' (div (div :class "font-medium" nm)'
' (div :class "text-xs text-stone-500" ds))'
' (div :class "ml-4 font-medium" cs))',
nm=name, ds=f"{start}{end_str}", cs=f"\u00a3{cost:.2f}",
)
return (
'<div class="mt-6 border-t border-stone-200 pt-4">'
'<h2 class="text-base font-semibold mb-2">Calendar bookings</h2>'
f'<ul class="space-y-2">{"".join(items)}</ul></div>'
return sexp(
'(div :class "mt-6 border-t border-stone-200 pt-4"'
' (h2 :class "text-base font-semibold mb-2" "Calendar bookings")'
' (ul :class "space-y-2" (raw! items)))',
items=items,
)
@@ -284,9 +335,7 @@ def _ticket_groups_html(ticket_groups: list, ctx: dict) -> str:
csrf = generate_csrf_token()
qty_url = url_for("cart_global.update_ticket_quantity")
parts = ['<div class="mt-6 border-t border-stone-200 pt-4">',
'<h2 class="text-base font-semibold mb-2"><i class="fa fa-ticket mr-1" aria-hidden="true"></i> Event tickets</h2>',
'<div class="space-y-3">']
items = ""
for tg in ticket_groups:
name = tg.entry_name if hasattr(tg, "entry_name") else tg.get("entry_name", "")
@@ -303,34 +352,51 @@ def _ticket_groups_html(ticket_groups: list, ctx: dict) -> str:
if end_at:
date_str += f" \u2013 {end_at.strftime('%-d %b %Y, %H:%M')}"
tt_name_html = f'<p class="mt-0.5 text-[0.7rem] sm:text-xs text-stone-500">{tt_name}</p>' if tt_name else ""
tt_hidden = f'<input type="hidden" name="ticket_type_id" value="{tt_id}">' if tt_id else ""
tt_name_html = sexp('(p :class "mt-0.5 text-[0.7rem] sm:text-xs text-stone-500" tn)', tn=tt_name) if tt_name else ""
tt_hidden = sexp('(input :type "hidden" :name "ticket_type_id" :value tid)', tid=str(tt_id)) if tt_id else ""
parts.append(
f'<article class="flex flex-col sm:flex-row gap-3 sm:gap-4 rounded-2xl bg-white shadow-sm border border-stone-200 p-3 sm:p-4">'
f'<div class="flex-1 min-w-0">'
f'<div class="flex flex-col sm:flex-row sm:items-start justify-between gap-2 sm:gap-3">'
f'<div class="min-w-0"><h3 class="text-sm sm:text-base font-semibold text-stone-900">{name}</h3>{tt_name_html}'
f'<p class="mt-0.5 text-[0.7rem] sm:text-xs text-stone-500">{date_str}</p></div>'
f'<div class="text-left sm:text-right"><p class="text-sm sm:text-base font-semibold text-stone-900">\u00a3{price or 0:.2f}</p></div></div>'
f'<div class="mt-3 flex flex-col sm:flex-row sm:items-center justify-between gap-2 sm:gap-4">'
f'<div class="flex items-center gap-2 text-xs sm:text-sm text-stone-700">'
f'<span class="text-[0.65rem] sm:text-xs uppercase tracking-wide text-stone-500">Quantity</span>'
f'<form action="{qty_url}" method="post" hx-post="{qty_url}" hx-swap="none">'
f'<input type="hidden" name="csrf_token" value="{csrf}"><input type="hidden" name="entry_id" value="{entry_id}">{tt_hidden}'
f'<input type="hidden" name="count" value="{max(quantity - 1, 0)}">'
f'<button type="submit" class="inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl">-</button></form>'
f'<span class="inline-flex items-center justify-center px-2 py-1 rounded-full bg-stone-100 text-[0.7rem] sm:text-xs font-medium">{quantity}</span>'
f'<form action="{qty_url}" method="post" hx-post="{qty_url}" hx-swap="none">'
f'<input type="hidden" name="csrf_token" value="{csrf}"><input type="hidden" name="entry_id" value="{entry_id}">{tt_hidden}'
f'<input type="hidden" name="count" value="{quantity + 1}">'
f'<button type="submit" class="inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl">+</button></form></div>'
f'<div class="flex items-center justify-between sm:justify-end gap-3">'
f'<p class="text-sm sm:text-base font-semibold text-stone-900">Line total: \u00a3{line_total:.2f}</p></div></div></div></article>'
items += sexp(
'(article :class "flex flex-col sm:flex-row gap-3 sm:gap-4 rounded-2xl bg-white shadow-sm border border-stone-200 p-3 sm:p-4"'
' (div :class "flex-1 min-w-0"'
' (div :class "flex flex-col sm:flex-row sm:items-start justify-between gap-2 sm:gap-3"'
' (div :class "min-w-0"'
' (h3 :class "text-sm sm:text-base font-semibold text-stone-900" nm)'
' (raw! tnh)'
' (p :class "mt-0.5 text-[0.7rem] sm:text-xs text-stone-500" ds))'
' (div :class "text-left sm:text-right"'
' (p :class "text-sm sm:text-base font-semibold text-stone-900" ps)))'
' (div :class "mt-3 flex flex-col sm:flex-row sm:items-center justify-between gap-2 sm:gap-4"'
' (div :class "flex items-center gap-2 text-xs sm:text-sm text-stone-700"'
' (span :class "text-[0.65rem] sm:text-xs uppercase tracking-wide text-stone-500" "Quantity")'
' (form :action qu :method "post" :hx-post qu :hx-swap "none"'
' (input :type "hidden" :name "csrf_token" :value csrf)'
' (input :type "hidden" :name "entry_id" :value eid)'
' (raw! tth)'
' (input :type "hidden" :name "count" :value minus)'
' (button :type "submit" :class "inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl" "-"))'
' (span :class "inline-flex items-center justify-center px-2 py-1 rounded-full bg-stone-100 text-[0.7rem] sm:text-xs font-medium" qty)'
' (form :action qu :method "post" :hx-post qu :hx-swap "none"'
' (input :type "hidden" :name "csrf_token" :value csrf)'
' (input :type "hidden" :name "entry_id" :value eid)'
' (raw! tth)'
' (input :type "hidden" :name "count" :value plus)'
' (button :type "submit" :class "inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl" "+")))'
' (div :class "flex items-center justify-between sm:justify-end gap-3"'
' (p :class "text-sm sm:text-base font-semibold text-stone-900" lt)))))',
nm=name, tnh=tt_name_html, ds=date_str,
ps=f"\u00a3{price or 0:.2f}", qu=qty_url, csrf=csrf,
eid=str(entry_id), tth=tt_hidden,
minus=str(max(quantity - 1, 0)), qty=str(quantity),
plus=str(quantity + 1), lt=f"Line total: \u00a3{line_total:.2f}",
)
parts.append('</div></div>')
return "".join(parts)
return sexp(
'(div :class "mt-6 border-t border-stone-200 pt-4"'
' (h2 :class "text-base font-semibold mb-2"'
' (i :class "fa fa-ticket mr-1" :aria-hidden "true") " Event tickets")'
' (div :class "space-y-3" (raw! items)))',
items=items,
)
def _cart_summary_html(ctx: dict, cart: list, cal_entries: list, tickets: list,
@@ -365,28 +431,36 @@ def _cart_summary_html(ctx: dict, cart: list, cal_entries: list, tickets: list,
action = url_for("cart_global.checkout")
from shared.utils import route_prefix
action = route_prefix() + action
checkout_html = (
f'<form method="post" action="{action}" class="w-full">'
f'<input type="hidden" name="csrf_token" value="{csrf}">'
f'<button type="submit" class="w-full inline-flex items-center justify-center px-4 py-2 text-xs sm:text-sm rounded-full border border-emerald-600 bg-emerald-600 text-white hover:bg-emerald-700 transition">'
f'<i class="fa-solid fa-credit-card mr-2" aria-hidden="true"></i> Checkout as {user.email}</button></form>'
checkout_html = sexp(
'(form :method "post" :action act :class "w-full"'
' (input :type "hidden" :name "csrf_token" :value csrf)'
' (button :type "submit" :class "w-full inline-flex items-center justify-center px-4 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-solid fa-credit-card mr-2" :aria-hidden "true") lbl))',
act=action, csrf=csrf, lbl=f" Checkout as {user.email}",
)
else:
href = login_url(request.url)
checkout_html = (
f'<div class="w-full flex"><a href="{href}" class="w-full cursor-pointer flex flex-row items-center justify-center p-3 gap-2 rounded bg-stone-200 text-black hover:bg-stone-300 transition">'
f'<i class="fa-solid fa-key"></i><span>sign in or register to checkout</span></a></div>'
checkout_html = sexp(
'(div :class "w-full flex"'
' (a :href h :class "w-full cursor-pointer flex flex-row items-center justify-center p-3 gap-2 rounded bg-stone-200 text-black hover:bg-stone-300 transition"'
' (i :class "fa-solid fa-key") (span "sign in or register to checkout")))',
h=href,
)
return (
f'<aside id="cart-summary" class="lg:pl-2"><div class="rounded-2xl bg-white shadow-sm border border-stone-200 p-4 sm:p-5">'
f'<h2 class="text-sm sm:text-base font-semibold text-stone-900 mb-3 sm:mb-4">Order summary</h2>'
f'<dl class="space-y-2 text-xs sm:text-sm">'
f'<div class="flex items-center justify-between"><dt class="text-stone-600">Items</dt><dd class="text-stone-900">{item_count}</dd></div>'
f'<div class="flex items-center justify-between"><dt class="text-stone-600">Subtotal</dt><dd class="text-stone-900">{symbol}{grand:.2f}</dd></div></dl>'
f'<div class="flex flex-col items-center w-full"><h1 class="text-5xl mt-2">This is a test - it will not take actual money</h1>'
f'<div>use dummy card number: 5555 5555 5555 4444</div></div>'
f'<div class="mt-4 sm:mt-5">{checkout_html}</div></div></aside>'
return sexp(
'(aside :id "cart-summary" :class "lg:pl-2"'
' (div :class "rounded-2xl bg-white shadow-sm border border-stone-200 p-4 sm:p-5"'
' (h2 :class "text-sm sm:text-base font-semibold text-stone-900 mb-3 sm:mb-4" "Order summary")'
' (dl :class "space-y-2 text-xs sm:text-sm"'
' (div :class "flex items-center justify-between"'
' (dt :class "text-stone-600" "Items") (dd :class "text-stone-900" ic))'
' (div :class "flex items-center justify-between"'
' (dt :class "text-stone-600" "Subtotal") (dd :class "text-stone-900" st)))'
' (div :class "flex flex-col items-center w-full"'
' (h1 :class "text-5xl mt-2" "This is a test - it will not take actual money")'
' (div "use dummy card number: 5555 5555 5555 4444"))'
' (div :class "mt-4 sm:mt-5" (raw! ch))))',
ic=str(item_count), st=f"{symbol}{grand:.2f}", ch=checkout_html,
)
@@ -396,12 +470,13 @@ def _page_cart_main_panel_html(ctx: dict, cart: list, cal_entries: list,
ticket_total_fn: Any) -> str:
"""Page cart main panel."""
if not cart and not cal_entries and not tickets:
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">'
'<div class="inline-flex h-10 w-10 sm:h-12 sm:w-12 items-center justify-center rounded-full bg-stone-100 mb-3">'
'<i class="fa fa-shopping-cart text-stone-500 text-sm sm:text-base" aria-hidden="true"></i></div>'
'<p class="text-base sm:text-lg font-medium text-stone-800">Your cart is empty</p></div></div></div>'
return sexp(
'(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"'
' (div :class "inline-flex h-10 w-10 sm:h-12 sm:w-12 items-center justify-center rounded-full bg-stone-100 mb-3"'
' (i :class "fa fa-shopping-cart text-stone-500 text-sm sm:text-base" :aria-hidden "true"))'
' (p :class "text-base sm:text-lg font-medium text-stone-800" "Your cart is empty"))))',
)
items_html = "".join(_cart_item_html(item, ctx) for item in cart)
@@ -409,10 +484,12 @@ def _page_cart_main_panel_html(ctx: dict, cart: list, cal_entries: list,
tickets_html = _ticket_groups_html(ticket_groups, ctx)
summary_html = _cart_summary_html(ctx, cart, cal_entries, tickets, total_fn, cal_total_fn, ticket_total_fn)
return (
f'<div class="max-w-full px-3 py-3 space-y-3"><div id="cart">'
f'<div><section class="space-y-3 sm:space-y-4">{items_html}{cal_html}{tickets_html}</section>'
f'{summary_html}</div></div></div>'
return sexp(
'(div :class "max-w-full px-3 py-3 space-y-3"'
' (div :id "cart"'
' (div (section :class "space-y-3 sm:space-y-4" (raw! ih) (raw! ch) (raw! th))'
' (raw! sh))))',
ih=items_html, ch=cal_html, th=tickets_html, sh=summary_html,
)
@@ -432,22 +509,36 @@ def _order_row_html(order: Any, detail_url: str) -> str:
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 (
f'<tr class="hidden sm:table-row border-t border-stone-100 hover:bg-stone-50/60">'
f'<td class="px-3 py-2 align-top"><span class="font-mono text-[11px] sm:text-xs">#{order.id}</span></td>'
f'<td class="px-3 py-2 align-top text-stone-700 text-xs sm:text-sm">{created}</td>'
f'<td class="px-3 py-2 align-top text-stone-700 text-xs sm:text-sm">{order.description or ""}</td>'
f'<td class="px-3 py-2 align-top text-stone-700 text-xs sm:text-sm">{total}</td>'
f'<td class="px-3 py-2 align-top"><span class="inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] sm:text-xs {pill}">{status}</span></td>'
f'<td class="px-3 py-0.5 align-top text-right"><a href="{detail_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</a></td></tr>'
f'<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">'
f'<div class="flex items-center justify-between gap-2"><span class="font-mono text-[11px] text-stone-700">#{order.id}</span>'
f'<span class="inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] {pill}">{status}</span></div>'
f'<div class="text-[11px] text-stone-500 break-words">{created}</div>'
f'<div class="flex items-center justify-between gap-2"><div class="font-medium text-stone-800">{total}</div>'
f'<a href="{detail_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</a></div></div></td></tr>'
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" oid))'
' (td :class "px-3 py-2 align-top text-stone-700 text-xs sm:text-sm" cr)'
' (td :class "px-3 py-2 align-top text-stone-700 text-xs sm:text-sm" desc)'
' (td :class "px-3 py-2 align-top text-stone-700 text-xs sm:text-sm" tot)'
' (td :class "px-3 py-2 align-top" (span :class (str "inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] sm:text-xs " pill) st))'
' (td :class "px-3 py-0.5 align-top text-right"'
' (a :href du :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}", cr=created, desc=order.description or "",
tot=total, pill=pill, st=status, du=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" oid)'
' (span :class (str "inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] " pill) st))'
' (div :class "text-[11px] text-stone-500 break-words" cr)'
' (div :class "flex items-center justify-between gap-2"'
' (div :class "font-medium text-stone-800" tot)'
' (a :href du :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}", pill=pill, st=status, cr=created,
tot=total, du=detail_url,
)
return desktop + mobile
def _orders_rows_html(orders: list, page: int, total_pages: int,
url_for_fn: Any, qs_fn: Any) -> str:
@@ -467,7 +558,9 @@ def _orders_rows_html(orders: list, page: int, total_pages: int,
u=next_url, p=page, **{"total-pages": total_pages},
))
else:
parts.append('<tr><td colspan="5" class="px-3 py-4 text-center text-xs text-stone-400">End of results</td></tr>')
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)
@@ -475,33 +568,36 @@ def _orders_rows_html(orders: list, page: int, total_pages: int,
def _orders_main_panel_html(orders: list, rows_html: str) -> str:
"""Main panel for orders list."""
if not orders:
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-4 sm:p-6 text-sm text-stone-700">'
'No orders yet.</div></div>'
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 (
'<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>'
'<th class="px-3 py-2 text-left font-medium">Created</th>'
'<th class="px-3 py-2 text-left font-medium">Description</th>'
'<th class="px-3 py-2 text-left font-medium">Total</th>'
'<th class="px-3 py-2 text-left font-medium">Status</th>'
'<th class="px-3 py-2 text-left font-medium"></th>'
f'</tr></thead><tbody>{rows_html}</tbody></table></div></div>'
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! rh)))))',
rh=rows_html,
)
def _orders_summary_html(ctx: dict) -> str:
"""Filter section for orders list."""
return (
'<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.</p></div>'
f'<div class="md:hidden">{search_mobile_html(ctx)}</div>'
'</header>'
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),
)
@@ -513,29 +609,36 @@ def _order_items_html(order: Any) -> str:
"""Render order items list."""
if not order or not order.items:
return ""
items = []
items = ""
for item in order.items:
prod_url = market_product_url(item.product_slug)
img = (
f'<img src="{item.product_image}" alt="{item.product_title or "Product image"}"'
f' class="w-full h-full object-contain object-center" loading="lazy" decoding="async">'
if item.product_image else
'<div class="w-full h-full flex items-center justify-center text-[9px] text-stone-400">No image</div>'
if item.product_image:
img = sexp(
'(img :src pi :alt pt :class "w-full h-full object-contain object-center" :loading "lazy" :decoding "async")',
pi=item.product_image, pt=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 += sexp(
'(li (a :class "w-full py-2 flex gap-3" :href pu'
' (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" pt)'
' (p :class "text-[11px] text-stone-500" pid))'
' (div :class "text-right whitespace-nowrap"'
' (p qty) (p pr)))))',
pu=prod_url, img=img, pt=item.product_title or "Unknown product",
pid=f"Product ID: {item.product_id}",
qty=f"Qty: {item.quantity}",
pr=f"{item.currency or order.currency or 'GBP'} {item.unit_price or 0:.2f}",
)
items.append(
f'<li><a class="w-full py-2 flex gap-3" href="{prod_url}">'
f'<div class="w-12 h-12 sm:w-14 sm:h-14 rounded-md bg-stone-100 flex-shrink-0 overflow-hidden">{img}</div>'
f'<div class="flex-1 flex justify-between gap-3">'
f'<div><p class="font-medium">{item.product_title or "Unknown product"}</p>'
f'<p class="text-[11px] text-stone-500">Product ID: {item.product_id}</p></div>'
f'<div class="text-right whitespace-nowrap"><p>Qty: {item.quantity}</p>'
f'<p>{item.currency or order.currency or "GBP"} {item.unit_price or 0:.2f}</p>'
f'</div></div></a></li>'
)
return (
'<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</h2>'
f'<ul class="divide-y divide-stone-100 text-xs sm:text-sm">{"".join(items)}</ul></div>'
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=items,
)
@@ -554,7 +657,7 @@ def _order_calendar_items_html(calendar_entries: list | None) -> str:
"""Render calendar bookings for an order."""
if not calendar_entries:
return ""
items = []
items = ""
for e in calendar_entries:
st = e.state or ""
pill = (
@@ -566,25 +669,29 @@ def _order_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'<li class="px-4 py-3 flex items-start justify-between text-sm">'
f'<div><div class="font-medium flex items-center gap-2">{e.name}'
f'<span class="inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium {pill}">'
f'{st.capitalize()}</span></div>'
f'<div class="text-xs text-stone-500">{ds}</div></div>'
f'<div class="ml-4 font-medium">\u00a3{e.cost or 0:.2f}</div></li>'
items += sexp(
'(li :class "px-4 py-3 flex items-start justify-between text-sm"'
' (div (div :class "font-medium flex items-center gap-2"'
' nm (span :class (str "inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium " pill) sc))'
' (div :class "text-xs text-stone-500" ds))'
' (div :class "ml-4 font-medium" cs))',
nm=e.name, pill=pill, sc=st.capitalize(), ds=ds, cs=f"\u00a3{e.cost or 0:.2f}",
)
return (
'<section class="mt-6 space-y-3">'
'<h2 class="text-base sm:text-lg font-semibold">Calendar bookings in this order</h2>'
f'<ul class="divide-y divide-stone-200 rounded-2xl border border-stone-200 bg-white/80">{"".join(items)}</ul></section>'
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=items,
)
def _order_main_html(order: Any, calendar_entries: list | None) -> str:
"""Main panel for single order detail."""
summary = _order_summary_html(order)
return f'<div class="max-w-full px-3 py-3 space-y-4">{summary}{_order_items_html(order)}{_order_calendar_items_html(calendar_entries)}</div>'
return sexp(
'(div :class "max-w-full px-3 py-3 space-y-4" (raw! s) (raw! oi) (raw! ci))',
s=summary, oi=_order_items_html(order), ci=_order_calendar_items_html(calendar_entries),
)
def _order_filter_html(order: Any, list_url: str, recheck_url: str,
@@ -592,20 +699,29 @@ 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'<a href="{pay_url}" class="inline-flex items-center px-3 py-2 text-xs sm:text-sm '
f'rounded-full border border-emerald-600 bg-emerald-600 text-white hover:bg-emerald-700 transition">'
f'<i class="fa fa-credit-card mr-2" aria-hidden="true"></i>Open payment page</a>'
) if status != "paid" else ""
return (
'<header class="mb-6 sm:mb-8 flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4">'
f'<div class="space-y-1"><p class="text-xs sm:text-sm text-stone-600">Placed {created} &middot; Status: {status}</p></div>'
'<div class="flex w-full sm:w-auto justify-start sm:justify-end gap-2">'
f'<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"></i>All orders</a>'
f'<form method="post" action="{recheck_url}" class="inline"><input type="hidden" name="csrf_token" value="{csrf_token}">'
f'<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"></i>Re-check status</button></form>'
f'{pay}</div></header>'
pay = ""
if status != "paid":
pay = sexp(
'(a :href pu :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")',
pu=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" info))'
' (div :class "flex w-full sm:w-auto justify-start sm:justify-end gap-2"'
' (a :href lu :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 ru :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)))',
info=f"Placed {created} \u00b7 Status: {status}",
lu=list_url, ru=recheck_url, csrf=csrf_token, pay=pay,
)
@@ -810,13 +926,11 @@ async def render_order_oob(ctx: dict, order: Any,
# ---------------------------------------------------------------------------
def _checkout_error_filter_html() -> str:
return (
'<header class="mb-6 sm:mb-8">'
'<h1 class="text-xl sm:text-2xl md:text-3xl font-semibold tracking-tight">'
'Checkout error</h1>'
'<p class="text-xs sm:text-sm text-stone-600">'
'We tried to start your payment with SumUp but hit a problem.</p>'
'</header>'
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."))',
)
@@ -824,25 +938,21 @@ 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'<p class="text-xs text-rose-800/80">'
f'Order ID: <span class="font-mono">#{order.id}</span></p>'
order_html = sexp(
'(p :class "text-xs text-rose-800/80"'
' "Order ID: " (span :class "font-mono" oid))',
oid=f"#{order.id}",
)
back_url = cart_url("/")
return (
'<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">'
f'<p class="font-medium">Something went wrong.</p>'
f'<p>{err_msg}</p>'
f'{order_html}'
'</div>'
'<div>'
f'<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"></i>'
'Back to cart</a>'
'</div>'
'</div>'
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 em)'
' (raw! oh))'
' (div (a :href bu :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")))',
em=err_msg, oh=order_html, bu=back_url,
)

View File

@@ -0,0 +1,454 @@
# Sexp Protocol and Tiered Client Architecture
**One server, three clients, progressive enhancement from HTML to native.**
---
## Overview
The same rose-ash application serves three tiers of client from the same route handlers and component trees:
```
┌─────────────────────────────┐
│ rose-ash server (Quart) │
│ │
│ Same sexp component tree │
│ Same data, same logic │
│ │
├──────────┬──────────┬────────┤
│ HTTPS │ HTTPS │ SEXPR │
│ HTML out │ sexp out │ native │
└────┬─────┴────┬─────┴───┬────┘
│ │ │
┌──────────▼──┐ ┌─────▼──────┐ ┌▼──────────────┐
│ Browser │ │ Browser + │ │ Rust client │
│ (vanilla) │ │ extension │ │ (native) │
│ │ │ │ │ │
│ HTML + HTMX │ │ sexpr.js │ │ sexp protocol │
│ Full CSS │ │ over HTTPS │ │ over QUIC │
│ ~200ms load │ │ ~80ms load │ │ ~20ms load │
└─────────────┘ └────────────┘ └────────────────┘
Tier 0 Tier 1 Tier 2
```
---
## Content Negotiation
The server does format selection on a single endpoint:
```python
@bp.get("/markets/")
async def markets():
# Same data, same component tree, always
data = await get_markets(g.s)
tree = sexp('(page :title "Markets" (each markets ...))', markets=data)
accept = request.headers.get("Accept", "")
if "application/x-sexpr" in accept:
# Tier 1 (extension) or Tier 2 (native): raw sexp
return Response(serialize(tree), content_type="application/x-sexpr")
# Tier 0: render to HTML for vanilla browsers
html = render_to_html(tree)
return Response(html, content_type="text/html")
```
One route. One component tree. The output format is the only thing that changes.
---
## Tier Comparison
| | Tier 0: Browser | Tier 1: Extension | Tier 2: Rust Client |
|---|---|---|---|
| URL | `https://rose-ash.com` | `https://rose-ash.com` | `sexpr://rose-ash.com` |
| Protocol | HTTPS | HTTPS | sexpr:// over QUIC |
| Wire format | HTML | sexp over HTTP | sexp native |
| Rendering | Browser DOM | sexpr.js → DOM | Rust → GPU |
| Component cache | Browser cache (URL-keyed) | IndexedDB (hash-keyed) | Disk (hash-keyed, pre-parsed AST) |
| Real-time | HTMX polling / SSE | WebSocket sexp mutations | Native bidirectional stream |
| Bundle size | HTMX 14KB + CSS | sexpr.js ~8KB | 5MB binary, zero runtime deps |
| Page load | ~200ms | ~80ms | ~20ms |
| Memory | ~200MB per tab | ~150MB per tab | ~20MB |
| Offline | Service worker (if built) | Component cache + manifest | Full local cache |
| AI agent | Parse HTML (painful) | Parse sexp (easy) | Native sexp (trivial) |
| Federation | N/A | AP via fetch | Native AP sexp stream |
---
## The Sexp Protocol (Tier 2)
### Why Not Just HTTP
HTTP is a request-response protocol with text headers and a body. Strip away the historical baggage and what you actually need is:
```
Client sends: method + path + metadata + optional body
Server sends: status + metadata + body
```
That's just an s-expression:
```scheme
;; Request
(GET "/markets/"
:accept "application/x-sexpr"
:auth "bearer tok_abc123"
:if-none-match "sha3-a1b2c3")
;; Response
(200
:content-hash "sha3-d4e5f6"
:cache :immutable
:components ("sha3-aaa" "sha3-bbb")
(page :title "Markets" :layout "main"
(section (h1 "Markets")
(each markets (lambda (m)
(use "vendor-card" :name (get m "name")))))))
```
No header/body split. No chunked transfer encoding. No `Content-Length` because the parser knows when the expression ends (balanced parens). No `Content-Type` because everything is sexp. No MIME, no multipart, no `Transfer-Encoding`, no `Connection: keep-alive` negotiation.
### What HTTP Gets Wrong That Sexp Fixes
#### 1. Headers are a bad key-value format
HTTP headers are case-insensitive, can be duplicated, have weird continuation rules, and are parsed separately from the body. In sexp, metadata is just keywords in the expression — same parser, same format, no special case.
#### 2. Request/response is too rigid
HTTP is strictly one request → one response. To get around this, we've bolted on WebSocket (separate protocol, upgrade handshake), Server-Sent Events (hack using chunked encoding), HTTP/2 server push (failed, being removed), and HTTP/3 QUIC streams (complex, still one-request-one-response per stream).
A sexp protocol is **bidirectional from the start**:
```scheme
;; Client opens connection, sends request
(GET "/feed/" :stream #t)
;; Server responds with initial content
(200 :stream #t
(page :title "Feed"
(div :id "feed" (p "Loading..."))))
;; Server pushes updates as they happen (same connection)
(push! (swap! "#feed" :prepend
(use "post-card" :title "New post" :author "alice")))
;; Client sends an action (same connection)
(POST "/like/" :body (:post-id 123))
;; Server responds with mutation
(push! (swap! "#like-count-123" :inner "43"))
;; Client navigates (same connection, no new handshake)
(GET "/markets/")
;; Server responds
(200 (page :title "Markets" ...))
```
One persistent connection. Requests, responses, and pushes are all sexp expressions on the same stream. No protocol upgrade, no separate WebSocket connection, no polling.
#### 3. Caching is overcomplicated
HTTP caching: `Cache-Control`, `ETag`, `If-None-Match`, `If-Modified-Since`, `Vary`, `Age`, `Expires`, `Last-Modified`, `s-maxage`, `stale-while-revalidate`... a dozen headers with complex interaction rules.
With content-addressed sexp:
```scheme
;; Request with known hash
(GET "/markets/" :have "sha3-a1b2c3")
;; If unchanged:
(304)
;; If changed:
(200 :hash "sha3-d4e5f6" (page ...))
```
One field. The hash *is* the cache key, the ETag, and the content address.
Components take this further — the client sends what it already has:
```scheme
(GET "/markets/"
:have-components ("sha3-aaa" "sha3-bbb" "sha3-ccc"))
;; Server only sends components the client is missing
(200
:new-components (
(component "vendor-card" :hash "sha3-ddd" (params ...) (template ...)))
(page ...))
```
#### 4. Status codes are arbitrary numbers
```scheme
(ok (page ...))
(redirect :to "/new-location/" :permanent #t)
(not-found :message "Page does not exist")
(error :code "auth-required" :message "Please log in"
:login-url "sexpr://rose-ash.com/login/")
```
The status *is* the expression. Machines pattern-match on the head symbol. Humans read it. AI agents understand it without a lookup table.
#### 5. Forms and file uploads are a mess
HTTP: `application/x-www-form-urlencoded` or `multipart/form-data` with boundary strings, MIME parts, content-disposition headers.
Sexp:
```scheme
(POST "/submit/"
:body (
:username "alice"
:email "alice@example.com"
:avatar (file :name "photo.jpg" :type "image/jpeg" :data <binary>)))
```
Structured data with inline binary. One format.
#### 6. The protocol is self-describing
HTTP has no introspection. You need OpenAPI/Swagger specs bolted on separately.
```scheme
(GET "/__schema/")
;; Response: the API describes itself
(schema
(endpoint "/" :method GET
:returns (page)
:params (:stream bool))
(endpoint "/markets/" :method GET
:returns (page :contains (list (use "vendor-card"))))
(endpoint "/like/" :method POST
:params (:post-id int)
:returns (mutation)))
```
An AI agent hitting `/__schema/` learns the entire API surface as parseable sexp. No separate OpenAPI doc. The schema *is* the API.
### Protocol Stack
```
┌─────────────────────────────────────────┐
│ Application: sexp documents, mutations │
│ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │
│ Session: bidirectional sexp stream │
│ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │
│ Transport: QUIC (or TCP+TLS) │
│ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │
│ Network: IP │
└─────────────────────────────────────────┘
```
QUIC is ideal — multiplexed streams, built-in TLS, connection migration. Each sexp expression gets its own QUIC stream. Requests and pushes multiplex without head-of-line blocking. Connection survives network changes (mobile → wifi).
---
## Rust Client Architecture
```
┌─────────────────────────────────────┐
│ sexpr-client (Rust) │
│ │
│ ┌──────────┐ ┌─────────────────┐ │
│ │ Parser │ │ Component │ │
│ │ (zero- │ │ Cache │ │
│ │ copy) │ │ (SHA3 → AST) │ │
│ └──────────┘ └─────────────────┘ │
│ ┌──────────┐ ┌─────────────────┐ │
│ │ Layout │ │ Network │ │
│ │ Engine │ │ (tokio + QUIC) │ │
│ └──────────┘ └─────────────────┘ │
│ ┌──────────────────────────────┐ │
│ │ Renderer (wgpu / iced) │ │
│ └──────────────────────────────┘ │
└─────────────────────────────────────┘
```
**Why Rust makes this fast:**
- **Parser**: sexp parsing is trivial recursive descent. Nanoseconds per node. 100-1000x faster than JS.
- **Renderer**: Skip the DOM entirely. Render directly to GPU-backed surface via `wgpu` or `iced`.
- **Networking**: `reqwest` + `tokio` with connection pooling. Component manifests fetched in parallel.
- **Component cache**: Pre-parsed ASTs on disk, content-addressed. No parse step on cache hit.
- **Memory**: No GC pauses. 10-50MB where a browser tab uses 200-500MB.
**What you skip by not being a browser:**
- No HTML parser (5-10ms per page)
- No CSS cascade resolution (the most expensive part of browser rendering)
- No DOM construction (sexp AST *is* the tree)
- No JavaScript engine (logic in the sexp evaluator, compiled Rust)
- No security sandbox overhead (no arbitrary JS execution)
- No 2000+ web platform APIs you don't use
Page load: network (~50ms) → parse (~0.1ms) → layout (~2ms) → paint (~3ms) = **under 60ms**.
---
## Fallback Gateway
For users without the extension or native client, a gateway translates:
```
sexpr://rose-ash.com/markets/
→ Gateway fetches sexp from server
→ Renders to HTML
→ Serves to browser at https://gateway.rose-ash.com/markets/
```
The regular web always works. The sexp protocol is an acceleration layer, not a requirement.
---
## Implementation Plan
### Phase 1: Content Negotiation (Quart)
Add `Accept` header handling to existing Quart routes.
- Check for `application/x-sexpr` in `Accept` header
- If present: serialize the sexp tree and return directly (skip HTML render)
- If absent: render to HTML as today
- Add `Vary: Accept` header to responses
**Files:**
- `shared/infrastructure/factory.py` — middleware for content negotiation
- `shared/sexp/serialize.py` — canonical sexp serializer (deterministic output for caching/signing)
**Result:** Existing routes serve sexp to any client that asks. Zero changes to route logic.
### Phase 2: sexpr.js Client Library
Build the browser-side JS runtime (see `sexpr-js-runtime-plan.md` for full details).
- Parser + renderer: `parse()` → AST → `renderToDOM()`
- Mutation engine: `swap!`, `batch!`, `class!`, `request!`
- Component registry with localStorage cache (content-addressed)
- `<script src="sexpr.js">` drop-in for any page
**Result:** Any page can opt into client-side sexp rendering.
### Phase 3: Browser Extension
Package sexpr.js as a WebExtension that intercepts `sexpr://` URLs.
- Register `sexpr://` protocol handler
- Intercept navigation → fetch via HTTPS with `Accept: application/x-sexpr`
- Parse response → render to DOM in current tab
- Component cache in extension storage (IndexedDB, content-addressed)
- Address bar shows `sexpr://rose-ash.com/markets/`
**Tech:** WebExtension API (Firefox + Chrome), JS/TS.
**Result:** Users install extension, navigate to `sexpr://` URLs, get sexp-rendered pages.
### Phase 4: Sexp Protocol Specification
Define the native protocol:
- **Framing**: length-prefixed sexp expressions over QUIC streams
- **Request format**: `(METHOD path :keyword value ... body)`
- **Response format**: `(status :keyword value ... body)`
- **Push format**: `(push! mutation)` — server-initiated, any time
- **Stream lifecycle**: open on first request, multiplex subsequent requests
- **Caching**: content-hash based (`:have "sha3-..."`, `:have-components (...)`)
- **Authentication**: token in request keywords (`:auth "bearer ..."`)
- **Canonical serialization**: deterministic output rules for signing
- **Schema introspection**: `GET /__schema/` returns API description as sexp
Publish as a specification document.
### Phase 5: Rust Protocol Server
Build a QUIC server that speaks the sexp protocol alongside Hypercorn:
- Listen on a separate port (e.g., 4433 for QUIC)
- Parse sexp requests, route to the same handler logic as Quart
- Bidirectional stream: pushes, requests, responses on one connection
- Component manifest endpoint: serve component hashes + definitions
- Connection pooling, TLS via rustls
**Tech:** Rust, `quinn` (QUIC), `tokio`, sexp parser crate.
**Architecture option:** The Rust server could proxy to Quart for data/logic and handle only the protocol layer. Or it could run the full application logic natively (long-term, after porting route handlers to Rust).
**Files:**
- `sexpr-server/` — new Rust crate alongside the Python services
- `sexpr-server/src/protocol.rs` — framing, parsing, routing
- `sexpr-server/src/quic.rs` — QUIC listener + stream management
### Phase 6: Rust Native Client
Build the standalone sexp document viewer:
- QUIC client connecting to sexpr:// servers
- Sexp parser (zero-copy, arena-allocated AST)
- Component cache on disk (SQLite or filesystem, SHA3-keyed)
- Layout engine (flexbox subset — enough for document layout)
- GPU renderer via `wgpu` or `iced`
- Text rendering via `cosmic-text` or `fontdue`
- Input handling: keyboard, mouse, touch
- Bidirectional stream: real-time mutations, navigation without reconnection
**Tech:** Rust, `quinn`, `wgpu`/`iced`, `cosmic-text`, `tokio`.
**Files:**
- `sexpr-client/` — new Rust crate
- `sexpr-client/src/parser.rs` — zero-copy sexp parser
- `sexpr-client/src/cache.rs` — content-addressed component cache
- `sexpr-client/src/layout.rs` — layout engine
- `sexpr-client/src/render.rs` — GPU renderer
### Phase 7: Fallback Gateway
HTTP proxy that translates for browsers without extension/client:
- Accepts regular HTTPS requests
- Fetches sexp from the server (internal)
- Renders to HTML
- Serves to browser
- Adds `<link rel="alternate" type="application/x-sexpr" href="sexpr://...">` for discovery
**Result:** `https://rose-ash.com` always works. `sexpr://rose-ash.com` is an acceleration layer.
---
## Migration Path
Each tier builds on the last. No tier breaks the others:
1. **Today**: Tier 0 works. Ship HTML. Done.
2. **Add `Accept` header check** (Phase 1): Same routes now serve sexp to clients that ask. Tier 0 unchanged.
3. **Build sexpr.js** (Phase 2): Browser extension or `<script>` tag. Tier 1 works.
4. **Build extension** (Phase 3): `sexpr://` URLs work in browser. Tier 1 complete.
5. **Build Rust server** (Phase 5): Native protocol alongside HTTPS. Tier 2 infrastructure ready.
6. **Build Rust client** (Phase 6): `sexpr://` URLs work natively. Tier 2 complete.
A user with the Rust client visits the same URL as someone with Firefox. The server serves both from the same handler. If the Rust client is offline, the user opens `https://rose-ash.com` in a browser and gets the HTML version. Same content, same components, same data.
---
## Cooperative Angle
- Members who install the Rust client get the fast native experience
- Visitors browsing the public site get standard HTML — no barrier to entry
- Federated peers negotiate the best format they support
- AI agents get structured sexp via any tier
- The 5MB Rust binary replaces a 500MB browser for accessing the cooperative platform
- Auto-updates via content-addressed components — no app store gatekeeping
---
## Relationship to Other Plans
- **sexpr-js-runtime-plan.md** — Phase 2 of this plan; the JS library that powers Tier 1
- **ghost-removal-plan.md** — posts must be sexp before content negotiation adds value
- **sexpr-ai-integration.md** — AI agents benefit from all three tiers
- **sexpr-activitypub-extension.md** — federation over the native protocol (Tier 2 peers speak AP natively)

View File

@@ -90,10 +90,11 @@ def create_app() -> "Quart":
url_prefix="/<slug>",
)
# Calendars nested under post slug: /<slug>/calendars/...
# Calendars nested under post slug: /<slug>/<calendar_slug>/...
# Listing stays at /<slug>/calendars/, individual at /<slug>/<calendar_slug>/
app.register_blueprint(
register_calendars(),
url_prefix="/<slug>/calendars",
url_prefix="/<slug>",
)
# Markets nested under post slug: /<slug>/markets/...

View File

@@ -32,7 +32,7 @@ def register():
# ---------- Pages ----------
@bp.get("/")
@bp.get("/calendars/")
@cache_page(tag="calendars")
async def home(**kwargs):
from shared.sexp.page import get_template_context
@@ -46,7 +46,7 @@ def register():
return await make_response(html)
@bp.post("/new/")
@bp.post("/calendars/new/")
@require_admin
@clear_cache(tag="calendars", tag_scope="all")
async def create_calendar(**kwargs):

View File

@@ -58,7 +58,7 @@ def register():
)
for entry in entries:
entry_path = (
f"/{post_slug}/calendars/{entry.calendar_slug}/"
f"/{post_slug}/{entry.calendar_slug}/"
f"{entry.start_at.year}/{entry.start_at.month}/"
f"{entry.start_at.day}/entries/{entry.id}/"
)
@@ -87,7 +87,7 @@ def register():
g.s, container_type, container_id,
)
for cal in calendars:
href = events_url(f"/{post_slug}/calendars/{cal.slug}/")
href = events_url(f"/{post_slug}/{cal.slug}/")
html_parts.append(render_sexp(
'(~calendar-link-nav :href href :name name :nav-class nav-class)',
href=href, name=cal.name, **{"nav-class": nav_class},

View File

@@ -716,8 +716,9 @@ def _day_row_html(ctx: dict, entry) -> str:
'(td :class "p-2 align-top w-1/6" (div :class "text-xs font-medium"'
' (a :href h :class pc :hx-get h :hx-target "#main-panel" :hx-select "#main-panel"'
' :hx-swap "outerHTML" :hx-push-url "true" sn)'
' (span :class "text-stone-600 font-normal" (str "(" ts te ")"))))',
h=slot_href, pc=pill_cls, sn=slot.name, ts=time_start, te=time_end,
' (span :class "text-stone-600 font-normal" (raw! time-str))))',
h=slot_href, pc=pill_cls, sn=slot.name,
**{"time-str": f"({time_start}{time_end})"},
)
else:
start = entry.start_at.strftime("%H:%M") if entry.start_at else ""
@@ -1250,7 +1251,7 @@ def _entry_card_html(entry, page_info: dict, pending_tickets: dict,
day_href = ""
if page_slug and entry.start_at:
day_href = events_url_fn(f"/{page_slug}/calendars/{entry.calendar_slug}/day/{entry.start_at.strftime('%Y/%-m/%-d')}/")
day_href = events_url_fn(f"/{page_slug}/{entry.calendar_slug}/day/{entry.start_at.strftime('%Y/%-m/%-d')}/")
entry_href = f"{day_href}entries/{entry.id}/" if day_href else ""
# Title (linked or plain)
@@ -1332,7 +1333,7 @@ def _entry_card_tile_html(entry, page_info: dict, pending_tickets: dict,
day_href = ""
if page_slug and entry.start_at:
day_href = events_url_fn(f"/{page_slug}/calendars/{entry.calendar_slug}/day/{entry.start_at.strftime('%Y/%-m/%-d')}/")
day_href = events_url_fn(f"/{page_slug}/{entry.calendar_slug}/day/{entry.start_at.strftime('%Y/%-m/%-d')}/")
entry_href = f"{day_href}entries/{entry.id}/" if day_href else ""
# Title
@@ -2762,7 +2763,7 @@ def render_post_nav_entries_oob(associated_entries, calendars, post) -> str:
if has_entries:
for entry in associated_entries.entries:
entry_path = (
f"/{slug}/calendars/{entry.calendar_slug}/"
f"/{slug}/{entry.calendar_slug}/"
f"{entry.start_at.year}/{entry.start_at.month}/{entry.start_at.day}/"
f"entries/{entry.id}/"
)
@@ -2781,7 +2782,7 @@ def render_post_nav_entries_oob(associated_entries, calendars, post) -> str:
if calendars:
for cal in calendars:
cs = getattr(cal, "slug", "")
local_href = events_url(f"/{slug}/calendars/{cs}/")
local_href = events_url(f"/{slug}/{cs}/")
items += sexp(
'(a :href lh :class nb'
' (i :class "fa fa-calendar" :aria-hidden "true")'

View File

@@ -7,7 +7,7 @@
{# Left: event info #}
<div class="flex-1 min-w-0">
{% if page_slug %}
{% set day_href = events_url('/' ~ page_slug ~ '/calendars/' ~ entry.calendar_slug ~ '/day/' ~ entry.start_at.strftime('%Y/%-m/%-d') ~ '/') %}
{% set day_href = events_url('/' ~ page_slug ~ '/' ~ entry.calendar_slug ~ '/day/' ~ entry.start_at.strftime('%Y/%-m/%-d') ~ '/') %}
{% else %}
{% set day_href = '' %}
{% endif %}

View File

@@ -4,7 +4,7 @@
{% set page_title = pi.get('title') %}
<article class="rounded-xl bg-white shadow-sm border border-stone-200 overflow-hidden">
{% if page_slug %}
{% set day_href = events_url('/' ~ page_slug ~ '/calendars/' ~ entry.calendar_slug ~ '/day/' ~ entry.start_at.strftime('%Y/%-m/%-d') ~ '/') %}
{% set day_href = events_url('/' ~ page_slug ~ '/' ~ entry.calendar_slug ~ '/day/' ~ entry.start_at.strftime('%Y/%-m/%-d') ~ '/') %}
{% else %}
{% set day_href = '' %}
{% endif %}

View File

@@ -6,7 +6,7 @@
<div class="flex flex-col sm:flex-row sm:items-start justify-between gap-3">
{# Left: event info #}
<div class="flex-1 min-w-0">
{% set day_href = events_url('/' ~ page_slug ~ '/calendars/' ~ entry.calendar_slug ~ '/day/' ~ entry.start_at.strftime('%Y/%-m/%-d') ~ '/') %}
{% set day_href = events_url('/' ~ page_slug ~ '/' ~ entry.calendar_slug ~ '/day/' ~ entry.start_at.strftime('%Y/%-m/%-d') ~ '/') %}
{% set entry_href = day_href ~ 'entries/' ~ entry.id ~ '/' %}
<a href="{{ entry_href }}" class="hover:text-emerald-700">
<h2 class="text-lg font-semibold text-stone-900">{{ entry.name }}</h2>

View File

@@ -3,7 +3,7 @@
{% set page_slug = pi.get('slug', post.slug) %}
{% set page_title = pi.get('title') %}
<article class="rounded-xl bg-white shadow-sm border border-stone-200 overflow-hidden">
{% set day_href = events_url('/' ~ page_slug ~ '/calendars/' ~ entry.calendar_slug ~ '/day/' ~ entry.start_at.strftime('%Y/%-m/%-d') ~ '/') %}
{% set day_href = events_url('/' ~ page_slug ~ '/' ~ entry.calendar_slug ~ '/day/' ~ entry.start_at.strftime('%Y/%-m/%-d') ~ '/') %}
{% set entry_href = day_href ~ 'entries/' ~ entry.id ~ '/' %}
<div class="p-3">
<a href="{{ entry_href }}" class="hover:text-emerald-700">

View File

@@ -1,11 +1,11 @@
{% import 'macros/links.html' as links %}
<div class="relative nav-group">
<a href="{{ events_url('/' + post.slug + '/calendars/') }}" class="{{styles.nav_button}}">
calendars
<a href="{{ events_url('/' + post.slug + '/calendar/') }}" class="{{styles.nav_button}}">
calendar
</a>
</div>
<div class="relative nav-group">
<a href="{{ events_url('/' + post.slug + '/markets/') }}" class="{{styles.nav_button}}">
<a href="{{ market_url('/' + post.slug + '/') }}" class="{{styles.nav_button}}">
markets
</a>
</div>

View File

@@ -5,7 +5,7 @@
{% call nav_entries_oob(has_items) %}
{% if associated_entries and associated_entries.entries %}
{% for entry in associated_entries.entries %}
{% set _entry_path = '/' + post.slug + '/calendars/' + entry.calendar_slug + '/' + entry.start_at.year|string + '/' + entry.start_at.month|string + '/' + entry.start_at.day|string + '/entries/' + entry.id|string + '/' %}
{% set _entry_path = '/' + post.slug + '/' +entry.calendar_slug + '/' + entry.start_at.year|string + '/' + entry.start_at.month|string + '/' + entry.start_at.day|string + '/entries/' + entry.id|string + '/' %}
<a
href="{{ events_url(_entry_path) }}"
class="{{styles.nav_button_less_pad}}">
@@ -22,7 +22,7 @@
{% endif %}
{% if calendars %}
{% for calendar in calendars %}
{% set local_href=events_url('/' + post.slug + '/calendars/' + calendar.slug + '/') %}
{% set local_href=events_url('/' + post.slug + '/' +calendar.slug + '/') %}
<a
href="{{ local_href }}"
class="{{styles.nav_button_less_pad}}">

View File

@@ -11,7 +11,7 @@
<div class="flex gap-2 px-2">
{% for entry in widget_entries %}
{% set _post_slug = slug_map.get(post_id, '') %}
{% set _entry_path = '/' + _post_slug + '/calendars/' + entry.calendar_slug + '/' + entry.start_at.year|string + '/' + entry.start_at.month|string + '/' + entry.start_at.day|string + '/entries/' + entry.id|string + '/' %}
{% set _entry_path = '/' + _post_slug + '/' + entry.calendar_slug + '/' + entry.start_at.year|string + '/' + entry.start_at.month|string + '/' + entry.start_at.day|string + '/entries/' + entry.id|string + '/' %}
<a
href="{{ events_url(_entry_path) }}"
class="flex flex-col gap-1 px-3 py-2 bg-stone-50 hover:bg-stone-100 rounded border border-stone-200 transition text-sm whitespace-nowrap flex-shrink-0 min-w-[180px]">

View File

@@ -23,10 +23,10 @@ def _social_nav_html(actor: Any) -> str:
if not actor:
choose_url = url_for("identity.choose_username_form")
return (
'<nav class="flex gap-3 text-sm items-center">'
f'<a href="{choose_url}" class="px-2 py-1 rounded hover:bg-stone-200 font-bold">Choose username</a>'
'</nav>'
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 = ['<nav class="flex gap-3 text-sm items-center flex-wrap">']
parts = []
for endpoint, label in links:
href = url_for(endpoint)
bold = " font-bold" if request.path == href else ""
parts.append(f'<a href="{href}" class="px-2 py-1 rounded hover:bg-stone-200{bold}">{label}</a>')
parts.append(sexp(
'(a :href href :class cls (raw! label))',
href=href,
cls=f"px-2 py-1 rounded hover:bg-stone-200{bold}",
label=label,
))
# Notifications with live badge
notif_url = url_for("social.notifications")
notif_count_url = url_for("social.notification_count")
notif_bold = " font-bold" if request.path == notif_url else ""
parts.append(
f'<a href="{notif_url}" class="px-2 py-1 rounded hover:bg-stone-200 relative{notif_bold}">Notifications'
f'<span hx-get="{notif_count_url}" hx-trigger="load, every 30s" hx-swap="innerHTML"'
f' class="absolute -top-2 -right-3 text-xs bg-red-500 text-white rounded-full px-1 empty:hidden"></span></a>'
)
parts.append(sexp(
'(a :href href :class cls "Notifications"'
' (span :hx-get count-url :hx-trigger "load, every 30s" :hx-swap "innerHTML"'
' :class "absolute -top-2 -right-3 text-xs bg-red-500 text-white rounded-full px-1 empty:hidden"))',
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="{profile_url}" class="px-2 py-1 rounded hover:bg-stone-200">@{actor.preferred_username}</a>')
parts.append('</nav>')
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'<title>{escape(title)}</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 = "&#x2665;"
like_icon = "\u2665"
else:
like_action = url_for("social.like")
like_cls = "hover:text-red-500"
like_icon = "&#x2661;"
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'<a href="{reply_url}" class="hover:text-stone-700">Reply</a>' 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'<div class="flex items-center gap-4 mt-3 text-sm text-stone-500">'
f'<form hx-post="{like_action}" hx-target="{target}" hx-swap="innerHTML">'
f'<input type="hidden" name="object_id" value="{oid}">'
f'<input type="hidden" name="author_inbox" value="{ainbox}">'
f'<input type="hidden" name="csrf_token" value="{csrf}">'
f'<button type="submit" class="flex items-center gap-1 {like_cls}"><span>{like_icon}</span> {lcount}</button></form>'
f'<form hx-post="{boost_action}" hx-target="{target}" hx-swap="innerHTML">'
f'<input type="hidden" name="object_id" value="{oid}">'
f'<input type="hidden" name="author_inbox" value="{ainbox}">'
f'<input type="hidden" name="csrf_token" value="{csrf}">'
f'<button type="submit" class="flex items-center gap-1 {boost_cls}"><span>&#x21BB;</span> {bcount}</button></form>'
f'{reply_html}</div>'
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'<div class="text-sm text-stone-500 mb-2">Boosted by {escape(boosted_by)}</div>' 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'<img src="{actor_icon}" alt="" class="w-10 h-10 rounded-full">'
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'<div class="w-10 h-10 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-sm">{initial}</div>'
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'<details class="mt-2"><summary class="text-stone-500 cursor-pointer">CW: {escape(summary)}</summary>'
f'<div class="mt-2 prose prose-sm prose-stone max-w-none">{content}</div></details>'
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'<div class="mt-2 prose prose-sm prose-stone max-w-none">{content}</div>'
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'<a href="{url}" target="_blank" rel="noopener" class="text-sm text-stone-400 hover:underline mt-1 inline-block">original</a>'
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'<div id="interactions-{safe_id}">{_interaction_buttons_html(item, actor)}</div>'
interactions_html = sexp(
'(div :id id (raw! buttons))',
id=f"interactions-{safe_id}",
buttons=_interaction_buttons_html(item, actor),
)
return (
f'<article class="bg-white rounded-lg shadow-sm border border-stone-200 p-4 mb-4">'
f'{boost_html}'
f'<div class="flex items-start gap-3">{avatar}'
f'<div class="flex-1 min-w-0">'
f'<div class="flex items-baseline gap-2">'
f'<span class="font-semibold text-stone-900">{escape(actor_name)}</span>'
f'<span class="text-sm text-stone-500">@{escape(actor_username)}{domain_html}</span>'
f'<span class="text-sm text-stone-400 ml-auto">{time_html}</span></div>'
f'{content_html}{original_html}{interactions_html}</div></div></article>'
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'<div hx-get="{next_url}" hx-trigger="revealed" hx-swap="outerHTML"></div>')
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'<img src="{icon_url}" alt="" class="w-12 h-12 rounded-full">'
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'<div class="w-12 h-12 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold">{initial}</div>'
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'<a href="{url_for("social.actor_timeline", id=aid)}" class="font-semibold text-stone-900 hover:underline">{escape(display_name)}</a>'
elif list_type == "search" and aid:
name_html = f'<a href="{url_for("social.actor_timeline", id=aid)}" class="font-semibold text-stone-900 hover:underline">{escape(display_name)}</a>'
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'<a href="https://{domain}/@{username}" target="_blank" rel="noopener" class="font-semibold text-stone-900 hover:underline">{escape(display_name)}</a>'
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'<div class="text-sm text-stone-600 mt-1 truncate">{summary}</div>' 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'<div class="flex-shrink-0"><form method="post" action="{url_for("social.unfollow")}"'
f' hx-post="{url_for("social.unfollow")}" hx-target="closest article" hx-swap="outerHTML">'
f'<input type="hidden" name="csrf_token" value="{csrf}">'
f'<input type="hidden" name="actor_url" value="{actor_url}">'
f'<button type="submit" class="text-sm border border-stone-300 rounded px-3 py-1 hover:bg-stone-100">Unfollow</button></form></div>'
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'<div class="flex-shrink-0"><form method="post" action="{url_for("social.follow")}"'
f' hx-post="{url_for("social.follow")}" hx-target="closest article" hx-swap="outerHTML">'
f'<input type="hidden" name="csrf_token" value="{csrf}">'
f'<input type="hidden" name="actor_url" value="{actor_url}">'
f'<button type="submit" class="text-sm bg-stone-800 text-white rounded px-3 py-1 hover:bg-stone-700">{label}</button></form></div>'
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'<article class="bg-white rounded-lg shadow-sm border border-stone-200 p-4 mb-3 flex items-center gap-4" id="actor-{safe_id}">'
f'{avatar}<div class="flex-1 min-w-0">{name_html}'
f'<div class="text-sm text-stone-500">@{escape(username)}@{escape(domain)}</div>'
f'{summary_html}</div>{button_html}</article>'
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'<div hx-get="{next_url}" hx-trigger="revealed" hx-swap="outerHTML"></div>')
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'<div hx-get="{next_url}" hx-trigger="revealed" hx-swap="outerHTML"></div>')
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'<img src="{from_icon}" alt="" class="w-8 h-8 rounded-full">'
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'<div class="w-8 h-8 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-xs">{initial}</div>'
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'<div class="text-sm text-stone-500 mt-1 truncate">{escape(preview)}</div>' 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'<div class="bg-white rounded-lg shadow-sm border border-stone-200 p-4{border}">'
f'<div class="flex items-start gap-3">{avatar}<div class="flex-1">'
f'<div class="text-sm"><span class="font-semibold">{escape(from_name)}</span>'
f' <span class="text-stone-500">@{escape(from_username)}{domain_html}</span>'
f' <span class="text-stone-600">{action}</span></div>'
f'{preview_html}<div class="text-xs text-stone-400 mt-1">{time_html}</div></div></div></div>'
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'<div class="bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4">{error}</div>' if error else ""
content = (
f'<div class="py-8 max-w-md mx-auto"><h1 class="text-2xl font-bold mb-6">Sign in</h1>{error_html}'
f'<form method="post" action="{action}" class="space-y-4">'
f'<input type="hidden" name="csrf_token" value="{csrf}">'
f'<div><label for="email" class="block text-sm font-medium mb-1">Email address</label>'
f'<input type="email" name="email" id="email" value="{escape(email)}" required autofocus'
f' class="w-full border border-stone-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-stone-500"></div>'
f'<button type="submit" class="w-full bg-stone-800 text-white py-2 px-4 rounded hover:bg-stone-700 transition">'
f'Send magic link</button></form></div>'
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="<title>Login \u2014 Rose Ash</title>")
meta_html='<title>Login \u2014 Rose Ash</title>')
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'<div class="bg-yellow-50 border border-yellow-200 text-yellow-700 p-3 rounded mt-4">'
f'{escape(email_error)}</div>'
)
content = (
'<div class="py-8 max-w-md mx-auto text-center">'
'<h1 class="text-2xl font-bold mb-4">Check your email</h1>'
f'<p class="text-stone-600 mb-2">We sent a sign-in link to <strong>{escape(email)}</strong>.</p>'
'<p class="text-stone-500 text-sm">Click the link in the email to sign in. The link expires in 15 minutes.</p>'
f'{error_html}</div>'
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'<a href="{compose_url}" class="bg-stone-800 text-white px-4 py-2 rounded hover:bg-stone-700">Compose</a>'
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'<div class="flex items-center justify-between mb-6">'
f'<h1 class="text-2xl font-bold">{label} Timeline</h1>{compose_html}</div>'
f'<div id="timeline">{timeline_html}</div>'
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'<input type="hidden" name="in_reply_to" value="{escape(reply_to)}">'
f'<div class="text-sm text-stone-500">Replying to <span class="font-mono">{escape(reply_to)}</span></div>'
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'<h1 class="text-2xl font-bold mb-6">Compose</h1>'
f'<form method="post" action="{action}" class="space-y-4">'
f'<input type="hidden" name="csrf_token" value="{csrf}">{reply_html}'
f'<textarea name="content" rows="6" maxlength="5000" required'
f' class="w-full border border-stone-300 rounded-lg p-3 focus:outline-none focus:ring-2 focus:ring-stone-500"'
f' placeholder="What\'s on your mind?"></textarea>'
f'<div class="flex items-center justify-between">'
f'<select name="visibility" class="border border-stone-300 rounded px-3 py-1.5 text-sm">'
f'<option value="public">Public</option><option value="unlisted">Unlisted</option>'
f'<option value="followers">Followers only</option></select>'
f'<button type="submit" class="bg-stone-800 text-white px-6 py-2 rounded hover:bg-stone-700">Publish</button></div></form>'
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'<p class="text-sm text-stone-500 mb-4">{total} result{s} for <strong>{escape(query)}</strong></p>'
info_html = sexp(
'(p :class "text-sm text-stone-500 mb-4" (raw! t))',
t=f"{total} result{s} for <strong>{escape(query)}</strong>",
)
elif query:
info_html = f'<p class="text-stone-500 mb-4">No results found for <strong>{escape(query)}</strong></p>'
info_html = sexp(
'(p :class "text-stone-500 mb-4" (raw! t))',
t=f"No results found for <strong>{escape(query)}</strong>",
)
content = (
f'<h1 class="text-2xl font-bold mb-6">Search</h1>'
f'<form method="get" action="{search_url}" class="mb-6"'
f' hx-get="{search_page_url}" hx-target="#search-results" hx-push-url="{search_url}">'
f'<div class="flex gap-2"><input type="text" name="q" value="{escape(query)}"'
f' class="flex-1 border border-stone-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-stone-500"'
f' placeholder="Search users or @user@instance.tld">'
f'<button type="submit" class="bg-stone-800 text-white px-6 py-2 rounded hover:bg-stone-700">Search</button></div></form>'
f'{info_html}<div id="search-results">{results_html}</div>'
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'<h1 class="text-2xl font-bold mb-6">Following <span class="text-stone-400 font-normal">({total})</span></h1>'
f'<div id="actor-list">{items_html}</div>'
content = sexp(
'(h1 :class "text-2xl font-bold mb-6" "Following "'
' (span :class "text-stone-400 font-normal" (raw! count-str)))'
'(div :id "actor-list" (raw! items))',
**{"count-str": f"({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'<h1 class="text-2xl font-bold mb-6">Followers <span class="text-stone-400 font-normal">({total})</span></h1>'
f'<div id="actor-list">{items_html}</div>'
content = sexp(
'(h1 :class "text-2xl font-bold mb-6" "Followers "'
' (span :class "text-stone-400 font-normal" (raw! count-str)))'
'(div :id "actor-list" (raw! items))',
**{"count-str": f"({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'<img src="{icon_url}" alt="" class="w-16 h-16 rounded-full">'
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'<div class="w-16 h-16 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-xl">{initial}</div>'
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'<div class="text-sm text-stone-600 mt-2">{summary}</div>' 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'<div class="flex-shrink-0"><form method="post" action="{url_for("social.unfollow")}">'
f'<input type="hidden" name="csrf_token" value="{csrf}">'
f'<input type="hidden" name="actor_url" value="{actor_url}">'
f'<button type="submit" class="border border-stone-300 rounded px-4 py-2 hover:bg-stone-100">Unfollow</button></form></div>'
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'<div class="flex-shrink-0"><form method="post" action="{url_for("social.follow")}">'
f'<input type="hidden" name="csrf_token" value="{csrf}">'
f'<input type="hidden" name="actor_url" value="{actor_url}">'
f'<button type="submit" class="bg-stone-800 text-white rounded px-4 py-2 hover:bg-stone-700">Follow</button></form></div>'
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'<div class="bg-white rounded-lg shadow-sm border border-stone-200 p-6 mb-6">'
f'<div class="flex items-center gap-4">{avatar}'
f'<div class="flex-1"><h1 class="text-xl font-bold">{escape(display_name)}</h1>'
f'<div class="text-stone-500">@{escape(remote_actor.preferred_username)}@{escape(remote_actor.domain)}</div>'
f'{summary_html}</div>{follow_html}</div></div>'
f'<div id="timeline">{timeline_html}</div>'
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 = '<p class="text-stone-500">No notifications yet.</p>'
notif_html = sexp('(p :class "text-stone-500" "No notifications yet.")')
else:
notif_html = '<div class="space-y-2">' + "".join(_notification_html(n) for n in notifications) + '</div>'
notif_html = sexp(
'(div :class "space-y-2" (raw! items))',
items="".join(_notification_html(n) for n in notifications),
)
content = f'<h1 class="text-2xl font-bold mb-6">Notifications</h1>{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'<div class="bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4">{error}</div>' 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'<div class="py-8 max-w-md mx-auto">'
f'<h1 class="text-2xl font-bold mb-2">Choose your username</h1>'
f'<p class="text-stone-600 mb-6">This will be your identity on the fediverse: '
f'<strong>@username@{escape(ap_domain)}</strong></p>'
f'{error_html}'
f'<form method="post" class="space-y-4">'
f'<input type="hidden" name="csrf_token" value="{csrf}">'
f'<div><label for="username" class="block text-sm font-medium mb-1">Username</label>'
f'<div class="flex items-center"><span class="text-stone-400 mr-1">@</span>'
f'<input type="text" name="username" id="username" value="{escape(username)}"'
f' pattern="[a-z][a-z0-9_]{{2,31}}" minlength="3" maxlength="32" required autocomplete="off"'
f' class="flex-1 border border-stone-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-stone-500"'
f' hx-get="{check_url}" hx-trigger="keyup changed delay:300ms" hx-target="#username-status" hx-include="[name=\'username\']">'
f'</div><div id="username-status" class="text-sm mt-1"></div>'
f'<p class="text-xs text-stone-400 mt-1">3-32 characters. Lowercase letters, numbers, underscores. Must start with a letter.</p></div>'
f'<button type="submit" class="w-full bg-stone-800 text-white py-2 px-4 rounded hover:bg-stone-700 transition">Claim username</button></form></div>'
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,50 @@ 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'<p class="mt-2">{escape(actor.summary)}</p>' 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'<span class="text-sm text-stone-500">{a.object_type}</span>' if a.object_type else ""
parts.append(
f'<div class="bg-white rounded-lg shadow p-4"><div class="flex justify-between items-start">'
f'<span class="font-medium">{a.activity_type}</span>'
f'<span class="text-sm text-stone-400">{published}</span></div>{obj_type}</div>'
)
activities_html = '<div class="space-y-4">' + "".join(parts) + '</div>'
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 = '<p class="text-stone-500">No activities yet.</p>'
activities_html = sexp('(p :class "text-stone-500" "No activities yet.")')
content = (
f'<div class="py-8"><div class="bg-white rounded-lg shadow p-6 mb-6">'
f'<h1 class="text-2xl font-bold">{escape(display_name)}</h1>'
f'<p class="text-stone-500">@{escape(actor.preferred_username)}@{escape(ap_domain)}</p>'
f'{summary_html}</div>'
f'<h2 class="text-xl font-bold mb-4">Activities ({total})</h2>{activities_html}</div>'
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" (raw! activities-heading))'
' (raw! activities))',
dname=str(escape(display_name)),
username=str(escape(actor.preferred_username)),
domain=str(escape(ap_domain)),
summary=summary_html,
**{"activities-heading": f"Activities ({total})"},
activities=activities_html,
)
return _social_page(ctx, actor, content_html=content,

View File

@@ -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'<tr class="hidden sm:table-row border-t border-stone-100 hover:bg-stone-50/60">'
f'<td class="px-3 py-2 align-top"><span class="font-mono text-[11px] sm:text-xs">#{order.id}</span></td>'
f'<td class="px-3 py-2 align-top text-stone-700 text-xs sm:text-sm">{created}</td>'
f'<td class="px-3 py-2 align-top text-stone-700 text-xs sm:text-sm">{order.description or ""}</td>'
f'<td class="px-3 py-2 align-top text-stone-700 text-xs sm:text-sm">{total}</td>'
f'<td class="px-3 py-2 align-top"><span class="inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] sm:text-xs {pill}">{status}</span></td>'
f'<td class="px-3 py-0.5 align-top text-right"><a href="{detail_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</a></td></tr>'
# Mobile row
f'<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">'
f'<div class="flex items-center justify-between gap-2"><span class="font-mono text-[11px] text-stone-700">#{order.id}</span>'
f'<span class="inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] {pill}">{status}</span></div>'
f'<div class="text-[11px] text-stone-500 break-words">{created}</div>'
f'<div class="flex items-center justify-between gap-2"><div class="font-medium text-stone-800">{total}</div>'
f'<a href="{detail_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</a></div></div></td></tr>'
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('<tr><td colspan="5" class="px-3 py-4 text-center text-xs text-stone-400">End of results</td></tr>')
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 (
'<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.</div></div>'
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 (
'<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>'
'<th class="px-3 py-2 text-left font-medium">Created</th>'
'<th class="px-3 py-2 text-left font-medium">Description</th>'
'<th class="px-3 py-2 text-left font-medium">Total</th>'
'<th class="px-3 py-2 text-left font-medium">Status</th>'
'<th class="px-3 py-2 text-left font-medium"></th>'
f'</tr></thead><tbody>{rows_html}</tbody></table></div></div>'
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 (
'<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.</p></div>'
f'<div class="md:hidden">{search_mobile_html(ctx)}</div>'
'</header>'
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'<img src="{item.product_image}" alt="{item.product_title or "Product image"}"'
f' class="w-full h-full object-contain object-center" loading="lazy" decoding="async">'
if item.product_image else
'<div class="w-full h-full flex items-center justify-center text-[9px] text-stone-400">No image</div>'
)
items.append(
f'<li><a class="w-full py-2 flex gap-3" href="{prod_url}">'
f'<div class="w-12 h-12 sm:w-14 sm:h-14 rounded-md bg-stone-100 flex-shrink-0 overflow-hidden">{img}</div>'
f'<div class="flex-1 flex justify-between gap-3">'
f'<div><p class="font-medium">{item.product_title or "Unknown product"}</p>'
f'<p class="text-[11px] text-stone-500">Product ID: {item.product_id}</p></div>'
f'<div class="text-right whitespace-nowrap"><p>Qty: {item.quantity}</p>'
f'<p>{item.currency or order.currency or "GBP"} {item.unit_price or 0:.2f}</p>'
f'</div></div></a></li>'
)
return (
'<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</h2>'
f'<ul class="divide-y divide-stone-100 text-xs sm:text-sm">{"".join(items)}</ul></div>'
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'<li class="px-4 py-3 flex items-start justify-between text-sm">'
f'<div><div class="font-medium flex items-center gap-2">{e.name}'
f'<span class="inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium {pill}">'
f'{st.capitalize()}</span></div>'
f'<div class="text-xs text-stone-500">{ds}</div></div>'
f'<div class="ml-4 font-medium">\u00a3{e.cost or 0:.2f}</div></li>'
)
return (
'<section class="mt-6 space-y-3">'
'<h2 class="text-base sm:text-lg font-semibold">Calendar bookings in this order</h2>'
f'<ul class="divide-y divide-stone-200 rounded-2xl border border-stone-200 bg-white/80">{"".join(items)}</ul></section>'
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'<div class="max-w-full px-3 py-3 space-y-4">{summary}{_order_items_html(order)}{_calendar_items_html(calendar_entries)}</div>'
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'<a href="{pay_url}" class="inline-flex items-center px-3 py-2 text-xs sm:text-sm '
f'rounded-full border border-emerald-600 bg-emerald-600 text-white hover:bg-emerald-700 transition">'
f'<i class="fa fa-credit-card mr-2" aria-hidden="true"></i>Open payment page</a>'
) if status != "paid" else ""
return (
'<header class="mb-6 sm:mb-8 flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4">'
f'<div class="space-y-1"><p class="text-xs sm:text-sm text-stone-600">Placed {created} &middot; Status: {status}</p></div>'
'<div class="flex w-full sm:w-auto justify-start sm:justify-end gap-2">'
f'<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"></i>All orders</a>'
f'<form method="post" action="{recheck_url}" class="inline"><input type="hidden" name="csrf_token" value="{csrf_token}">'
f'<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"></i>Re-check status</button></form>'
f'{pay}</div></header>'
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 (
'<header class="mb-6 sm:mb-8">'
'<h1 class="text-xl sm:text-2xl md:text-3xl font-semibold tracking-tight">'
'Checkout error</h1>'
'<p class="text-xs sm:text-sm text-stone-600">'
'We tried to start your payment with SumUp but hit a problem.</p>'
'</header>'
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'<p class="text-xs text-rose-800/80">'
f'Order ID: <span class="font-mono">#{order.id}</span></p>'
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 (
'<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">'
f'<p class="font-medium">Something went wrong.</p>'
f'<p>{err_msg}</p>'
f'{order_html}'
'</div>'
'<div>'
f'<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"></i>'
'Back to cart</a>'
'</div>'
'</div>'
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},
)