Externalize sexp to .sexpr files + render() API
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m20s

Replace all 676 inline sexp() string calls across 7 services with
render(component_name, **kwargs) calls backed by 46 external .sexpr
component definition files (587 defcomps total).

- Add render() function to shared/sexp/jinja_bridge.py
- Add load_service_components() helper and update load_sexp_dir() for *.sexpr
- Update parser keyword regex to support HTMX hx-on::event syntax
- Convert remaining inline HTML in route files to render() calls
- Add shared/sexp/templates/misc.sexp for cross-service utility components

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-28 16:14:58 +00:00
parent f4c2f4b6b8
commit f9d9697c67
64 changed files with 5041 additions and 4051 deletions

58
account/sexp/auth.sexpr Normal file
View File

@@ -0,0 +1,58 @@
;; Auth page components (login, device, check email)
(defcomp ~account-login-error (&key error)
(when error
(div :class "bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4"
(raw! error))))
(defcomp ~account-login-form (&key error-html action csrf-token email)
(div :class "py-8 max-w-md mx-auto"
(h1 :class "text-2xl font-bold mb-6" "Sign in")
(raw! error-html)
(form :method "post" :action action :class "space-y-4"
(input :type "hidden" :name "csrf_token" :value csrf-token)
(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"))))
(defcomp ~account-device-error (&key error)
(when error
(div :class "bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4"
(raw! error))))
(defcomp ~account-device-form (&key error-html action csrf-token code)
(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! error-html)
(form :method "post" :action action :class "space-y-4"
(input :type "hidden" :name "csrf_token" :value csrf-token)
(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"))))
(defcomp ~account-device-approved ()
(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.")))
(defcomp ~account-check-email-error (&key error)
(when error
(div :class "bg-yellow-50 border border-yellow-200 text-yellow-700 p-3 rounded mt-4"
(raw! error))))
(defcomp ~account-check-email (&key email error-html)
(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! error-html)))

View File

@@ -0,0 +1,48 @@
;; Account dashboard components
(defcomp ~account-error-banner (&key error)
(when error
(div :class "rounded-lg border border-red-200 bg-red-50 text-red-800 px-4 py-3 text-sm"
(raw! error))))
(defcomp ~account-user-email (&key email)
(when email
(p :class "text-sm text-stone-500 mt-1" (raw! email))))
(defcomp ~account-user-name (&key name)
(when name
(p :class "text-sm text-stone-600" (raw! name))))
(defcomp ~account-logout-form (&key csrf-token)
(form :action "/auth/logout/" :method "post"
(input :type "hidden" :name "csrf_token" :value csrf-token)
(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")))
(defcomp ~account-label-item (&key name)
(span :class "inline-flex items-center rounded-full border border-stone-200 px-3 py-1 text-xs font-medium bg-white/60"
(raw! name)))
(defcomp ~account-labels-section (&key items-html)
(when items-html
(div
(h2 :class "text-base font-semibold tracking-tight mb-3" "Labels")
(div :class "flex flex-wrap gap-2" (raw! items-html)))))
(defcomp ~account-main-panel (&key error-html email-html name-html logout-html labels-html)
(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! error-html)
(div :class "flex items-center justify-between"
(div
(h1 :class "text-xl font-semibold tracking-tight" "Account")
(raw! email-html)
(raw! name-html))
(raw! logout-html))
(raw! labels-html))))
;; Header child wrapper
(defcomp ~account-header-child (&key inner-html)
(div :id "root-header-child" :class "flex flex-col w-full items-center"
(raw! inner-html)))

View File

@@ -0,0 +1,37 @@
;; Newsletter management components
(defcomp ~account-newsletter-desc (&key description)
(when description
(p :class "text-xs text-stone-500 mt-0.5 truncate" (raw! description))))
(defcomp ~account-newsletter-toggle (&key id url hdrs target cls checked knob-cls)
(div :id id :class "flex items-center"
(button :hx-post url :hx-headers hdrs :hx-target target :hx-swap "outerHTML"
:class cls :role "switch" :aria-checked checked
(span :class knob-cls))))
(defcomp ~account-newsletter-toggle-off (&key id url hdrs target)
(div :id id :class "flex items-center"
(button :hx-post url :hx-headers hdrs :hx-target target :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"))))
(defcomp ~account-newsletter-item (&key name desc-html toggle-html)
(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-html))
(div :class "ml-4 flex-shrink-0" (raw! toggle-html))))
(defcomp ~account-newsletter-list (&key items-html)
(div :class "divide-y divide-stone-100" (raw! items-html)))
(defcomp ~account-newsletter-empty ()
(p :class "text-sm text-stone-500" "No newsletters available."))
(defcomp ~account-newsletters-panel (&key list-html)
(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-html))))

View File

@@ -6,14 +6,18 @@ auth pages. Called from route handlers in place of ``render_template()``.
""" """
from __future__ import annotations from __future__ import annotations
import os
from typing import Any from typing import Any
from shared.sexp.jinja_bridge import sexp from shared.sexp.jinja_bridge import render, load_service_components
from shared.sexp.helpers import ( from shared.sexp.helpers import (
call_url, root_header_html, search_desktop_html, call_url, root_header_html, search_desktop_html,
search_mobile_html, full_page, oob_page, search_mobile_html, full_page, oob_page,
) )
# Load account-specific .sexpr components at import time
load_service_components(os.path.dirname(os.path.dirname(__file__)))
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Header helpers # Header helpers
@@ -21,10 +25,11 @@ from shared.sexp.helpers import (
def _auth_nav_html(ctx: dict) -> str: def _auth_nav_html(ctx: dict) -> str:
"""Auth section desktop nav items.""" """Auth section desktop nav items."""
html = sexp( html = render(
'(~nav-link :href h :label "newsletters" :select-colours sc)', "nav-link",
h=call_url(ctx, "account_url", "/newsletters/"), href=call_url(ctx, "account_url", "/newsletters/"),
sc=ctx.get("select_colours", ""), label="newsletters",
select_colours=ctx.get("select_colours", ""),
) )
account_nav_html = ctx.get("account_nav_html", "") account_nav_html = ctx.get("account_nav_html", "")
if account_nav_html: if account_nav_html:
@@ -34,22 +39,23 @@ def _auth_nav_html(ctx: dict) -> str:
def _auth_header_html(ctx: dict, *, oob: bool = False) -> str: def _auth_header_html(ctx: dict, *, oob: bool = False) -> str:
"""Build the account section header row.""" """Build the account section header row."""
return sexp( return render(
'(~menu-row :id "auth-row" :level 1 :colour "sky"' "menu-row",
' :link-href lh :link-label "account" :icon "fa-solid fa-user"' id="auth-row", level=1, colour="sky",
' :nav-html nh :child-id "auth-header-child" :oob oob)', link_href=call_url(ctx, "account_url", "/"),
lh=call_url(ctx, "account_url", "/"), link_label="account", icon="fa-solid fa-user",
nh=_auth_nav_html(ctx), nav_html=_auth_nav_html(ctx),
oob=oob, child_id="auth-header-child", oob=oob,
) )
def _auth_nav_mobile_html(ctx: dict) -> str: def _auth_nav_mobile_html(ctx: dict) -> str:
"""Mobile nav menu for auth section.""" """Mobile nav menu for auth section."""
html = sexp( html = render(
'(~nav-link :href h :label "newsletters" :select-colours sc)', "nav-link",
h=call_url(ctx, "account_url", "/newsletters/"), href=call_url(ctx, "account_url", "/newsletters/"),
sc=ctx.get("select_colours", ""), label="newsletters",
select_colours=ctx.get("select_colours", ""),
) )
account_nav_html = ctx.get("account_nav_html", "") account_nav_html = ctx.get("account_nav_html", "")
if account_nav_html: if account_nav_html:
@@ -69,58 +75,30 @@ def _account_main_panel_html(ctx: dict) -> str:
user = getattr(g, "user", None) user = getattr(g, "user", None)
error = ctx.get("error", "") error = ctx.get("error", "")
error_html = sexp( error_html = render("account-error-banner", error=error) if error else ""
'(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 ""
user_email_html = "" user_email_html = ""
user_name_html = "" user_name_html = ""
if user: if user:
user_email_html = sexp( user_email_html = render("account-user-email", email=user.email)
'(p :class "text-sm text-stone-500 mt-1" (raw! e))',
e=user.email,
)
if user.name: if user.name:
user_name_html = sexp( user_name_html = render("account-user-name", name=user.name)
'(p :class "text-sm text-stone-600" (raw! n))',
n=user.name,
)
logout_html = sexp( logout_html = render("account-logout-form", csrf_token=generate_csrf_token())
'(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 = "" labels_html = ""
if user and hasattr(user, "labels") and user.labels: if user and hasattr(user, "labels") and user.labels:
label_items = "".join( label_items = "".join(
sexp( render("account-label-item", name=label.name)
'(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 for label in user.labels
) )
labels_html = sexp( labels_html = render("account-labels-section", items_html=label_items)
'(div (h2 :class "text-base font-semibold tracking-tight mb-3" "Labels")'
' (div :class "flex flex-wrap gap-2" (raw! items)))',
items=label_items,
)
return sexp( return render(
'(div :class "w-full max-w-3xl mx-auto px-4 py-6"' "account-main-panel",
' (div :class "bg-white/70 backdrop-blur rounded-2xl shadow border border-stone-200 p-6 sm:p-8 space-y-8"' error_html=error_html, email_html=user_email_html,
' (raw! err)' name_html=user_name_html, logout_html=logout_html,
' (div :class "flex items-center justify-between"' labels_html=labels_html,
' (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,
) )
@@ -140,31 +118,24 @@ def _newsletter_toggle_html(un: Any, account_url_fn: Any, csrf_token: str) -> st
bg = "bg-stone-300" bg = "bg-stone-300"
translate = "translate-x-1" translate = "translate-x-1"
checked = "false" checked = "false"
return sexp( return render(
'(div :id id :class "flex items-center"' "account-newsletter-toggle",
' (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, id=f"nl-{nid}", url=toggle_url,
hdrs=f'{{"X-CSRFToken": "{csrf_token}"}}', hdrs=f'{{"X-CSRFToken": "{csrf_token}"}}',
tgt=f"#nl-{nid}", target=f"#nl-{nid}",
cls=f"relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 {bg}", 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, checked=checked,
knob=f"inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform {translate}", knob_cls=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: def _newsletter_toggle_off_html(nid: int, toggle_url: str, csrf_token: str) -> str:
"""Render an unsubscribed newsletter toggle (no subscription record yet).""" """Render an unsubscribed newsletter toggle (no subscription record yet)."""
return sexp( return render(
'(div :id id :class "flex items-center"' "account-newsletter-toggle-off",
' (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, id=f"nl-{nid}", url=toggle_url,
hdrs=f'{{"X-CSRFToken": "{csrf_token}"}}', hdrs=f'{{"X-CSRFToken": "{csrf_token}"}}',
tgt=f"#nl-{nid}", target=f"#nl-{nid}",
) )
@@ -181,9 +152,8 @@ def _newsletters_panel_html(ctx: dict, newsletter_list: list) -> str:
nl = item["newsletter"] nl = item["newsletter"]
un = item.get("un") un = item.get("un")
desc_html = sexp( desc_html = render(
'(p :class "text-xs text-stone-500 mt-0.5 truncate" (raw! d))', "account-newsletter-desc", description=nl.description
d=nl.description,
) if nl.description else "" ) if nl.description else ""
if un: if un:
@@ -192,28 +162,18 @@ def _newsletters_panel_html(ctx: dict, newsletter_list: list) -> str:
toggle_url = account_url_fn(f"/newsletter/{nl.id}/toggle/") toggle_url = account_url_fn(f"/newsletter/{nl.id}/toggle/")
toggle = _newsletter_toggle_off_html(nl.id, toggle_url, csrf) toggle = _newsletter_toggle_off_html(nl.id, toggle_url, csrf)
items.append(sexp( items.append(render(
'(div :class "flex items-center justify-between py-4 first:pt-0 last:pb-0"' "account-newsletter-item",
' (div :class "min-w-0 flex-1"' name=nl.name, desc_html=desc_html, toggle_html=toggle,
' (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( list_html = render(
'(div :class "divide-y divide-stone-100" (raw! items))', "account-newsletter-list",
items="".join(items), items_html="".join(items),
) )
else: else:
list_html = sexp('(p :class "text-sm text-stone-500" "No newsletters available.")') list_html = render("account-newsletter-empty")
return sexp( return render("account-newsletters-panel", list_html=list_html)
'(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,
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -229,26 +189,12 @@ def _login_page_content(ctx: dict) -> str:
email = ctx.get("email", "") email = ctx.get("email", "")
action = url_for("auth.start_login") action = url_for("auth.start_login")
error_html = sexp( error_html = render("account-login-error", error=error) if error else ""
'(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( return render(
'(div :class "py-8 max-w-md mx-auto"' "account-login-form",
' (h1 :class "text-2xl font-bold mb-6" "Sign in")' error_html=error_html, action=action,
' (raw! err)' csrf_token=generate_csrf_token(), email=email,
' (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,
) )
@@ -261,38 +207,18 @@ def _device_page_content(ctx: dict) -> str:
code = ctx.get("code", "") code = ctx.get("code", "")
action = url_for("auth.device_submit") action = url_for("auth.device_submit")
error_html = sexp( error_html = render("account-device-error", error=error) if error else ""
'(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( return render(
'(div :class "py-8 max-w-md mx-auto"' "account-device-form",
' (h1 :class "text-2xl font-bold mb-6" "Authorize device")' error_html=error_html, action=action,
' (p :class "text-stone-600 mb-4" "Enter the code shown in your terminal to sign in.")' csrf_token=generate_csrf_token(), code=code,
' (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,
) )
def _device_approved_content() -> str: def _device_approved_content() -> str:
"""Device approved success content.""" """Device approved success content."""
return sexp( return render("account-device-approved")
'(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."))',
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -304,10 +230,7 @@ async def render_account_page(ctx: dict) -> str:
main = _account_main_panel_html(ctx) main = _account_main_panel_html(ctx)
hdr = root_header_html(ctx) hdr = root_header_html(ctx)
hdr += sexp( hdr += render("account-header-child", inner_html=_auth_header_html(ctx))
'(div :id "root-header-child" :class "flex flex-col w-full items-center" (raw! a))',
a=_auth_header_html(ctx),
)
return full_page(ctx, header_rows_html=hdr, return full_page(ctx, header_rows_html=hdr,
content_html=main, content_html=main,
@@ -337,10 +260,7 @@ async def render_newsletters_page(ctx: dict, newsletter_list: list) -> str:
main = _newsletters_panel_html(ctx, newsletter_list) main = _newsletters_panel_html(ctx, newsletter_list)
hdr = root_header_html(ctx) hdr = root_header_html(ctx)
hdr += sexp( hdr += render("account-header-child", inner_html=_auth_header_html(ctx))
'(div :id "root-header-child" :class "flex flex-col w-full items-center" (raw! a))',
a=_auth_header_html(ctx),
)
return full_page(ctx, header_rows_html=hdr, return full_page(ctx, header_rows_html=hdr,
content_html=main, content_html=main,
@@ -368,10 +288,7 @@ async def render_newsletters_oob(ctx: dict, newsletter_list: list) -> str:
async def render_fragment_page(ctx: dict, page_fragment_html: str) -> str: async def render_fragment_page(ctx: dict, page_fragment_html: str) -> str:
"""Full page: fragment-provided content.""" """Full page: fragment-provided content."""
hdr = root_header_html(ctx) hdr = root_header_html(ctx)
hdr += sexp( hdr += render("account-header-child", inner_html=_auth_header_html(ctx))
'(div :id "root-header-child" :class "flex flex-col w-full items-center" (raw! a))',
a=_auth_header_html(ctx),
)
return full_page(ctx, header_rows_html=hdr, return full_page(ctx, header_rows_html=hdr,
content_html=page_fragment_html, content_html=page_fragment_html,
@@ -426,18 +343,13 @@ def _check_email_content(email: str, email_error: str | None = None) -> str:
"""Check email confirmation content.""" """Check email confirmation content."""
from markupsafe import escape from markupsafe import escape
error_html = sexp( error_html = render(
'(div :class "bg-yellow-50 border border-yellow-200 text-yellow-700 p-3 rounded mt-4" (raw! e))', "account-check-email-error", error=str(escape(email_error))
e=str(escape(email_error)),
) if email_error else "" ) if email_error else ""
return sexp( return render(
'(div :class "py-8 max-w-md mx-auto text-center"' "account-check-email",
' (h1 :class "text-2xl font-bold mb-4" "Check your email")' email=str(escape(email)), error_html=error_html,
' (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,
) )
@@ -468,7 +380,6 @@ def render_newsletter_toggle(un) -> str:
from quart import g from quart import g
account_url_fn = getattr(g, "_account_url", None) account_url_fn = getattr(g, "_account_url", None)
if account_url_fn is None: if account_url_fn is None:
# Fallback: construct URL directly
from shared.infrastructure.urls import account_url from shared.infrastructure.urls import account_url
account_url_fn = account_url account_url_fn = account_url
return _newsletter_toggle_html(un, account_url_fn, generate_csrf_token()) return _newsletter_toggle_html(un, account_url_fn, generate_csrf_token())

View File

@@ -59,7 +59,8 @@ def register(url_prefix):
await clear_all_cache() await clear_all_cache()
if is_htmx_request(): if is_htmx_request():
now = datetime.now() now = datetime.now()
html = f'<span class="text-green-600 font-bold">Cache cleared at {now.strftime("%H:%M:%S")}</span>' from shared.sexp.jinja_bridge import render as render_comp
html = render_comp("cache-cleared", time_str=now.strftime("%H:%M:%S"))
return html return html
return redirect(url_for("settings.cache")) return redirect(url_for("settings.cache"))

178
blog/sexp/admin.sexpr Normal file
View File

@@ -0,0 +1,178 @@
;; Blog admin panel components
(defcomp ~blog-cache-panel (&key clear-url csrf)
(div :class "max-w-2xl mx-auto px-4 py-6 space-y-6"
(div :class "flex flex-col md:flex-row gap-3 items-start"
(form :hx-post clear-url :hx-trigger "submit" :hx-target "#cache-status" :hx-swap "innerHTML"
(input :type "hidden" :name "csrf_token" :value csrf)
(button :class "border rounded px-4 py-2 bg-stone-800 text-white text-sm" :type "submit" "Clear cache"))
(div :id "cache-status" :class "py-2"))))
(defcomp ~blog-snippets-panel (&key list-html)
(div :class "max-w-4xl mx-auto p-6"
(div :class "mb-6 flex justify-between items-center"
(h1 :class "text-3xl font-bold" "Snippets"))
(div :id "snippets-list" (raw! list-html))))
(defcomp ~blog-snippets-empty ()
(div :class "bg-white rounded-lg shadow"
(div :class "p-8 text-center text-stone-400"
(i :class "fa fa-puzzle-piece text-4xl mb-2")
(p "No snippets yet. Create one from the blog editor."))))
(defcomp ~blog-snippet-visibility-select (&key patch-url hx-headers options-html cls)
(select :name "visibility" :hx-patch patch-url :hx-target "#snippets-list" :hx-swap "innerHTML"
:hx-headers hx-headers :class "text-sm border border-stone-300 rounded px-2 py-1"
(raw! options-html)))
(defcomp ~blog-snippet-option (&key value selected label)
(option :value value :selected selected label))
(defcomp ~blog-snippet-delete-button (&key confirm-text delete-url hx-headers)
(button :type "button" :data-confirm "" :data-confirm-title "Delete snippet?"
:data-confirm-text confirm-text :data-confirm-icon "warning"
:data-confirm-confirm-text "Yes, delete" :data-confirm-cancel-text "Cancel"
:data-confirm-event "confirmed"
:hx-delete delete-url :hx-trigger "confirmed" :hx-target "#snippets-list" :hx-swap "innerHTML"
:hx-headers hx-headers
:class "px-3 py-1 text-sm bg-red-200 hover:bg-red-300 rounded text-red-800 flex-shrink-0"
(i :class "fa fa-trash") " Delete"))
(defcomp ~blog-snippet-row (&key name owner badge-cls visibility extra-html)
(div :class "flex items-center gap-4 p-4 hover:bg-stone-50 transition"
(div :class "flex-1 min-w-0"
(div :class "font-medium truncate" name)
(div :class "text-xs text-stone-500" owner))
(span :class (str "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium " badge-cls) visibility)
(raw! extra-html)))
(defcomp ~blog-snippets-list (&key rows-html)
(div :class "bg-white rounded-lg shadow" (div :class "divide-y" (raw! rows-html))))
(defcomp ~blog-menu-items-panel (&key new-url list-html)
(div :class "max-w-4xl mx-auto p-6"
(div :class "mb-6 flex justify-end items-center"
(button :type "button" :hx-get new-url :hx-target "#menu-item-form" :hx-swap "innerHTML"
:class "px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
(i :class "fa fa-plus") " Add Menu Item"))
(div :id "menu-item-form" :class "mb-6")
(div :id "menu-items-list" (raw! list-html))))
(defcomp ~blog-menu-items-empty ()
(div :class "bg-white rounded-lg shadow"
(div :class "p-8 text-center text-stone-400"
(i :class "fa fa-inbox text-4xl mb-2")
(p "No menu items yet. Add one to get started!"))))
(defcomp ~blog-menu-item-image (&key src label)
(if src (img :src src :alt label :class "w-12 h-12 rounded-full object-cover flex-shrink-0")
(div :class "w-12 h-12 rounded-full bg-stone-200 flex-shrink-0")))
(defcomp ~blog-menu-item-row (&key img-html label slug sort-order edit-url delete-url confirm-text hx-headers)
(div :class "flex items-center gap-4 p-4 hover:bg-stone-50 transition"
(div :class "text-stone-400 cursor-move" (i :class "fa fa-grip-vertical"))
(raw! img-html)
(div :class "flex-1 min-w-0"
(div :class "font-medium truncate" label)
(div :class "text-xs text-stone-500 truncate" slug))
(div :class "text-sm text-stone-500" (str "Order: " sort-order))
(div :class "flex gap-2 flex-shrink-0"
(button :type "button" :hx-get edit-url :hx-target "#menu-item-form" :hx-swap "innerHTML"
:class "px-3 py-1 text-sm bg-stone-200 hover:bg-stone-300 rounded"
(i :class "fa fa-edit") " Edit")
(button :type "button" :data-confirm "" :data-confirm-title "Delete menu item?"
:data-confirm-text confirm-text :data-confirm-icon "warning"
:data-confirm-confirm-text "Yes, delete" :data-confirm-cancel-text "Cancel"
:data-confirm-event "confirmed"
:hx-delete delete-url :hx-trigger "confirmed" :hx-target "#menu-items-list" :hx-swap "innerHTML"
:hx-headers hx-headers
:class "px-3 py-1 text-sm bg-red-200 hover:bg-red-300 rounded text-red-800"
(i :class "fa fa-trash") " Delete"))))
(defcomp ~blog-menu-items-list (&key rows-html)
(div :class "bg-white rounded-lg shadow" (div :class "divide-y" (raw! rows-html))))
;; Tag groups admin
(defcomp ~blog-tag-groups-create-form (&key create-url csrf)
(form :method "post" :action create-url :class "border rounded p-4 bg-white space-y-3"
(input :type "hidden" :name "csrf_token" :value csrf)
(h3 :class "text-sm font-semibold text-stone-700" "New Group")
(div :class "flex flex-col sm:flex-row gap-3"
(input :type "text" :name "name" :placeholder "Group name" :required "" :class "flex-1 border rounded px-3 py-2 text-sm")
(input :type "text" :name "colour" :placeholder "#colour" :class "w-28 border rounded px-3 py-2 text-sm")
(input :type "number" :name "sort_order" :placeholder "Order" :value "0" :class "w-20 border rounded px-3 py-2 text-sm"))
(input :type "text" :name "feature_image" :placeholder "Image URL (optional)" :class "w-full border rounded px-3 py-2 text-sm")
(button :type "submit" :class "border rounded px-4 py-2 bg-stone-800 text-white text-sm" "Create")))
(defcomp ~blog-tag-group-icon-image (&key src name)
(img :src src :alt name :class "h-8 w-8 rounded-full object-cover border border-stone-300 flex-shrink-0"))
(defcomp ~blog-tag-group-icon-color (&key style initial)
(div :class "h-8 w-8 rounded-full text-xs font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0"
:style style initial))
(defcomp ~blog-tag-group-li (&key icon-html edit-href name slug sort-order)
(li :class "border rounded p-3 bg-white flex items-center gap-3"
(raw! icon-html)
(div :class "flex-1"
(a :href edit-href :class "font-medium text-stone-800 hover:underline" name)
(span :class "text-xs text-stone-500 ml-2" slug))
(span :class "text-xs text-stone-500" (str "order: " sort-order))))
(defcomp ~blog-tag-groups-list (&key items-html)
(ul :class "space-y-2" (raw! items-html)))
(defcomp ~blog-tag-groups-empty ()
(p :class "text-stone-500 text-sm" "No tag groups yet."))
(defcomp ~blog-unassigned-tag (&key name)
(span :class "inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200 rounded" name))
(defcomp ~blog-unassigned-tags (&key heading spans-html)
(div :class "border-t pt-4"
(h3 :class "text-sm font-semibold text-stone-700 mb-2" heading)
(div :class "flex flex-wrap gap-2" (raw! spans-html))))
(defcomp ~blog-tag-groups-main (&key form-html groups-html unassigned-html)
(div :class "max-w-2xl mx-auto px-4 py-6 space-y-8"
(raw! form-html) (raw! groups-html) (raw! unassigned-html)))
;; Tag group edit
(defcomp ~blog-tag-checkbox (&key tag-id checked img-html name)
(label :class "flex items-center gap-2 px-2 py-1 hover:bg-stone-50 rounded text-sm cursor-pointer"
(input :type "checkbox" :name "tag_ids" :value tag-id :checked checked :class "rounded border-stone-300")
(raw! img-html) (span name)))
(defcomp ~blog-tag-checkbox-image (&key src)
(img :src src :alt "" :class "h-4 w-4 rounded-full object-cover"))
(defcomp ~blog-tag-group-edit-form (&key save-url csrf name colour sort-order feature-image tags-html)
(form :method "post" :action save-url :class "border rounded p-4 bg-white space-y-4"
(input :type "hidden" :name "csrf_token" :value csrf)
(div :class "space-y-3"
(div (label :class "block text-xs font-medium text-stone-600 mb-1" "Name")
(input :type "text" :name "name" :value name :required "" :class "w-full border rounded px-3 py-2 text-sm"))
(div :class "flex gap-3"
(div :class "flex-1" (label :class "block text-xs font-medium text-stone-600 mb-1" "Colour")
(input :type "text" :name "colour" :value colour :placeholder "#hex" :class "w-full border rounded px-3 py-2 text-sm"))
(div :class "w-24" (label :class "block text-xs font-medium text-stone-600 mb-1" "Order")
(input :type "number" :name "sort_order" :value sort-order :class "w-full border rounded px-3 py-2 text-sm")))
(div (label :class "block text-xs font-medium text-stone-600 mb-1" "Feature Image URL")
(input :type "text" :name "feature_image" :value feature-image :placeholder "https://..." :class "w-full border rounded px-3 py-2 text-sm")))
(div (label :class "block text-xs font-medium text-stone-600 mb-2" "Assign Tags")
(div :class "grid grid-cols-1 sm:grid-cols-2 gap-1 max-h-64 overflow-y-auto border rounded p-2"
(raw! tags-html)))
(div :class "flex gap-3"
(button :type "submit" :class "border rounded px-4 py-2 bg-stone-800 text-white text-sm" "Save"))))
(defcomp ~blog-tag-group-delete-form (&key delete-url csrf)
(form :method "post" :action delete-url :class "border-t pt-4"
:onsubmit "return confirm('Delete this tag group? Tags will not be deleted.')"
(input :type "hidden" :name "csrf_token" :value csrf)
(button :type "submit" :class "border rounded px-4 py-2 bg-red-600 text-white text-sm" "Delete Group")))
(defcomp ~blog-tag-group-edit-main (&key edit-form-html delete-form-html)
(div :class "max-w-2xl mx-auto px-4 py-6 space-y-6"
(raw! edit-form-html) (raw! delete-form-html)))

89
blog/sexp/cards.sexpr Normal file
View File

@@ -0,0 +1,89 @@
;; Blog card components
(defcomp ~blog-like-button (&key like-url hx-headers heart)
(div :class "absolute top-20 right-2 z-10 text-6xl md:text-4xl"
(button :hx-post like-url :hx-swap "outerHTML"
:hx-headers hx-headers :class "cursor-pointer" heart)))
(defcomp ~blog-draft-status (&key publish-requested timestamp)
(<> (div :class "flex justify-center gap-2 mt-1"
(span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-800" "Draft")
(when publish-requested (span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800" "Publish requested")))
(when timestamp (p :class "text-sm text-stone-500" (str "Updated: " timestamp)))))
(defcomp ~blog-published-status (&key timestamp)
(p :class "text-sm text-stone-500" (str "Published: " timestamp)))
(defcomp ~blog-card (&key like-html href hx-select title status-html feature-image excerpt widget-html at-bar-html)
(article :class "border-b pb-6 last:border-b-0 relative"
(raw! like-html)
(a :href href :hx-get href :hx-target "#main-panel"
:hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true"
:class "block rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden"
(header :class "mb-2 text-center"
(h2 :class "text-4xl font-bold text-stone-900" title)
(raw! status-html))
(when feature-image (div :class "mb-4" (img :src feature-image :alt "" :class "rounded-lg w-full object-cover")))
(when excerpt (p :class "text-stone-700 text-lg leading-relaxed text-center" excerpt)))
(when widget-html (raw! widget-html))
(raw! at-bar-html)))
(defcomp ~blog-card-tile (&key href hx-select feature-image title status-html excerpt at-bar-html)
(article :class "relative"
(a :href href :hx-get href :hx-target "#main-panel"
:hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true"
:class "block rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden"
(when feature-image (div (img :src feature-image :alt "" :class "w-full aspect-video object-cover")))
(div :class "p-3 text-center"
(h2 :class "text-lg font-bold text-stone-900" title)
(raw! status-html)
(when excerpt (p :class "text-stone-700 text-sm leading-relaxed line-clamp-3 mt-1" excerpt))))
(raw! at-bar-html)))
(defcomp ~blog-tag-icon-image (&key src name)
(img :src src :alt name :class "h-4 w-4 rounded-full object-cover border border-stone-300 flex-shrink-0"))
(defcomp ~blog-tag-icon-initial (&key initial)
(div :class "h-4 w-4 rounded-full text-[8px] font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0 bg-stone-200 text-stone-600" initial))
(defcomp ~blog-tag-li (&key icon-html name)
(li (a :class "flex items-center gap-1" (raw! icon-html)
(span :class "inline-block rounded-full bg-stone-100 text-stone-600 px-2 py-1 text-sm font-medium border border-stone-200" name))))
(defcomp ~blog-tag-bar (&key items-html)
(div :class "mt-4 flex items-center gap-2" (div "in")
(ul :class "flex flex-wrap gap-2 text-sm" (raw! items-html))))
(defcomp ~blog-author-with-image (&key image name)
(li :class "flex items-center gap-1"
(img :src image :alt name :class "h-5 w-5 rounded-full object-cover")
(span :class "text-stone-700" name)))
(defcomp ~blog-author-text (&key name)
(li :class "text-stone-700" name))
(defcomp ~blog-author-bar (&key items-html)
(div :class "mt-4 flex items-center gap-2" (div "by")
(ul :class "flex flex-wrap gap-2 text-sm" (raw! items-html))))
(defcomp ~blog-at-bar (&key tag-items-html author-items-html)
(div :class "flex flex-row justify-center gap-3"
(raw! tag-items-html) (div) (raw! author-items-html)))
(defcomp ~blog-page-badges (&key has-calendar has-market)
(div :class "flex justify-center gap-2 mt-2"
(when has-calendar (span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800"
(i :class "fa fa-calendar mr-1") "Calendar"))
(when has-market (span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-green-100 text-green-800"
(i :class "fa fa-shopping-bag mr-1") "Market"))))
(defcomp ~blog-page-card (&key href hx-select title badges-html pub-html feature-image excerpt)
(article :class "border-b pb-6 last:border-b-0 relative"
(a :href href :hx-get href :hx-target "#main-panel"
:hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true"
:class "block rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden"
(header :class "mb-2 text-center"
(h2 :class "text-4xl font-bold text-stone-900" title)
(raw! badges-html) (raw! pub-html))
(when feature-image (div :class "mb-4" (img :src feature-image :alt "" :class "rounded-lg w-full object-cover")))
(when excerpt (p :class "text-stone-700 text-lg leading-relaxed text-center" excerpt)))))

57
blog/sexp/detail.sexpr Normal file
View File

@@ -0,0 +1,57 @@
;; Blog post detail components
(defcomp ~blog-detail-edit-link (&key href hx-select)
(a :href href :hx-get href :hx-target "#main-panel"
:hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true"
:class "inline-block px-3 py-1 rounded-full text-sm font-semibold bg-stone-700 text-white hover:bg-stone-800 transition-colors"
(i :class "fa fa-pencil mr-1") " Edit"))
(defcomp ~blog-detail-draft (&key publish-requested edit-html)
(div :class "flex items-center justify-center gap-2 mb-3"
(span :class "inline-block px-3 py-1 rounded-full text-sm font-semibold bg-amber-100 text-amber-800" "Draft")
(when publish-requested (span :class "inline-block px-3 py-1 rounded-full text-sm font-semibold bg-blue-100 text-blue-800" "Publish requested"))
(raw! edit-html)))
(defcomp ~blog-detail-like (&key like-url hx-headers heart)
(div :class "absolute top-2 right-2 z-10 text-8xl md:text-6xl"
(button :hx-post like-url :hx-swap "outerHTML"
:hx-headers hx-headers :class "cursor-pointer" heart)))
(defcomp ~blog-detail-excerpt (&key excerpt)
(div :class "w-full text-center italic text-3xl p-2" excerpt))
(defcomp ~blog-detail-chrome (&key like-html excerpt-html at-bar-html)
(<> (raw! like-html) (raw! excerpt-html) (div :class "hidden md:block" (raw! at-bar-html))))
(defcomp ~blog-detail-main (&key draft-html chrome-html feature-image html-content)
(<> (article :class "relative"
(raw! draft-html) (raw! chrome-html)
(when feature-image (div :class "mb-3 flex justify-center"
(img :src feature-image :alt "" :class "rounded-lg w-full md:w-3/4 object-cover")))
(when html-content (div :class "blog-content p-2" (raw! html-content))))
(div :class "pb-8")))
(defcomp ~blog-meta (&key robots base-title desc canonical og-type og-title image twitter-card twitter-title)
(<>
(meta :name "robots" :content robots)
(title base-title)
(meta :name "description" :content desc)
(when canonical (link :rel "canonical" :href canonical))
(meta :property "og:type" :content og-type)
(meta :property "og:title" :content og-title)
(meta :property "og:description" :content desc)
(when canonical (meta :property "og:url" :content canonical))
(when image (meta :property "og:image" :content image))
(meta :name "twitter:card" :content twitter-card)
(meta :name "twitter:title" :content twitter-title)
(meta :name "twitter:description" :content desc)
(when image (meta :name "twitter:image" :content image))))
(defcomp ~blog-home-main (&key html-content)
(article :class "relative" (div :class "blog-content p-2" (raw! html-content))))
(defcomp ~blog-admin-empty ()
(div :class "pb-8"))
(defcomp ~blog-settings-empty ()
(div :class "max-w-2xl mx-auto px-4 py-6"))

54
blog/sexp/editor.sexpr Normal file
View File

@@ -0,0 +1,54 @@
;; Blog editor components
(defcomp ~blog-editor-error (&key error)
(div :class "max-w-[768px] mx-auto mt-[16px] rounded-[8px] border border-red-300 bg-red-50 px-[16px] py-[12px] text-[14px] text-red-700"
(strong "Save failed:") " " error))
(defcomp ~blog-editor-form (&key csrf title-placeholder create-label)
(form :id "post-new-form" :method "post" :class "max-w-[768px] mx-auto pb-[48px]"
(input :type "hidden" :name "csrf_token" :value csrf)
(input :type "hidden" :id "lexical-json-input" :name "lexical" :value "")
(input :type "hidden" :id "feature-image-input" :name "feature_image" :value "")
(input :type "hidden" :id "feature-image-caption-input" :name "feature_image_caption" :value "")
(div :id "feature-image-container" :class "relative mt-[16px] mb-[24px] group"
(div :id "feature-image-empty"
(button :type "button" :id "feature-image-add-btn"
:class "text-[14px] text-stone-400 hover:text-stone-600 transition-colors cursor-pointer"
"+ Add feature image"))
(div :id "feature-image-filled" :class "relative hidden"
(img :id "feature-image-preview" :src "" :alt ""
:class "w-full max-h-[448px] object-cover rounded-[8px] cursor-pointer")
(button :type "button" :id "feature-image-delete-btn"
:class "absolute top-[8px] right-[8px] w-[32px] h-[32px] rounded-full bg-black/50 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-black/70 cursor-pointer text-[14px]"
:title "Remove feature image"
(i :class "fa-solid fa-trash-can"))
(input :type "text" :id "feature-image-caption" :value ""
:placeholder "Add a caption..."
:class "mt-[8px] w-full text-[14px] text-stone-500 bg-transparent border-none outline-none placeholder:text-stone-300 focus:text-stone-700"))
(div :id "feature-image-uploading"
:class "hidden flex items-center gap-[8px] mt-[8px] text-[14px] text-stone-400"
(i :class "fa-solid fa-spinner fa-spin") " Uploading...")
(input :type "file" :id "feature-image-file"
:accept "image/jpeg,image/png,image/gif,image/webp,image/svg+xml" :class "hidden"))
(input :type "text" :name "title" :value "" :placeholder title-placeholder
:class "w-full text-[36px] font-bold bg-transparent border-none outline-none placeholder:text-stone-300 mb-[8px] leading-tight")
(textarea :name "custom_excerpt" :rows "1" :placeholder "Add an excerpt..."
:class "w-full text-[18px] text-stone-500 bg-transparent border-none outline-none placeholder:text-stone-300 resize-none mb-[24px] leading-relaxed")
(div :id "lexical-editor" :class "relative w-full bg-transparent")
(div :class "flex items-center gap-[16px] mt-[32px] pt-[16px] border-t border-stone-200"
(select :name "status"
:class "text-[14px] rounded-[4px] border border-stone-200 px-[8px] py-[6px] bg-white text-stone-600"
(option :value "draft" :selected t "Draft")
(option :value "published" "Published"))
(button :type "submit"
:class "px-[20px] py-[6px] bg-stone-700 text-white text-[14px] rounded-[8px] hover:bg-stone-800 transition-colors cursor-pointer" create-label))))
(defcomp ~blog-editor-styles (&key css-href)
(<> (link :rel "stylesheet" :href css-href)
(style
"#lexical-editor { display: flow-root; }"
"#lexical-editor [data-kg-card=\"html\"] * { float: none !important; }"
"#lexical-editor [data-kg-card=\"html\"] table { width: 100% !important; }")))
(defcomp ~blog-editor-scripts (&key js-src init-js)
(<> (script :src js-src) (script (raw! init-js))))

65
blog/sexp/filters.sexpr Normal file
View File

@@ -0,0 +1,65 @@
;; Blog filter components
(defcomp ~blog-action-button (&key href hx-select btn-class title icon-class label)
(a :href href :hx-get href :hx-target "#main-panel"
:hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true"
:class btn-class :title title (i :class icon-class) label))
(defcomp ~blog-drafts-button (&key href hx-select btn-class title label draft-count)
(a :href href :hx-get href :hx-target "#main-panel"
:hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true"
:class btn-class :title title (i :class "fa fa-file-text-o mr-1") " Drafts "
(span :class "inline-block bg-stone-500 text-white px-1.5 py-0.5 text-xs font-medium rounded ml-1" draft-count)))
(defcomp ~blog-drafts-button-amber (&key href hx-select btn-class title label draft-count)
(a :href href :hx-get href :hx-target "#main-panel"
:hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true"
:class btn-class :title title (i :class "fa fa-file-text-o mr-1") " Drafts "
(span :class "inline-block bg-amber-500 text-white px-1.5 py-0.5 text-xs font-medium rounded ml-1" draft-count)))
(defcomp ~blog-action-buttons-wrapper (&key inner-html)
(div :class "flex flex-wrap gap-2 px-4 py-3" (raw! inner-html)))
(defcomp ~blog-filter-any-topic (&key cls hx-select)
(li (a :class (str "px-3 py-1 rounded border " cls)
:hx-get "?page=1" :hx-target "#main-panel" :hx-select hx-select
:hx-swap "outerHTML" :hx-push-url "true" "Any Topic")))
(defcomp ~blog-filter-group-icon-image (&key src name)
(img :src src :alt name :class "h-6 w-6 rounded-full object-cover border border-stone-300 flex-shrink-0"))
(defcomp ~blog-filter-group-icon-color (&key style initial)
(div :class "h-6 w-6 rounded-full text-[10px] font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0" :style style initial))
(defcomp ~blog-filter-group-li (&key cls hx-get hx-select icon-html name count)
(li (a :class (str "flex items-center gap-2 px-3 py-1 rounded border " cls)
:hx-get hx-get :hx-target "#main-panel" :hx-select hx-select
:hx-swap "outerHTML" :hx-push-url "true"
(raw! icon-html)
(span :class "inline-block rounded-full bg-stone-100 text-stone-600 px-2 py-1 text-sm font-medium border border-stone-200" name)
(span :class "flex-1")
(span :class "inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200" count))))
(defcomp ~blog-filter-nav (&key items-html)
(nav :class "max-w-3xl mx-auto px-4 pb-4 flex flex-wrap gap-2 text-sm"
(ul :class "divide-y flex flex-col gap-3" (raw! items-html))))
(defcomp ~blog-filter-any-author (&key cls hx-select)
(li (a :class (str "px-3 py-1 rounded " cls)
:hx-get "?page=1" :hx-target "#main-panel" :hx-select hx-select
:hx-swap "outerHTML" :hx-push-url "true" "Any author")))
(defcomp ~blog-filter-author-icon (&key src name)
(img :src src :alt name :class "h-5 w-5 rounded-full object-cover"))
(defcomp ~blog-filter-author-li (&key cls hx-get hx-select icon-html name count)
(li (a :class (str "flex items-center gap-2 px-3 py-1 rounded " cls)
:hx-get hx-get :hx-target "#main-panel" :hx-select hx-select
:hx-swap "outerHTML" :hx-push-url "true"
(raw! icon-html)
(span :class "text-stone-700" name)
(span :class "flex-1")
(span :class "inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200" count))))
(defcomp ~blog-filter-summary (&key text)
(span :class "text-sm text-stone-600" text))

34
blog/sexp/header.sexpr Normal file
View File

@@ -0,0 +1,34 @@
;; Blog header components
(defcomp ~blog-oob-header (&key parent-id child-id row-html)
(div :id parent-id :hx-swap-oob "outerHTML" :class "w-full"
(div :class "w-full" (raw! row-html)
(div :id child-id))))
(defcomp ~blog-header-label ()
(div))
(defcomp ~blog-post-label (&key feature-image title)
(<> (when feature-image (img :src feature-image :class "h-8 w-8 rounded-full object-cover border border-stone-300 flex-shrink-0"))
(span title)))
(defcomp ~blog-post-cart-link (&key href count)
(a :href href :class "relative inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-full border border-emerald-300 bg-emerald-50 text-emerald-800 hover:bg-emerald-100 transition"
(i :class "fa fa-shopping-cart" :aria-hidden "true")
(span count)))
(defcomp ~blog-container-nav (&key container-nav-html)
(div :class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"
:id "entries-calendars-nav-wrapper" (raw! container-nav-html)))
(defcomp ~blog-admin-label ()
(<> (i :class "fa fa-shield-halved" :aria-hidden "true") " admin"))
(defcomp ~blog-admin-nav-item (&key href nav-btn-class label)
(div :class "relative nav-group" (a :href href :class nav-btn-class label)))
(defcomp ~blog-sub-settings-label (&key icon label)
(<> (i :class icon :aria-hidden "true") " " label))
(defcomp ~blog-sub-admin-label (&key icon label)
(<> (i :class icon :aria-hidden "true") (div label)))

72
blog/sexp/index.sexpr Normal file
View File

@@ -0,0 +1,72 @@
;; Blog index components
(defcomp ~blog-end-of-results ()
(div :class "col-span-full mt-4 text-center text-xs text-stone-400" "End of results"))
(defcomp ~blog-sentinel-mobile (&key id next-url hyperscript)
(div :id id :class "block md:hidden h-[60vh] opacity-0 pointer-events-none js-mobile-sentinel"
:hx-get next-url :hx-trigger "intersect once delay:250ms, sentinelmobile:retry"
:hx-swap "outerHTML" :_ hyperscript
:role "status" :aria-live "polite" :aria-hidden "true"
(div :class "js-loading hidden flex justify-center py-8"
(div :class "animate-spin h-8 w-8 border-4 border-stone-300 border-t-stone-600 rounded-full"))
(div :class "js-neterr hidden text-center py-8 text-stone-400"
(i :class "fa fa-exclamation-triangle text-2xl")
(p :class "mt-2" "Loading failed \u2014 retrying\u2026"))))
(defcomp ~blog-sentinel-desktop (&key id next-url hyperscript)
(div :id id :class "hidden md:block h-4 opacity-0 pointer-events-none"
:hx-get next-url :hx-trigger "intersect once delay:250ms, sentinel:retry"
:hx-swap "outerHTML" :_ hyperscript
:role "status" :aria-live "polite" :aria-hidden "true"
(div :class "js-loading hidden flex justify-center py-2"
(div :class "animate-spin h-6 w-6 border-2 border-stone-300 border-t-stone-600 rounded-full"))
(div :class "js-neterr hidden text-center py-2 text-stone-400 text-sm" "Retry\u2026")))
(defcomp ~blog-page-sentinel (&key id next-url)
(div :id id :class "h-4 opacity-0 pointer-events-none"
:hx-get next-url :hx-trigger "intersect once delay:250ms" :hx-swap "outerHTML"))
(defcomp ~blog-no-pages ()
(div :class "col-span-full mt-8 text-center text-stone-500" "No pages found."))
(defcomp ~blog-list-svg ()
(svg :xmlns "http://www.w3.org/2000/svg" :class "h-5 w-5" :fill "none" :viewBox "0 0 24 24"
:stroke "currentColor" :stroke-width "2"
(path :stroke-linecap "round" :stroke-linejoin "round" :d "M4 6h16M4 12h16M4 18h16")))
(defcomp ~blog-tile-svg ()
(svg :xmlns "http://www.w3.org/2000/svg" :class "h-5 w-5" :fill "none" :viewBox "0 0 24 24"
:stroke "currentColor" :stroke-width "2"
(path :stroke-linecap "round" :stroke-linejoin "round"
:d "M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z")))
(defcomp ~blog-view-toggle (&key list-href tile-href hx-select list-cls tile-cls list-svg-html tile-svg-html)
(div :class "hidden md:flex justify-end px-3 pt-3 gap-1"
(a :href list-href :hx-get list-href :hx-target "#main-panel" :hx-select hx-select
:hx-swap "outerHTML" :hx-push-url "true" :class (str "p-1.5 rounded " list-cls) :title "List view"
:_ "on click js localStorage.removeItem('blog_view') end" (raw! list-svg-html))
(a :href tile-href :hx-get tile-href :hx-target "#main-panel" :hx-select hx-select
:hx-swap "outerHTML" :hx-push-url "true" :class (str "p-1.5 rounded " tile-cls) :title "Tile view"
:_ "on click js localStorage.setItem('blog_view','tile') end" (raw! tile-svg-html))))
(defcomp ~blog-content-type-tabs (&key posts-href pages-href hx-select posts-cls pages-cls)
(div :class "flex justify-center gap-1 px-3 pt-3"
(a :href posts-href :hx-get posts-href :hx-target "#main-panel"
:hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true"
:class (str "px-4 py-1.5 rounded-t text-sm font-medium transition-colors " posts-cls) "Posts")
(a :href pages-href :hx-get pages-href :hx-target "#main-panel"
:hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true"
:class (str "px-4 py-1.5 rounded-t text-sm font-medium transition-colors " pages-cls) "Pages")))
(defcomp ~blog-main-panel-pages (&key tabs-html cards-html)
(<> (raw! tabs-html) (div :class "max-w-full px-3 py-3 space-y-3" (raw! cards-html)) (div :class "pb-8")))
(defcomp ~blog-main-panel-posts (&key tabs-html toggle-html grid-cls cards-html)
(<> (raw! tabs-html) (raw! toggle-html) (div :class grid-cls (raw! cards-html)) (div :class "pb-8")))
(defcomp ~blog-aside (&key search-html action-buttons-html tag-groups-filter-html authors-filter-html)
(<> (raw! search-html) (raw! action-buttons-html)
(div :id "category-summary-desktop" :hxx-swap-oob "outerHTML"
(raw! tag-groups-filter-html) (raw! authors-filter-html))
(div :id "filter-summary-desktop" :hxx-swap-oob "outerHTML")))

67
blog/sexp/nav.sexpr Normal file
View File

@@ -0,0 +1,67 @@
;; Blog navigation components
(defcomp ~blog-nav-empty (&key wrapper-id)
(div :id wrapper-id :hx-swap-oob "outerHTML"))
(defcomp ~blog-nav-item-image (&key src label)
(if src (img :src src :alt label :class "w-8 h-8 rounded-full object-cover flex-shrink-0")
(div :class "w-8 h-8 rounded-full bg-stone-200 flex-shrink-0")))
(defcomp ~blog-nav-item-link (&key href hx-get selected nav-cls img-html label)
(div (a :href href :hx-get hx-get :hx-target "#main-panel"
:hx-swap "outerHTML" :hx-push-url "true"
:aria-selected selected :class nav-cls
(raw! img-html) (span label))))
(defcomp ~blog-nav-item-plain (&key href selected nav-cls img-html label)
(div (a :href href :aria-selected selected :class nav-cls
(raw! img-html) (span label))))
(defcomp ~blog-nav-wrapper (&key arrow-cls container-id left-hs scroll-hs right-hs items-html)
(div :class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"
:id "menu-items-nav-wrapper" :hx-swap-oob "outerHTML"
(button :class (str arrow-cls " hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded")
:aria-label "Scroll left"
:_ left-hs (i :class "fa fa-chevron-left"))
(div :id container-id
:class "overflow-y-auto sm:overflow-x-auto sm:overflow-y-visible scrollbar-hide max-h-[50vh] sm:max-h-none"
:style "scroll-behavior: smooth;" :_ scroll-hs
(div :class "flex flex-col sm:flex-row gap-1" (raw! items-html)))
(style ".scrollbar-hide::-webkit-scrollbar { display: none; } .scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }")
(button :class (str arrow-cls " hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded")
:aria-label "Scroll right"
:_ right-hs (i :class "fa fa-chevron-right"))))
;; Nav entries
(defcomp ~blog-nav-entries-empty ()
(div :id "entries-calendars-nav-wrapper" :hx-swap-oob "true"))
(defcomp ~blog-nav-entry-item (&key href nav-cls name date-str)
(a :href href :class nav-cls
(div :class "w-8 h-8 rounded bg-stone-200 flex-shrink-0")
(div :class "flex-1 min-w-0"
(div :class "font-medium truncate" name)
(div :class "text-xs text-stone-600 truncate" date-str))))
(defcomp ~blog-nav-calendar-item (&key href nav-cls name)
(a :href href :class nav-cls
(i :class "fa fa-calendar" :aria-hidden "true")
(div name)))
(defcomp ~blog-nav-entries-wrapper (&key scroll-hs items-html)
(div :class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"
:id "entries-calendars-nav-wrapper" :hx-swap-oob "true"
(button :class "entries-nav-arrow hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"
:aria-label "Scroll left"
:_ "on click set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft - 200"
(i :class "fa fa-chevron-left"))
(div :id "associated-items-container"
:class "overflow-y-auto sm:overflow-x-auto sm:overflow-y-visible scrollbar-hide max-h-[50vh] sm:max-h-none"
:style "scroll-behavior: smooth;" :_ scroll-hs
(div :class "flex flex-col sm:flex-row gap-1" (raw! items-html)))
(style ".scrollbar-hide::-webkit-scrollbar { display: none; } .scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }")
(button :class "entries-nav-arrow hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"
:aria-label "Scroll right"
:_ "on click set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft + 200"
(i :class "fa fa-chevron-right"))))

113
blog/sexp/settings.sexpr Normal file
View File

@@ -0,0 +1,113 @@
;; Blog settings panel components (features, markets, associated entries)
(defcomp ~blog-features-form (&key features-url calendar-checked market-checked hs-trigger)
(form :hx-put features-url :hx-target "#features-panel" :hx-swap "outerHTML"
:hx-headers "{\"Content-Type\": \"application/json\"}" :hx-ext "json-enc" :class "space-y-3"
(label :class "flex items-center gap-3 cursor-pointer"
(input :type "checkbox" :name "calendar" :value "true" :checked calendar-checked
:class "h-5 w-5 rounded border-stone-300 text-blue-600 focus:ring-blue-500"
:_ hs-trigger)
(span :class "text-sm text-stone-700"
(i :class "fa fa-calendar text-blue-600 mr-1")
" Calendar \u2014 enable event booking on this page"))
(label :class "flex items-center gap-3 cursor-pointer"
(input :type "checkbox" :name "market" :value "true" :checked market-checked
:class "h-5 w-5 rounded border-stone-300 text-green-600 focus:ring-green-500"
:_ hs-trigger)
(span :class "text-sm text-stone-700"
(i :class "fa fa-shopping-bag text-green-600 mr-1")
" Market \u2014 enable product catalog on this page"))))
(defcomp ~blog-sumup-connected ()
(span :class "ml-2 text-xs text-green-600" (i :class "fa fa-check-circle") " Connected"))
(defcomp ~blog-sumup-key-hint ()
(p :class "text-xs text-stone-400 mt-0.5" "Key is set. Leave blank to keep current key."))
(defcomp ~blog-sumup-form (&key sumup-url merchant-code placeholder key-hint-html checkout-prefix connected-html)
(div :class "mt-4 pt-4 border-t border-stone-100"
(h4 :class "text-sm font-medium text-stone-700"
(i :class "fa fa-credit-card text-purple-600 mr-1") " SumUp Payment")
(p :class "text-xs text-stone-400 mt-1 mb-3"
"Configure per-page SumUp credentials. Leave blank to use the global merchant account.")
(form :hx-put sumup-url :hx-target "#features-panel" :hx-swap "outerHTML" :class "space-y-3"
(div (label :class "block text-xs font-medium text-stone-600 mb-1" "Merchant Code")
(input :type "text" :name "merchant_code" :value merchant-code :placeholder "e.g. ME4J6100"
:class "w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500"))
(div (label :class "block text-xs font-medium text-stone-600 mb-1" "API Key")
(input :type "password" :name "api_key" :value "" :placeholder placeholder
:class "w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500")
(raw! key-hint-html))
(div (label :class "block text-xs font-medium text-stone-600 mb-1" "Checkout Reference Prefix")
(input :type "text" :name "checkout_prefix" :value checkout-prefix :placeholder "e.g. ROSE-"
:class "w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500"))
(button :type "submit"
:class "px-4 py-1.5 text-sm font-medium text-white bg-purple-600 rounded hover:bg-purple-700 focus:ring-2 focus:ring-purple-500"
"Save SumUp Settings")
(raw! connected-html))))
(defcomp ~blog-features-panel (&key form-html sumup-html)
(div :id "features-panel" :class "space-y-4 p-4 bg-white rounded-lg border border-stone-200"
(h3 :class "text-lg font-semibold text-stone-800" "Page Features")
(raw! form-html) (raw! sumup-html)))
;; Markets panel
(defcomp ~blog-market-item (&key name slug delete-url confirm-text)
(li :class "flex items-center justify-between p-3 bg-stone-50 rounded"
(div (span :class "font-medium" name)
(span :class "text-stone-400 text-sm ml-2" (str "/" slug "/")))
(button :hx-delete delete-url :hx-target "#markets-panel" :hx-swap "outerHTML"
:hx-confirm confirm-text :class "text-red-600 hover:text-red-800 text-sm" "Delete")))
(defcomp ~blog-markets-list (&key items-html)
(ul :class "space-y-2 mb-4" (raw! items-html)))
(defcomp ~blog-markets-empty ()
(p :class "text-stone-500 mb-4 text-sm" "No markets yet."))
(defcomp ~blog-markets-panel (&key list-html create-url)
(div :id "markets-panel"
(h3 :class "text-lg font-semibold mb-3" "Markets")
(raw! list-html)
(form :hx-post create-url :hx-target "#markets-panel" :hx-swap "outerHTML" :class "flex gap-2"
(input :type "text" :name "name" :placeholder "Market name" :required ""
:class "flex-1 border border-stone-300 rounded px-3 py-1.5 text-sm")
(button :type "submit"
:class "bg-stone-800 text-white px-4 py-1.5 rounded text-sm hover:bg-stone-700" "Create"))))
;; Associated entries
(defcomp ~blog-entry-image (&key src title)
(if src (img :src src :alt title :class "w-8 h-8 rounded-full object-cover flex-shrink-0")
(div :class "w-8 h-8 rounded-full bg-stone-200 flex-shrink-0")))
(defcomp ~blog-associated-entry (&key confirm-text toggle-url hx-headers img-html name date-str)
(button :type "button"
:class "w-full text-left p-3 rounded border bg-green-50 border-green-300 transition hover:bg-green-100"
:data-confirm "" :data-confirm-title "Remove entry?"
:data-confirm-text confirm-text :data-confirm-icon "warning"
:data-confirm-confirm-text "Yes, remove it"
:data-confirm-cancel-text "Cancel" :data-confirm-event "confirmed"
:hx-post toggle-url :hx-trigger "confirmed"
:hx-target "#associated-entries-list" :hx-swap "outerHTML"
:hx-headers hx-headers
:_ "on htmx:afterRequest trigger entryToggled on body"
(div :class "flex items-center justify-between gap-3"
(raw! img-html)
(div :class "flex-1"
(div :class "font-medium text-sm" name)
(div :class "text-xs text-stone-600 mt-1" date-str))
(i :class "fa fa-times-circle text-green-600 text-lg flex-shrink-0"))))
(defcomp ~blog-associated-entries-content (&key items-html)
(div :class "space-y-1" (raw! items-html)))
(defcomp ~blog-associated-entries-empty ()
(div :class "text-sm text-stone-400"
"No entries associated yet. Browse calendars below to add entries."))
(defcomp ~blog-associated-entries-panel (&key content-html)
(div :id "associated-entries-list" :class "border rounded-lg p-4 bg-white"
(h3 :class "text-lg font-semibold mb-4" "Associated Entries")
(raw! content-html)))

File diff suppressed because it is too large Load Diff

12
cart/sexp/calendar.sexpr Normal file
View File

@@ -0,0 +1,12 @@
;; Cart calendar entry components
(defcomp ~cart-cal-entry (&key name date-str cost)
(li :class "flex items-start justify-between text-sm"
(div (div :class "font-medium" (raw! name))
(div :class "text-xs text-stone-500" (raw! date-str)))
(div :class "ml-4 font-medium" (raw! cost))))
(defcomp ~cart-cal-section (&key items-html)
(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-html))))

20
cart/sexp/checkout.sexpr Normal file
View File

@@ -0,0 +1,20 @@
;; Cart checkout error components
(defcomp ~cart-checkout-error-filter ()
(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.")))
(defcomp ~cart-checkout-error-order-id (&key order-id)
(p :class "text-xs text-rose-800/80"
"Order ID: " (span :class "font-mono" (raw! order-id))))
(defcomp ~cart-checkout-error-content (&key error-msg order-html back-url)
(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! error-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"))))

44
cart/sexp/header.sexpr Normal file
View File

@@ -0,0 +1,44 @@
;; Cart header components
(defcomp ~cart-page-label-img (&key src)
(img :src src :class "h-8 w-8 rounded-full object-cover border border-stone-300 flex-shrink-0"))
(defcomp ~cart-all-carts-link (&key href)
(a :href href :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"
(i :class "fa fa-arrow-left text-xs" :aria-hidden "true") "All carts"))
(defcomp ~cart-header-child (&key inner-html)
(div :id "root-header-child" :class "flex flex-col w-full items-center"
(raw! inner-html)))
(defcomp ~cart-header-child-nested (&key outer-html inner-html)
(div :id "root-header-child" :class "flex flex-col w-full items-center"
(raw! outer-html)
(div :id "cart-header-child" :class "flex flex-col w-full items-center"
(raw! inner-html))))
(defcomp ~cart-header-child-oob (&key inner-html)
(div :id "cart-header-child" :hx-swap-oob "outerHTML" :class "flex flex-col w-full items-center"
(raw! inner-html)))
(defcomp ~cart-auth-header-child (&key auth-html orders-html)
(div :id "root-header-child" :class "flex flex-col w-full items-center"
(raw! auth-html)
(div :id "auth-header-child" :class "flex flex-col w-full items-center"
(raw! orders-html))))
(defcomp ~cart-auth-header-child-oob (&key inner-html)
(div :id "auth-header-child" :hx-swap-oob "outerHTML" :class "flex flex-col w-full items-center"
(raw! inner-html)))
(defcomp ~cart-order-header-child (&key auth-html orders-html order-html)
(div :id "root-header-child" :class "flex flex-col w-full items-center"
(raw! auth-html)
(div :id "auth-header-child" :class "flex flex-col w-full items-center"
(raw! orders-html)
(div :id "orders-header-child" :class "flex flex-col w-full items-center"
(raw! order-html)))))
(defcomp ~cart-orders-header-child-oob (&key inner-html)
(div :id "orders-header-child" :hx-swap-oob "outerHTML" :class "flex flex-col w-full items-center"
(raw! inner-html)))

66
cart/sexp/items.sexpr Normal file
View File

@@ -0,0 +1,66 @@
;; Cart item components
(defcomp ~cart-item-img (&key src alt)
(img :src src :alt alt :class "w-24 h-24 sm:w-32 sm:h-28 object-cover rounded-xl border border-stone-100" :loading "lazy"))
(defcomp ~cart-item-no-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"))
(defcomp ~cart-item-price (&key text)
(p :class "text-sm sm:text-base font-semibold text-stone-900" (raw! text)))
(defcomp ~cart-item-price-was (&key text)
(p :class "text-xs text-stone-400 line-through" (raw! text)))
(defcomp ~cart-item-no-price ()
(p :class "text-xs text-stone-500" "No price"))
(defcomp ~cart-item-deleted ()
(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"))
(defcomp ~cart-item-brand (&key brand)
(p :class "mt-0.5 text-[0.7rem] sm:text-xs text-stone-500" (raw! brand)))
(defcomp ~cart-item-line-total (&key text)
(p :class "text-sm sm:text-base font-semibold text-stone-900" (raw! text)))
(defcomp ~cart-item (&key id img-html prod-url title brand-html deleted-html price-html qty-url csrf minus qty plus line-total-html)
(article :id id :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-html))
(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 prod-url :class "hover:text-emerald-700" (raw! title)))
(raw! brand-html) (raw! deleted-html))
(div :class "text-left sm:text-right" (raw! price-html)))
(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 qty-url :method "post" :hx-post qty-url :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" (raw! qty))
(form :action qty-url :method "post" :hx-post qty-url :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! line-total-html))))))
(defcomp ~cart-page-empty ()
(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")))))
(defcomp ~cart-page-panel (&key items-html cal-html tickets-html summary-html)
(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! items-html) (raw! cal-html) (raw! tickets-html))
(raw! summary-html)))))

View File

@@ -0,0 +1,53 @@
;; Cart single order detail components
(defcomp ~cart-order-item-img (&key src alt)
(img :src src :alt alt :class "w-full h-full object-contain object-center" :loading "lazy" :decoding "async"))
(defcomp ~cart-order-item-no-img ()
(div :class "w-full h-full flex items-center justify-center text-[9px] text-stone-400" "No image"))
(defcomp ~cart-order-item (&key prod-url img-html title product-id qty price)
(li (a :class "w-full py-2 flex gap-3" :href prod-url
(div :class "w-12 h-12 sm:w-14 sm:h-14 rounded-md bg-stone-100 flex-shrink-0 overflow-hidden" (raw! img-html))
(div :class "flex-1 flex justify-between gap-3"
(div (p :class "font-medium" (raw! title))
(p :class "text-[11px] text-stone-500" (raw! product-id)))
(div :class "text-right whitespace-nowrap"
(p (raw! qty)) (p (raw! price)))))))
(defcomp ~cart-order-items-panel (&key items-html)
(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-html))))
(defcomp ~cart-order-cal-entry (&key name pill status date-str cost)
(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! status)))
(div :class "text-xs text-stone-500" (raw! date-str)))
(div :class "ml-4 font-medium" (raw! cost))))
(defcomp ~cart-order-cal-section (&key items-html)
(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-html))))
(defcomp ~cart-order-main (&key summary-html items-html cal-html)
(div :class "max-w-full px-3 py-3 space-y-4" (raw! summary-html) (raw! items-html) (raw! cal-html)))
(defcomp ~cart-order-pay-btn (&key url)
(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"))
(defcomp ~cart-order-filter (&key info list-url recheck-url csrf pay-html)
(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" (raw! info)))
(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-html))))

51
cart/sexp/orders.sexpr Normal file
View File

@@ -0,0 +1,51 @@
;; Cart orders list components
(defcomp ~cart-order-row-desktop (&key order-id created desc total pill status detail-url)
(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! order-id)))
(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 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"))))
(defcomp ~cart-order-row-mobile (&key order-id pill status created total detail-url)
(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! order-id))
(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 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"))))))
(defcomp ~cart-orders-end ()
(tr (td :colspan "5" :class "px-3 py-4 text-center text-xs text-stone-400" "End of results")))
(defcomp ~cart-orders-empty ()
(div :class "max-w-full px-3 py-3 space-y-3"
(div :class "rounded-2xl border border-dashed border-stone-300 bg-white/80 p-4 sm:p-6 text-sm text-stone-700"
"No orders yet.")))
(defcomp ~cart-orders-table (&key rows-html)
(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-html))))))
(defcomp ~cart-orders-filter (&key search-mobile-html)
(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! search-mobile-html))))

52
cart/sexp/overview.sexpr Normal file
View File

@@ -0,0 +1,52 @@
;; Cart overview components
(defcomp ~cart-badge (&key icon text)
(span :class "inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-stone-100"
(i :class icon :aria-hidden "true") (raw! text)))
(defcomp ~cart-badges-wrap (&key badges-html)
(div :class "mt-1 flex flex-wrap gap-2 text-xs text-stone-600"
(raw! badges-html)))
(defcomp ~cart-group-card-img (&key src alt)
(img :src src :alt alt :class "h-16 w-16 rounded-xl object-cover border border-stone-200 flex-shrink-0"))
(defcomp ~cart-group-card-placeholder ()
(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")))
(defcomp ~cart-mp-subtitle (&key title)
(p :class "text-xs text-stone-500 truncate" (raw! title)))
(defcomp ~cart-group-card (&key href img-html display-title subtitle-html badges-html total)
(a :href href :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-html)
(div :class "flex-1 min-w-0"
(h3 :class "text-base sm:text-lg font-semibold text-stone-900 truncate" (raw! display-title))
(raw! subtitle-html) (raw! badges-html))
(div :class "text-right flex-shrink-0"
(div :class "text-lg font-bold text-stone-900" (raw! total))
(div :class "mt-1 text-xs text-emerald-700 font-medium" "View cart \u2192")))))
(defcomp ~cart-orphan-card (&key badges-html total)
(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! badges-html))
(div :class "text-right flex-shrink-0"
(div :class "text-lg font-bold text-stone-900" (raw! total))))))
(defcomp ~cart-empty ()
(div :class "max-w-full px-3 py-3 space-y-3"
(div :class "rounded-2xl border border-dashed border-stone-300 bg-white/80 p-6 sm:p-8 text-center"
(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"))))
(defcomp ~cart-overview-panel (&key cards-html)
(div :class "max-w-full px-3 py-3 space-y-3"
(div :class "space-y-4" (raw! cards-html))))

View File

@@ -6,15 +6,19 @@ Called from route handlers in place of ``render_template()``.
""" """
from __future__ import annotations from __future__ import annotations
import os
from typing import Any from typing import Any
from shared.sexp.jinja_bridge import sexp from shared.sexp.jinja_bridge import render, load_service_components
from shared.sexp.helpers import ( from shared.sexp.helpers import (
call_url, root_header_html, search_desktop_html, call_url, root_header_html, search_desktop_html,
search_mobile_html, full_page, oob_page, search_mobile_html, full_page, oob_page,
) )
from shared.infrastructure.urls import market_product_url, cart_url from shared.infrastructure.urls import market_product_url, cart_url
# Load cart-specific .sexpr components at import time
load_service_components(os.path.dirname(os.path.dirname(__file__)))
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Header helpers # Header helpers
@@ -22,12 +26,12 @@ from shared.infrastructure.urls import market_product_url, cart_url
def _cart_header_html(ctx: dict, *, oob: bool = False) -> str: def _cart_header_html(ctx: dict, *, oob: bool = False) -> str:
"""Build the cart section header row.""" """Build the cart section header row."""
return sexp( return render(
'(~menu-row :id "cart-row" :level 1 :colour "sky"' "menu-row",
' :link-href lh :link-label "cart" :icon "fa fa-shopping-cart"' id="cart-row", level=1, colour="sky",
' :child-id "cart-header-child" :oob oob)', link_href=call_url(ctx, "cart_url", "/"),
lh=call_url(ctx, "cart_url", "/"), link_label="cart", icon="fa fa-shopping-cart",
oob=oob, child_id="cart-header-child", oob=oob,
) )
@@ -37,44 +41,35 @@ def _page_cart_header_html(ctx: dict, page_post: Any, *, oob: bool = False) -> s
title = ((page_post.title if page_post else None) or "")[:160] title = ((page_post.title if page_post else None) or "")[:160]
label_html = "" label_html = ""
if page_post and page_post.feature_image: if page_post and page_post.feature_image:
label_html += sexp( label_html += render("cart-page-label-img", src=page_post.feature_image)
'(img :src fi :class "h-8 w-8 rounded-full object-cover border border-stone-300 flex-shrink-0")', label_html += f"<span>{title}</span>"
fi=page_post.feature_image, nav_html = render("cart-all-carts-link", href=call_url(ctx, "cart_url", "/"))
) return render(
label_html += sexp('(span t)', t=title) "menu-row",
nav_html = sexp( id="page-cart-row", level=2, colour="sky",
'(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"' link_href=call_url(ctx, "cart_url", f"/{slug}/"),
' (i :class "fa fa-arrow-left text-xs" :aria-hidden "true") "All carts")', link_label_html=label_html, nav_html=nav_html, oob=oob,
h=call_url(ctx, "cart_url", "/"),
)
return sexp(
'(~menu-row :id "page-cart-row" :level 2 :colour "sky"'
' :link-href lh :link-label-html llh :nav-html nh :oob oob)',
lh=call_url(ctx, "cart_url", f"/{slug}/"),
llh=label_html,
nh=nav_html,
oob=oob,
) )
def _auth_header_html(ctx: dict, *, oob: bool = False) -> str: def _auth_header_html(ctx: dict, *, oob: bool = False) -> str:
"""Build the account section header row (for orders).""" """Build the account section header row (for orders)."""
return sexp( return render(
'(~menu-row :id "auth-row" :level 1 :colour "sky"' "menu-row",
' :link-href lh :link-label "account" :icon "fa-solid fa-user"' id="auth-row", level=1, colour="sky",
' :child-id "auth-header-child" :oob oob)', link_href=call_url(ctx, "account_url", "/"),
lh=call_url(ctx, "account_url", "/"), link_label="account", icon="fa-solid fa-user",
oob=oob, child_id="auth-header-child", oob=oob,
) )
def _orders_header_html(ctx: dict, list_url: str) -> str: def _orders_header_html(ctx: dict, list_url: str) -> str:
"""Build the orders section header row.""" """Build the orders section header row."""
return sexp( return render(
'(~menu-row :id "orders-row" :level 2 :colour "sky"' "menu-row",
' :link-href lh :link-label "Orders" :icon "fa fa-gbp"' id="orders-row", level=2, colour="sky",
' :child-id "orders-header-child")', link_href=list_url, link_label="Orders", icon="fa fa-gbp",
lh=list_url, child_id="orders-header-child",
) )
@@ -85,11 +80,7 @@ def _orders_header_html(ctx: dict, list_url: str) -> str:
def _badge_html(icon: str, count: int, label: str) -> str: def _badge_html(icon: str, count: int, label: str) -> str:
"""Render a count badge.""" """Render a count badge."""
s = "s" if count != 1 else "" s = "s" if count != 1 else ""
return sexp( return render("cart-badge", icon=icon, text=f"{count} {label}{s}")
'(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: def _page_group_card_html(grp: Any, ctx: dict) -> str:
@@ -115,10 +106,7 @@ def _page_group_card_html(grp: Any, ctx: dict) -> str:
badges += _badge_html("fa fa-calendar", calendar_count, "booking") badges += _badge_html("fa fa-calendar", calendar_count, "booking")
if ticket_count > 0: if ticket_count > 0:
badges += _badge_html("fa fa-ticket", ticket_count, "ticket") badges += _badge_html("fa fa-ticket", ticket_count, "ticket")
badges_html = sexp( badges_html = render("cart-badges-wrap", badges_html=badges)
'(div :class "mt-1 flex flex-wrap gap-2 text-xs text-stone-600" (raw! b))',
b=badges,
)
if post: if post:
slug = post.slug if hasattr(post, "slug") else post.get("slug", "") slug = post.slug if hasattr(post, "slug") else post.get("slug", "")
@@ -127,66 +115,38 @@ def _page_group_card_html(grp: Any, ctx: dict) -> str:
cart_href = call_url(ctx, "cart_url", f"/{slug}/") cart_href = call_url(ctx, "cart_url", f"/{slug}/")
if feature_image: if feature_image:
img = sexp( img = render("cart-group-card-img", src=feature_image, alt=title)
'(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: else:
img = sexp( img = render("cart-group-card-placeholder")
'(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 = "" mp_sub = ""
if market_place: if market_place:
mp_name = market_place.name if hasattr(market_place, "name") else market_place.get("name", "") mp_name = market_place.name if hasattr(market_place, "name") else market_place.get("name", "")
mp_sub = sexp('(p :class "text-xs text-stone-500 truncate" t)', t=title) mp_sub = render("cart-mp-subtitle", title=title)
else:
mp_name = ""
display_title = mp_name or title display_title = mp_name or title
return sexp( return render(
'(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"' "cart-group-card",
' (div :class "flex items-start gap-4"' href=cart_href, img_html=img, display_title=display_title,
' (raw! img)' subtitle_html=mp_sub, badges_html=badges_html,
' (div :class "flex-1 min-w-0"' total=f"\u00a3{total:.2f}",
' (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: else:
# Orphan items — use amber badges # Orphan items — use amber badges
badges_amber = badges.replace("bg-stone-100", "bg-amber-100") badges_amber = badges.replace("bg-stone-100", "bg-amber-100")
badges_html_amber = sexp( badges_html_amber = render("cart-badges-wrap", badges_html=badges_amber)
'(div :class "mt-1 flex flex-wrap gap-2 text-xs text-stone-600" (raw! b))', return render(
b=badges_amber, "cart-orphan-card",
) badges_html=badges_html_amber,
return sexp( total=f"\u00a3{total:.2f}",
'(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: def _empty_cart_html() -> str:
"""Empty cart state.""" """Empty cart state."""
return sexp( return render("cart-empty")
'(div :class "max-w-full px-3 py-3 space-y-3"'
' (div :class "rounded-2xl border border-dashed border-stone-300 bg-white/80 p-6 sm:p-8 text-center"'
' (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: def _overview_main_panel_html(page_groups: list, ctx: dict) -> str:
@@ -199,11 +159,7 @@ def _overview_main_panel_html(page_groups: list, ctx: dict) -> str:
if not has_items: if not has_items:
return _empty_cart_html() return _empty_cart_html()
return sexp( return render("cart-overview-panel", cards_html="".join(cards))
'(div :class "max-w-full px-3 py-3 space-y-3"'
' (div :class "space-y-4" (raw! c)))',
c="".join(cards),
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -225,78 +181,38 @@ def _cart_item_html(item: Any, ctx: dict) -> str:
prod_url = market_product_url(slug) prod_url = market_product_url(slug)
if p.image: if p.image:
img = sexp( img = render("cart-item-img", src=p.image, alt=p.title)
'(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: else:
img = sexp( img = render("cart-item-no-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")',
)
price_html = "" price_html = ""
if unit_price: if unit_price:
price_html = sexp( price_html = render("cart-item-price", text=f"{symbol}{unit_price:.2f}")
'(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: if p.special_price and p.special_price != p.regular_price:
price_html += sexp( price_html += render("cart-item-price-was", text=f"{symbol}{p.regular_price:.2f}")
'(p :class "text-xs text-stone-400 line-through" ps)',
ps=f"{symbol}{p.regular_price:.2f}",
)
else: else:
price_html = sexp('(p :class "text-xs text-stone-500" "No price")') price_html = render("cart-item-no-price")
deleted_html = "" deleted_html = ""
if getattr(item, "is_deleted", False): if getattr(item, "is_deleted", False):
deleted_html = sexp( deleted_html = render("cart-item-deleted")
'(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 = "" brand_html = ""
if getattr(p, "brand", None): 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) brand_html = render("cart-item-brand", brand=p.brand)
line_total_html = "" line_total_html = ""
if unit_price: if unit_price:
lt = unit_price * item.quantity lt = unit_price * item.quantity
line_total_html = sexp( line_total_html = render("cart-item-line-total", text=f"Line total: {symbol}{lt:.2f}")
'(p :class "text-sm sm:text-base font-semibold text-stone-900" lt)',
lt=f"Line total: {symbol}{lt:.2f}",
)
return sexp( return render(
'(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"' "cart-item",
' (div :class "w-full sm:w-32 shrink-0 flex justify-center sm:block" (raw! img))' id=f"cart-item-{slug}", img_html=img, prod_url=prod_url, title=p.title,
' (div :class "flex-1 min-w-0"' brand_html=brand_html, deleted_html=deleted_html, price_html=price_html,
' (div :class "flex flex-col sm:flex-row sm:items-start justify-between gap-2 sm:gap-3"' qty_url=qty_url, csrf=csrf, minus=str(item.quantity - 1),
' (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), qty=str(item.quantity), plus=str(item.quantity + 1),
lth=line_total_html, line_total_html=line_total_html,
) )
@@ -311,19 +227,11 @@ def _calendar_entries_html(entries: list) -> str:
end = getattr(e, "end_at", None) end = getattr(e, "end_at", None)
cost = getattr(e, "cost", 0) or 0 cost = getattr(e, "cost", 0) or 0
end_str = f" \u2013 {end}" if end else "" end_str = f" \u2013 {end}" if end else ""
items += sexp( items += render(
'(li :class "flex items-start justify-between text-sm"' "cart-cal-entry",
' (div (div :class "font-medium" nm)' name=name, date_str=f"{start}{end_str}", cost=f"\u00a3{cost:.2f}",
' (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 sexp( return render("cart-cal-section", items_html=items)
'(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,
)
def _ticket_groups_html(ticket_groups: list, ctx: dict) -> str: def _ticket_groups_html(ticket_groups: list, ctx: dict) -> str:
@@ -352,51 +260,19 @@ def _ticket_groups_html(ticket_groups: list, ctx: dict) -> str:
if end_at: if end_at:
date_str += f" \u2013 {end_at.strftime('%-d %b %Y, %H:%M')}" date_str += f" \u2013 {end_at.strftime('%-d %b %Y, %H:%M')}"
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_name_html = render("cart-ticket-type-name", name=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 "" tt_hidden = render("cart-ticket-type-hidden", value=str(tt_id)) if tt_id else ""
items += sexp( items += render(
'(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"' "cart-ticket-article",
' (div :class "flex-1 min-w-0"' name=name, type_name_html=tt_name_html, date_str=date_str,
' (div :class "flex flex-col sm:flex-row sm:items-start justify-between gap-2 sm:gap-3"' price=f"\u00a3{price or 0:.2f}", qty_url=qty_url, csrf=csrf,
' (div :class "min-w-0"' entry_id=str(entry_id), type_hidden_html=tt_hidden,
' (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), minus=str(max(quantity - 1, 0)), qty=str(quantity),
plus=str(quantity + 1), lt=f"Line total: \u00a3{line_total:.2f}", plus=str(quantity + 1), line_total=f"Line total: \u00a3{line_total:.2f}",
) )
return sexp( return render("cart-tickets-section", items_html=items)
'(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, def _cart_summary_html(ctx: dict, cart: list, cal_entries: list, tickets: list,
@@ -431,36 +307,18 @@ def _cart_summary_html(ctx: dict, cart: list, cal_entries: list, tickets: list,
action = url_for("cart_global.checkout") action = url_for("cart_global.checkout")
from shared.utils import route_prefix from shared.utils import route_prefix
action = route_prefix() + action action = route_prefix() + action
checkout_html = sexp( checkout_html = render(
'(form :method "post" :action act :class "w-full"' "cart-checkout-form",
' (input :type "hidden" :name "csrf_token" :value csrf)' action=action, csrf=csrf, label=f" Checkout as {user.email}",
' (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: else:
href = login_url(request.url) href = login_url(request.url)
checkout_html = sexp( checkout_html = render("cart-checkout-signin", href=href)
'(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 sexp( return render(
'(aside :id "cart-summary" :class "lg:pl-2"' "cart-summary-panel",
' (div :class "rounded-2xl bg-white shadow-sm border border-stone-200 p-4 sm:p-5"' item_count=str(item_count), subtotal=f"{symbol}{grand:.2f}",
' (h2 :class "text-sm sm:text-base font-semibold text-stone-900 mb-3 sm:mb-4" "Order summary")' checkout_html=checkout_html,
' (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,
) )
@@ -470,26 +328,17 @@ def _page_cart_main_panel_html(ctx: dict, cart: list, cal_entries: list,
ticket_total_fn: Any) -> str: ticket_total_fn: Any) -> str:
"""Page cart main panel.""" """Page cart main panel."""
if not cart and not cal_entries and not tickets: if not cart and not cal_entries and not tickets:
return sexp( return render("cart-page-empty")
'(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) items_html = "".join(_cart_item_html(item, ctx) for item in cart)
cal_html = _calendar_entries_html(cal_entries) cal_html = _calendar_entries_html(cal_entries)
tickets_html = _ticket_groups_html(ticket_groups, ctx) 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) summary_html = _cart_summary_html(ctx, cart, cal_entries, tickets, total_fn, cal_total_fn, ticket_total_fn)
return sexp( return render(
'(div :class "max-w-full px-3 py-3 space-y-3"' "cart-page-panel",
' (div :id "cart"' items_html=items_html, cal_html=cal_html,
' (div (section :class "space-y-3 sm:space-y-4" (raw! ih) (raw! ch) (raw! th))' tickets_html=tickets_html, summary_html=summary_html,
' (raw! sh))))',
ih=items_html, ch=cal_html, th=tickets_html, sh=summary_html,
) )
@@ -506,35 +355,21 @@ def _order_row_html(order: Any, detail_url: str) -> str:
else "border-rose-300 bg-rose-50 text-rose-700" if sl in ("failed", "cancelled") 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" else "border-stone-300 bg-stone-50 text-stone-700"
) )
pill_cls = f"inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] sm:text-xs {pill}"
created = order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else "\u2014" 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}" total = f"{order.currency or 'GBP'} {order.total_amount or 0:.2f}"
desktop = sexp( desktop = render(
'(tr :class "hidden sm:table-row border-t border-stone-100 hover:bg-stone-50/60"' "cart-order-row-desktop",
' (td :class "px-3 py-2 align-top" (span :class "font-mono text-[11px] sm:text-xs" oid))' order_id=f"#{order.id}", created=created, desc=order.description or "",
' (td :class "px-3 py-2 align-top text-stone-700 text-xs sm:text-sm" cr)' total=total, pill=pill_cls, status=status, detail_url=detail_url,
' (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( mobile_pill = f"inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] {pill}"
'(tr :class "sm:hidden border-t border-stone-100"' mobile = render(
' (td :colspan "5" :class "px-3 py-3"' "cart-order-row-mobile",
' (div :class "flex flex-col gap-2 text-xs"' order_id=f"#{order.id}", pill=mobile_pill, status=status,
' (div :class "flex items-center justify-between gap-2"' created=created, total=total, detail_url=detail_url,
' (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 return desktop + mobile
@@ -553,14 +388,13 @@ def _orders_rows_html(orders: list, page: int, total_pages: int,
if page < total_pages: if page < total_pages:
next_url = pfx + url_for_fn("orders.list_orders") + qs_fn(page=page + 1) next_url = pfx + url_for_fn("orders.list_orders") + qs_fn(page=page + 1)
parts.append(sexp( parts.append(render(
'(~infinite-scroll :url u :page p :total-pages tp :id-prefix "orders" :colspan 5)', "infinite-scroll",
u=next_url, p=page, **{"total-pages": total_pages}, url=next_url, page=page, total_pages=total_pages,
id_prefix="orders", colspan=5,
)) ))
else: else:
parts.append(sexp( parts.append(render("cart-orders-end"))
'(tr (td :colspan "5" :class "px-3 py-4 text-center text-xs text-stone-400" "End of results"))',
))
return "".join(parts) return "".join(parts)
@@ -568,37 +402,13 @@ def _orders_rows_html(orders: list, page: int, total_pages: int,
def _orders_main_panel_html(orders: list, rows_html: str) -> str: def _orders_main_panel_html(orders: list, rows_html: str) -> str:
"""Main panel for orders list.""" """Main panel for orders list."""
if not orders: if not orders:
return sexp( return render("cart-orders-empty")
'(div :class "max-w-full px-3 py-3 space-y-3"' return render("cart-orders-table", rows_html=rows_html)
' (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 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: def _orders_summary_html(ctx: dict) -> str:
"""Filter section for orders list.""" """Filter section for orders list."""
return sexp( return render("cart-orders-filter", search_mobile_html=search_mobile_html(ctx))
'(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),
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -613,43 +423,31 @@ def _order_items_html(order: Any) -> str:
for item in order.items: for item in order.items:
prod_url = market_product_url(item.product_slug) prod_url = market_product_url(item.product_slug)
if item.product_image: if item.product_image:
img = sexp( img = render(
'(img :src pi :alt pt :class "w-full h-full object-contain object-center" :loading "lazy" :decoding "async")', "cart-order-item-img",
pi=item.product_image, pt=item.product_title or "Product image", src=item.product_image, alt=item.product_title or "Product image",
) )
else: else:
img = sexp( img = render("cart-order-item-no-img")
'(div :class "w-full h-full flex items-center justify-center text-[9px] text-stone-400" "No image")', items += render(
) "cart-order-item",
items += sexp( prod_url=prod_url, img_html=img,
'(li (a :class "w-full py-2 flex gap-3" :href pu' title=item.product_title or "Unknown product",
' (div :class "w-12 h-12 sm:w-14 sm:h-14 rounded-md bg-stone-100 flex-shrink-0 overflow-hidden" (raw! img))' product_id=f"Product ID: {item.product_id}",
' (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}", qty=f"Qty: {item.quantity}",
pr=f"{item.currency or order.currency or 'GBP'} {item.unit_price or 0:.2f}", price=f"{item.currency or order.currency or 'GBP'} {item.unit_price or 0:.2f}",
) )
return sexp( return render("cart-order-items-panel", items_html=items)
'(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,
)
def _order_summary_html(order: Any) -> str: def _order_summary_html(order: Any) -> str:
"""Order summary card.""" """Order summary card."""
return sexp( return render(
'(~order-summary-card :order-id oid :created-at ca :description d :status s :currency c :total-amount ta)', "order-summary-card",
oid=order.id, order_id=order.id,
ca=order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else None, created_at=order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else None,
d=order.description, s=order.status, c=order.currency, description=order.description, status=order.status, currency=order.currency,
ta=f"{order.total_amount:.2f}" if order.total_amount else None, total_amount=f"{order.total_amount:.2f}" if order.total_amount else None,
) )
@@ -666,31 +464,25 @@ def _order_calendar_items_html(calendar_entries: list | None) -> str:
else "bg-blue-100 text-blue-800" if st == "ordered" else "bg-blue-100 text-blue-800" if st == "ordered"
else "bg-stone-100 text-stone-700" else "bg-stone-100 text-stone-700"
) )
pill_cls = f"inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium {pill}"
ds = e.start_at.strftime("%-d %b %Y, %H:%M") if e.start_at else "" ds = e.start_at.strftime("%-d %b %Y, %H:%M") if e.start_at else ""
if e.end_at: if e.end_at:
ds += f" \u2013 {e.end_at.strftime('%-d %b %Y, %H:%M')}" ds += f" \u2013 {e.end_at.strftime('%-d %b %Y, %H:%M')}"
items += sexp( items += render(
'(li :class "px-4 py-3 flex items-start justify-between text-sm"' "cart-order-cal-entry",
' (div (div :class "font-medium flex items-center gap-2"' name=e.name, pill=pill_cls, status=st.capitalize(),
' nm (span :class (str "inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium " pill) sc))' date_str=ds, cost=f"\u00a3{e.cost or 0:.2f}",
' (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 sexp( return render("cart-order-cal-section", items_html=items)
'(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: def _order_main_html(order: Any, calendar_entries: list | None) -> str:
"""Main panel for single order detail.""" """Main panel for single order detail."""
summary = _order_summary_html(order) summary = _order_summary_html(order)
return sexp( return render(
'(div :class "max-w-full px-3 py-3 space-y-4" (raw! s) (raw! oi) (raw! ci))', "cart-order-main",
s=summary, oi=_order_items_html(order), ci=_order_calendar_items_html(calendar_entries), summary_html=summary, items_html=_order_items_html(order),
cal_html=_order_calendar_items_html(calendar_entries),
) )
@@ -702,26 +494,12 @@ def _order_filter_html(order: Any, list_url: str, recheck_url: str,
pay = "" pay = ""
if status != "paid": if status != "paid":
pay = sexp( pay = render("cart-order-pay-btn", url=pay_url)
'(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( return render(
'(header :class "mb-6 sm:mb-8 flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4"' "cart-order-filter",
' (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}", info=f"Placed {created} \u00b7 Status: {status}",
lu=list_url, ru=recheck_url, csrf=csrf_token, pay=pay, list_url=list_url, recheck_url=recheck_url, csrf=csrf_token, pay_html=pay,
) )
@@ -733,10 +511,7 @@ async def render_overview_page(ctx: dict, page_groups: list) -> str:
"""Full page: cart overview.""" """Full page: cart overview."""
main = _overview_main_panel_html(page_groups, ctx) main = _overview_main_panel_html(page_groups, ctx)
hdr = root_header_html(ctx) hdr = root_header_html(ctx)
hdr += sexp( hdr += render("cart-header-child", inner_html=_cart_header_html(ctx))
'(div :id "root-header-child" :class "flex flex-col w-full items-center" (raw! c))',
c=_cart_header_html(ctx),
)
return full_page(ctx, header_rows_html=hdr, content_html=main) return full_page(ctx, header_rows_html=hdr, content_html=main)
@@ -764,10 +539,9 @@ async def render_page_cart_page(ctx: dict, page_post: Any,
hdr = root_header_html(ctx) hdr = root_header_html(ctx)
child = _cart_header_html(ctx) child = _cart_header_html(ctx)
page_hdr = _page_cart_header_html(ctx, page_post) page_hdr = _page_cart_header_html(ctx, page_post)
hdr += sexp( hdr += render(
'(div :id "root-header-child" :class "flex flex-col w-full items-center" (raw! c)' "cart-header-child-nested",
' (div :id "cart-header-child" :class "flex flex-col w-full items-center" (raw! p)))', outer_html=child, inner_html=page_hdr,
c=child, p=page_hdr,
) )
return full_page(ctx, header_rows_html=hdr, content_html=main) return full_page(ctx, header_rows_html=hdr, content_html=main)
@@ -780,8 +554,7 @@ async def render_page_cart_oob(ctx: dict, page_post: Any,
main = _page_cart_main_panel_html(ctx, cart, cal_entries, tickets, ticket_groups, main = _page_cart_main_panel_html(ctx, cart, cal_entries, tickets, ticket_groups,
total_fn, cal_total_fn, ticket_total_fn) total_fn, cal_total_fn, ticket_total_fn)
oobs = ( oobs = (
sexp('(div :id "cart-header-child" :hx-swap-oob "outerHTML" :class "flex flex-col w-full items-center" (raw! p))', render("cart-header-child-oob", inner_html=_page_cart_header_html(ctx, page_post))
p=_page_cart_header_html(ctx, page_post))
+ _cart_header_html(ctx, oob=True) + _cart_header_html(ctx, oob=True)
+ root_header_html(ctx, oob=True) + root_header_html(ctx, oob=True)
) )
@@ -807,10 +580,10 @@ async def render_orders_page(ctx: dict, orders: list, page: int,
main = _orders_main_panel_html(orders, rows) main = _orders_main_panel_html(orders, rows)
hdr = root_header_html(ctx) hdr = root_header_html(ctx)
hdr += sexp( hdr += render(
'(div :id "root-header-child" :class "flex flex-col w-full items-center" (raw! a)' "cart-auth-header-child",
' (div :id "auth-header-child" :class "flex flex-col w-full items-center" (raw! o)))', auth_html=_auth_header_html(ctx),
a=_auth_header_html(ctx), o=_orders_header_html(ctx, list_url), orders_html=_orders_header_html(ctx, list_url),
) )
return full_page(ctx, header_rows_html=hdr, return full_page(ctx, header_rows_html=hdr,
@@ -842,10 +615,9 @@ async def render_orders_oob(ctx: dict, orders: list, page: int,
oobs = ( oobs = (
_auth_header_html(ctx, oob=True) _auth_header_html(ctx, oob=True)
+ sexp( + render(
'(div :id "auth-header-child" :hx-swap-oob "outerHTML"' "cart-auth-header-child-oob",
' :class "flex flex-col w-full items-center" (raw! o))', inner_html=_orders_header_html(ctx, list_url),
o=_orders_header_html(ctx, list_url),
) )
+ root_header_html(ctx, oob=True) + root_header_html(ctx, oob=True)
) )
@@ -877,17 +649,16 @@ async def render_order_page(ctx: dict, order: Any,
filt = _order_filter_html(order, list_url, recheck_url, pay_url, generate_csrf_token()) filt = _order_filter_html(order, list_url, recheck_url, pay_url, generate_csrf_token())
hdr = root_header_html(ctx) hdr = root_header_html(ctx)
order_row = sexp( order_row = render(
'(~menu-row :id "order-row" :level 3 :colour "sky" :link-href lh :link-label ll :icon "fa fa-gbp")', "menu-row",
lh=detail_url, ll=f"Order {order.id}", id="order-row", level=3, colour="sky",
link_href=detail_url, link_label=f"Order {order.id}", icon="fa fa-gbp",
) )
hdr += sexp( hdr += render(
'(div :id "root-header-child" :class "flex flex-col w-full items-center" (raw! a)' "cart-order-header-child",
' (div :id "auth-header-child" :class "flex flex-col w-full items-center" (raw! b)' auth_html=_auth_header_html(ctx),
' (div :id "orders-header-child" :class "flex flex-col w-full items-center" (raw! c))))', orders_html=_orders_header_html(ctx, list_url),
a=_auth_header_html(ctx), order_html=order_row,
b=_orders_header_html(ctx, list_url),
c=order_row,
) )
return full_page(ctx, header_rows_html=hdr, filter_html=filt, content_html=main) return full_page(ctx, header_rows_html=hdr, filter_html=filt, content_html=main)
@@ -909,12 +680,14 @@ async def render_order_oob(ctx: dict, order: Any,
main = _order_main_html(order, calendar_entries) main = _order_main_html(order, calendar_entries)
filt = _order_filter_html(order, list_url, recheck_url, pay_url, generate_csrf_token()) filt = _order_filter_html(order, list_url, recheck_url, pay_url, generate_csrf_token())
order_row_oob = sexp( order_row_oob = render(
'(~menu-row :id "order-row" :level 3 :colour "sky" :link-href lh :link-label ll :icon "fa fa-gbp" :oob true)', "menu-row",
lh=detail_url, ll=f"Order {order.id}", id="order-row", level=3, colour="sky",
link_href=detail_url, link_label=f"Order {order.id}", icon="fa fa-gbp",
oob=True,
) )
oobs = ( oobs = (
sexp('(div :id "orders-header-child" :hx-swap-oob "outerHTML" :class "flex flex-col w-full items-center" (raw! o))', o=order_row_oob) render("cart-orders-header-child-oob", inner_html=order_row_oob)
+ root_header_html(ctx, oob=True) + root_header_html(ctx, oob=True)
) )
@@ -926,43 +699,25 @@ async def render_order_oob(ctx: dict, order: Any,
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _checkout_error_filter_html() -> str: def _checkout_error_filter_html() -> str:
return sexp( return render("cart-checkout-error-filter")
'(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."))',
)
def _checkout_error_content_html(error: str | None, order: Any | None) -> str: def _checkout_error_content_html(error: str | None, order: Any | None) -> str:
err_msg = error or "Unexpected error while creating the hosted checkout session." err_msg = error or "Unexpected error while creating the hosted checkout session."
order_html = "" order_html = ""
if order: if order:
order_html = sexp( order_html = render("cart-checkout-error-order-id", order_id=f"#{order.id}")
'(p :class "text-xs text-rose-800/80"'
' "Order ID: " (span :class "font-mono" oid))',
oid=f"#{order.id}",
)
back_url = cart_url("/") back_url = cart_url("/")
return sexp( return render(
'(div :class "max-w-full px-3 py-3 space-y-4"' "cart-checkout-error-content",
' (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"' error_msg=err_msg, order_html=order_html, back_url=back_url,
' (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,
) )
async def render_checkout_error_page(ctx: dict, error: str | None = None, order: Any | None = None) -> str: async def render_checkout_error_page(ctx: dict, error: str | None = None, order: Any | None = None) -> str:
"""Full page: checkout error.""" """Full page: checkout error."""
hdr = root_header_html(ctx) hdr = root_header_html(ctx)
hdr += sexp( hdr += render("cart-header-child", inner_html=_cart_header_html(ctx))
'(div :id "root-header-child" :class "flex flex-col w-full items-center" (raw! c))',
c=_cart_header_html(ctx),
)
filt = _checkout_error_filter_html() filt = _checkout_error_filter_html()
content = _checkout_error_content_html(error, order) content = _checkout_error_content_html(error, order)
return full_page(ctx, header_rows_html=hdr, filter_html=filt, content_html=content) return full_page(ctx, header_rows_html=hdr, filter_html=filt, content_html=content)

26
cart/sexp/summary.sexpr Normal file
View File

@@ -0,0 +1,26 @@
;; Cart summary / checkout components
(defcomp ~cart-checkout-form (&key action csrf label)
(form :method "post" :action action :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") (raw! label))))
(defcomp ~cart-checkout-signin (&key href)
(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"
(i :class "fa-solid fa-key") (span "sign in or register to checkout"))))
(defcomp ~cart-summary-panel (&key item-count subtotal checkout-html)
(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" (raw! item-count)))
(div :class "flex items-center justify-between"
(dt :class "text-stone-600" "Subtotal") (dd :class "text-stone-900" (raw! subtotal))))
(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! checkout-html)))))

42
cart/sexp/tickets.sexpr Normal file
View File

@@ -0,0 +1,42 @@
;; Cart ticket components
(defcomp ~cart-ticket-type-name (&key name)
(p :class "mt-0.5 text-[0.7rem] sm:text-xs text-stone-500" (raw! name)))
(defcomp ~cart-ticket-type-hidden (&key value)
(input :type "hidden" :name "ticket_type_id" :value value))
(defcomp ~cart-ticket-article (&key name type-name-html date-str price qty-url csrf entry-id type-hidden-html minus qty plus line-total)
(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" (raw! name))
(raw! type-name-html)
(p :class "mt-0.5 text-[0.7rem] sm:text-xs text-stone-500" (raw! date-str)))
(div :class "text-left sm:text-right"
(p :class "text-sm sm:text-base font-semibold text-stone-900" (raw! price))))
(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 qty-url :method "post" :hx-post qty-url :hx-swap "none"
(input :type "hidden" :name "csrf_token" :value csrf)
(input :type "hidden" :name "entry_id" :value entry-id)
(raw! type-hidden-html)
(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" (raw! qty))
(form :action qty-url :method "post" :hx-post qty-url :hx-swap "none"
(input :type "hidden" :name "csrf_token" :value csrf)
(input :type "hidden" :name "entry_id" :value entry-id)
(raw! type-hidden-html)
(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" (raw! line-total)))))))
(defcomp ~cart-tickets-section (&key items-html)
(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-html))))

View File

@@ -66,7 +66,8 @@ def register():
try: try:
await svc_create_calendar(g.s, post_id, name) await svc_create_calendar(g.s, post_id, name)
except Exception as e: except Exception as e:
return await make_response(f'<div class="text-red-600 text-sm">{e}</div>', 422) from shared.sexp.jinja_bridge import render as render_comp
return await make_response(render_comp("error-inline", message=str(e)), 422)
from shared.sexp.page import get_template_context from shared.sexp.page import get_template_context
from sexp.sexp_components import render_calendars_list_panel from sexp.sexp_components import render_calendars_list_panel

View File

@@ -37,7 +37,7 @@ def register():
async def _container_nav_handler(): async def _container_nav_handler():
from quart import current_app from quart import current_app
from shared.infrastructure.urls import events_url from shared.infrastructure.urls import events_url
from shared.sexp.jinja_bridge import sexp as render_sexp from shared.sexp.jinja_bridge import render as render_comp
container_type = request.args.get("container_type", "page") container_type = request.args.get("container_type", "page")
container_id = int(request.args.get("container_id", 0)) container_id = int(request.args.get("container_id", 0))
@@ -65,21 +65,21 @@ def register():
date_str = entry.start_at.strftime("%b %d, %Y at %H:%M") date_str = entry.start_at.strftime("%b %d, %Y at %H:%M")
if entry.end_at: if entry.end_at:
date_str += f" {entry.end_at.strftime('%H:%M')}" date_str += f" {entry.end_at.strftime('%H:%M')}"
html_parts.append(render_sexp( html_parts.append(render_comp(
'(~calendar-entry-nav :href href :name name :date-str date-str :nav-class nav-class)', "calendar-entry-nav",
href=events_url(entry_path), name=entry.name, href=events_url(entry_path), name=entry.name,
**{"date-str": date_str, "nav-class": nav_class}, date_str=date_str, nav_class=nav_class,
)) ))
# Infinite scroll sentinel (kept as raw HTML — HTMX-specific) # Infinite scroll sentinel (kept as raw HTML — HTMX-specific)
if has_more and paginate_url_base: if has_more and paginate_url_base:
html_parts.append( html_parts.append(render_comp(
f'<div id="entries-load-sentinel-{page}"' "htmx-sentinel",
f' hx-get="{paginate_url_base}?page={page + 1}"' id=f"entries-load-sentinel-{page}",
f' hx-trigger="intersect once"' hx_get=f"{paginate_url_base}?page={page + 1}",
f' hx-swap="beforebegin"' hx_trigger="intersect once",
f' _="on htmx:afterRequest trigger scroll on #associated-entries-container"' hx_swap="beforebegin",
f' class="flex-shrink-0 w-1"></div>' **{"class": "flex-shrink-0 w-1"},
) ))
# Calendar links nav # Calendar links nav
if not any(e.startswith("calendar") for e in excludes): if not any(e.startswith("calendar") for e in excludes):
@@ -88,9 +88,9 @@ def register():
) )
for cal in calendars: for cal in calendars:
href = events_url(f"/{post_slug}/{cal.slug}/") href = events_url(f"/{post_slug}/{cal.slug}/")
html_parts.append(render_sexp( html_parts.append(render_comp(
'(~calendar-link-nav :href href :name name :nav-class nav-class)', "calendar-link-nav",
href=href, name=cal.name, **{"nav-class": nav_class}, href=href, name=cal.name, nav_class=nav_class,
)) ))
return "\n".join(html_parts) return "\n".join(html_parts)
@@ -125,6 +125,7 @@ def register():
async def _account_nav_item_handler(): async def _account_nav_item_handler():
from quart import current_app from quart import current_app
from shared.infrastructure.urls import account_url from shared.infrastructure.urls import account_url
from shared.sexp.jinja_bridge import render as render_comp
styles = current_app.jinja_env.globals.get("styles", {}) styles = current_app.jinja_env.globals.get("styles", {})
nav_class = styles.get("nav_button", "") nav_class = styles.get("nav_button", "")
@@ -138,12 +139,10 @@ def register():
# hx-* attributes that don't map neatly to a reusable component. # hx-* attributes that don't map neatly to a reusable component.
parts = [] parts = []
for href, label in [(tickets_url, "tickets"), (bookings_url, "bookings")]: for href, label in [(tickets_url, "tickets"), (bookings_url, "bookings")]:
parts.append( parts.append(render_comp(
f'<div class="relative nav-group">' "nav-group-link",
f'<a href="{href}" hx-get="{href}" hx-target="#main-panel"' href=href, hx_select=hx_select, nav_class=nav_class, label=label,
f' hx-select="{hx_select}" hx-swap="outerHTML"' ))
f' hx-push-url="true" class="{nav_class}">{label}</a></div>'
)
return "\n".join(parts) return "\n".join(parts)
_handlers["account-nav-item"] = _account_nav_item_handler _handlers["account-nav-item"] = _account_nav_item_handler
@@ -176,7 +175,7 @@ def register():
async def _link_card_handler(): async def _link_card_handler():
from shared.infrastructure.urls import events_url from shared.infrastructure.urls import events_url
from shared.sexp.jinja_bridge import sexp as render_sexp from shared.sexp.jinja_bridge import render as render_comp
slug = request.args.get("slug", "") slug = request.args.get("slug", "")
keys_raw = request.args.get("keys", "") keys_raw = request.args.get("keys", "")
@@ -194,8 +193,8 @@ def register():
g.s, "page", post.id, g.s, "page", post.id,
) )
cal_names = ", ".join(c.name for c in calendars) if calendars else "" cal_names = ", ".join(c.name for c in calendars) if calendars else ""
parts.append(render_sexp( parts.append(render_comp(
'(~link-card :title title :image image :subtitle subtitle :link link)', "link-card",
title=post.title, image=post.feature_image, title=post.title, image=post.feature_image,
subtitle=cal_names, link=events_url(f"/{post.slug}"), subtitle=cal_names, link=events_url(f"/{post.slug}"),
)) ))
@@ -212,8 +211,8 @@ def register():
g.s, "page", post.id, g.s, "page", post.id,
) )
cal_names = ", ".join(c.name for c in calendars) if calendars else "" cal_names = ", ".join(c.name for c in calendars) if calendars else ""
return render_sexp( return render_comp(
'(~link-card :title title :image image :subtitle subtitle :link link)', "link-card",
title=post.title, image=post.feature_image, title=post.title, image=post.feature_image,
subtitle=cal_names, link=events_url(f"/{post.slug}"), subtitle=cal_names, link=events_url(f"/{post.slug}"),
) )

View File

@@ -50,7 +50,8 @@ def register():
try: try:
await svc_create_market(g.s, post_id, name) await svc_create_market(g.s, post_id, name)
except Exception as e: except Exception as e:
return await make_response(f'<div class="text-red-600 text-sm">{e}</div>', 422) from shared.sexp.jinja_bridge import render as render_comp
return await make_response(render_comp("error-inline", message=str(e)), 422)
from shared.sexp.page import get_template_context from shared.sexp.page import get_template_context
from sexp.sexp_components import render_markets_list_panel from sexp.sexp_components import render_markets_list_panel

96
events/sexp/admin.sexpr Normal file
View File

@@ -0,0 +1,96 @@
;; Events admin components
(defcomp ~events-calendar-admin-panel (&key description-html csrf description)
(section :class "max-w-3xl mx-auto p-4 space-y-10"
(div
(h2 :class "text-xl font-semibold" "Calendar configuration")
(div :id "cal-put-errors" :class "mt-2 text-sm text-red-600")
(div (label :class "block text-sm font-medium text-stone-700" "Description")
(raw! description-html))
(form :id "calendar-form" :method "post" :hx-target "#main-panel" :hx-select "#main-panel"
:hx-on::before-request "document.querySelector('#cal-put-errors').textContent='';"
:hx-on::response-error "document.querySelector('#cal-put-errors').innerHTML = event.detail.xhr.responseText;"
:hx-on::after-request "if (event.detail.successful) this.reset()"
:class "hidden space-y-4 mt-4" :autocomplete "off"
(input :type "hidden" :name "csrf_token" :value csrf)
(div (label :class "block text-sm font-medium text-stone-700" "Description")
(div description)
(textarea :name "description" :autocomplete "off" :rows "4" :class "w-full p-2 border rounded" description))
(div (button :class "px-3 py-2 rounded bg-stone-800 text-white" "Save"))))
(hr :class "border-stone-200")))
(defcomp ~events-entry-admin-link (&key href)
(a :href href :class "inline-flex items-center gap-1 px-2 py-1 text-xs text-stone-500 hover:text-stone-700 hover:bg-stone-100 rounded"
(i :class "fa fa-cog" :aria-hidden "true") " Admin"))
(defcomp ~events-entry-field (&key label content-html)
(div :class "flex flex-col mb-4"
(div :class "text-xs font-semibold uppercase tracking-wide text-stone-500" label)
(raw! content-html)))
(defcomp ~events-entry-name-field (&key name)
(div :class "mt-1 text-lg font-medium" name))
(defcomp ~events-entry-slot-assigned (&key slot-name flex-label)
(div :class "mt-1"
(span :class "px-2 py-1 rounded text-sm bg-blue-100 text-blue-700" slot-name)
(span :class "ml-2 text-xs text-stone-500" flex-label)))
(defcomp ~events-entry-slot-none ()
(div :class "mt-1" (span :class "text-sm text-stone-400" "No slot assigned")))
(defcomp ~events-entry-time-field (&key time-str)
(div :class "mt-1" time-str))
(defcomp ~events-entry-state-field (&key entry-id badge-html)
(div :class "mt-1" (div :id (str "entry-state-" entry-id) (raw! badge-html))))
(defcomp ~events-entry-cost-field (&key cost-html)
(div :class "mt-1" (span :class "font-medium text-green-600" (raw! cost-html))))
(defcomp ~events-entry-tickets-field (&key entry-id tickets-config-html)
(div :class "mt-1" :id (str "entry-tickets-" entry-id) (raw! tickets-config-html)))
(defcomp ~events-entry-date-field (&key date-str)
(div :class "mt-1" date-str))
(defcomp ~events-entry-posts-field (&key entry-id posts-panel-html)
(div :class "mt-1" :id (str "entry-posts-" entry-id) (raw! posts-panel-html)))
(defcomp ~events-entry-panel (&key entry-id list-container name-html slot-html time-html state-html cost-html
tickets-html buy-html date-html posts-html options-html pre-action edit-url)
(section :id (str "entry-" entry-id) :class list-container
(raw! name-html) (raw! slot-html) (raw! time-html) (raw! state-html) (raw! cost-html)
(raw! tickets-html) (raw! buy-html) (raw! date-html) (raw! posts-html)
(div :class "flex gap-2 mt-6"
(raw! options-html)
(button :type "button" :class pre-action
:hx-get edit-url :hx-target (str "#entry-" entry-id) :hx-swap "outerHTML"
"Edit"))))
(defcomp ~events-entry-title (&key name badge-html)
(<> (i :class "fa fa-clock") " " name " " (raw! badge-html)))
(defcomp ~events-entry-times (&key time-str)
(div :class "text-sm text-gray-600" time-str))
(defcomp ~events-entry-optioned-oob (&key entry-id title-html state-html)
(<> (div :id (str "entry-title-" entry-id) :hx-swap-oob "innerHTML" (raw! title-html))
(div :id (str "entry-state-" entry-id) :hx-swap-oob "innerHTML" (raw! state-html))))
(defcomp ~events-entry-options (&key entry-id buttons-html)
(div :id (str "calendar_entry_options_" entry-id) :class "flex flex-col md:flex-row gap-1"
(raw! buttons-html)))
(defcomp ~events-entry-option-button (&key url target csrf btn-type action-btn confirm-title confirm-text
label is-btn)
(form :hx-post url :hx-select target :hx-target target :hx-swap "outerHTML"
:hx-trigger (if is-btn "confirmed" nil)
(input :type "hidden" :name "csrf_token" :value csrf)
(button :type btn-type :class action-btn
:data-confirm "true" :data-confirm-title confirm-title
:data-confirm-text confirm-text :data-confirm-icon "question"
:data-confirm-confirm-text (str "Yes, " label " it")
:data-confirm-cancel-text "Cancel"
:data-confirm-event (if is-btn "confirmed" nil)
(i :class "fa-solid fa-rotate mr-2" :aria-hidden "true") label)))

102
events/sexp/calendar.sexpr Normal file
View File

@@ -0,0 +1,102 @@
;; Events calendar components
(defcomp ~events-calendar-nav-arrow (&key pill-cls href label)
(a :class (str pill-cls " text-xl") :href href
:hx-get href :hx-target "#main-panel" :hx-select "#main-panel" :hx-swap "outerHTML" :hx-push-url "true" label))
(defcomp ~events-calendar-month-label (&key month-name year)
(div :class "px-3 font-medium" (str month-name " " year)))
(defcomp ~events-calendar-weekday (&key name)
(div :class "py-1" name))
(defcomp ~events-calendar-day-short (&key day-str)
(span :class "sm:hidden text-[16px] text-stone-500" day-str))
(defcomp ~events-calendar-day-num (&key pill-cls href num)
(a :class pill-cls :href href :hx-get href :hx-target "#main-panel" :hx-select "#main-panel"
:hx-swap "outerHTML" :hx-push-url "true" num))
(defcomp ~events-calendar-entry-badge (&key bg-cls name state-label)
(div :class (str "flex items-center justify-between gap-1 text-[11px] rounded px-1 py-0.5 " bg-cls)
(span :class "truncate" name)
(span :class "shrink-0 text-[10px] font-semibold uppercase tracking-tight" state-label)))
(defcomp ~events-calendar-cell (&key cell-cls day-short-html day-num-html badges-html)
(div :class cell-cls
(div :class "flex justify-between items-center"
(div :class "flex flex-col" (raw! day-short-html) (raw! day-num-html)))
(div :class "mt-1 space-y-0.5" (raw! badges-html))))
(defcomp ~events-calendar-grid (&key arrows-html weekdays-html cells-html)
(section :class "bg-orange-100"
(header :class "flex items-center justify-center mt-2"
(nav :class "flex items-center gap-2 text-2xl" (raw! arrows-html)))
(div :class "rounded-2xl border border-stone-200 bg-white/80 p-4"
(div :class "hidden sm:grid grid-cols-7 text-center text-md font-semibold text-stone-700 mb-2" (raw! weekdays-html))
(div :class "grid grid-cols-1 sm:grid-cols-7 gap-px bg-stone-200 rounded-xl overflow-hidden" (raw! cells-html)))))
(defcomp ~events-calendars-create-form (&key create-url csrf)
(<>
(div :id "cal-create-errors" :class "mt-2 text-sm text-red-600")
(form :class "mt-4 flex gap-2 items-end" :hx-post create-url
:hx-target "#calendars-list" :hx-select "#calendars-list" :hx-swap "outerHTML"
:hx-on::before-request "document.querySelector('#cal-create-errors').textContent='';"
:hx-on::response-error "document.querySelector('#cal-create-errors').innerHTML = event.detail.xhr.responseText;"
(input :type "hidden" :name "csrf_token" :value csrf)
(div :class "flex-1"
(label :class "block text-sm text-gray-600" "Name")
(input :name "name" :type "text" :required true :class "w-full border rounded px-3 py-2"
:placeholder "e.g. Events, Gigs, Meetings"))
(button :type "submit" :class "border rounded px-3 py-2" "Add calendar"))))
(defcomp ~events-calendars-panel (&key form-html list-html)
(section :class "p-4"
(raw! form-html)
(div :id "calendars-list" :class "mt-6" (raw! list-html))))
(defcomp ~events-calendars-empty ()
(p :class "text-gray-500 mt-4" "No calendars yet. Create one above."))
(defcomp ~events-calendars-item (&key href cal-name cal-slug del-url csrf-hdr)
(div :class "mt-6 border rounded-lg p-4"
(div :class "flex items-center justify-between gap-3"
(a :class "flex items-baseline gap-3" :href href
:hx-get href :hx-target "#main-panel" :hx-select "#main-panel" :hx-swap "outerHTML" :hx-push-url "true"
(h3 :class "font-semibold" cal-name)
(h4 :class "text-gray-500" (str "/" cal-slug "/")))
(button :class "text-sm border rounded px-3 py-1 hover:bg-red-50 hover:border-red-400"
:data-confirm true :data-confirm-title "Delete calendar?"
:data-confirm-text "Entries will be hidden (soft delete)"
:data-confirm-icon "warning" :data-confirm-confirm-text "Yes, delete it"
:data-confirm-cancel-text "Cancel" :data-confirm-event "confirmed"
:hx-delete del-url :hx-trigger "confirmed"
:hx-target "#calendars-list" :hx-select "#calendars-list" :hx-swap "outerHTML"
:hx-headers csrf-hdr
(i :class "fa-solid fa-trash")))))
(defcomp ~events-calendar-description-display (&key description edit-url)
(div :id "calendar-description"
(if description
(p :class "text-stone-700 whitespace-pre-line break-all" description)
(p :class "text-stone-400 italic" "No description yet."))
(button :type "button" :class "mt-2 text-xs underline"
:hx-get edit-url :hx-target "#calendar-description" :hx-swap "outerHTML"
(i :class "fas fa-edit"))))
(defcomp ~events-calendar-description-title-oob (&key description)
(div :id "calendar-description-title" :hx-swap-oob "outerHTML"
:class "text-base font-normal break-words whitespace-normal min-w-0 break-all w-full text-center block"
description))
(defcomp ~events-calendar-description-edit-form (&key save-url cancel-url csrf description)
(div :id "calendar-description"
(form :hx-post save-url :hx-target "#calendar-description" :hx-swap "outerHTML"
(input :type "hidden" :name "csrf_token" :value csrf)
(textarea :name "description" :autocomplete "off" :rows "4"
:class "w-full p-2 border rounded" description)
(div :class "mt-2 flex gap-2 text-xs"
(button :type "submit" :class "px-3 py-1 rounded bg-stone-800 text-white" "Save")
(button :type "button" :class "px-3 py-1 rounded border"
:hx-get cancel-url :hx-target "#calendar-description" :hx-swap "outerHTML"
"Cancel")))))

84
events/sexp/day.sexpr Normal file
View File

@@ -0,0 +1,84 @@
;; Events day components
(defcomp ~events-day-entry-link (&key href name time-str)
(a :href href :class "flex items-center gap-2 px-3 py-2 hover:bg-stone-100 rounded transition text-sm border sm:whitespace-nowrap sm:flex-shrink-0"
(div :class "flex-1 min-w-0"
(div :class "font-medium truncate" name)
(div :class "text-xs text-stone-600 truncate" time-str))))
(defcomp ~events-day-entries-nav (&key inner-html)
(div :class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"
:id "day-entries-nav-wrapper"
(div :class "flex overflow-x-auto gap-1 scrollbar-thin"
(raw! inner-html))))
(defcomp ~events-day-table (&key list-container rows-html pre-action add-url)
(section :id "day-entries" :class list-container
(table :class "w-full text-sm border table-fixed"
(thead :class "bg-stone-100"
(tr
(th :class "p-2 text-left w-2/6" "Name")
(th :class "text-left p-2 w-1/6" "Slot/Time")
(th :class "text-left p-2 w-1/6" "State")
(th :class "text-left p-2 w-1/6" "Cost")
(th :class "text-left p-2 w-1/6" "Tickets")
(th :class "text-left p-2 w-1/6" "Actions")))
(tbody (raw! rows-html)))
(div :id "entry-add-container" :class "mt-4"
(button :type "button" :class pre-action
:hx-get add-url :hx-target "#entry-add-container" :hx-swap "innerHTML"
"+ Add entry"))))
(defcomp ~events-day-empty-row ()
(tr (td :colspan "6" :class "p-3 text-stone-500" "No entries yet.")))
(defcomp ~events-day-row-name (&key href pill-cls name)
(td :class "p-2 align-top w-2/6" (div :class "font-medium"
(a :href href :class pill-cls :hx-get href :hx-target "#main-panel" :hx-select "#main-panel"
:hx-swap "outerHTML" :hx-push-url "true" name))))
(defcomp ~events-day-row-slot (&key href pill-cls slot-name time-str)
(td :class "p-2 align-top w-1/6" (div :class "text-xs font-medium"
(a :href href :class pill-cls :hx-get href :hx-target "#main-panel" :hx-select "#main-panel"
:hx-swap "outerHTML" :hx-push-url "true" slot-name)
(span :class "text-stone-600 font-normal" (raw! time-str)))))
(defcomp ~events-day-row-time (&key start end)
(td :class "p-2 align-top w-1/6" (div :class "text-xs text-stone-600" (str start end))))
(defcomp ~events-day-row-state (&key state-id badge-html)
(td :class "p-2 align-top w-1/6" (div :id state-id (raw! badge-html))))
(defcomp ~events-day-row-cost (&key cost-str)
(td :class "p-2 align-top w-1/6" (span :class "font-medium text-green-600" cost-str)))
(defcomp ~events-day-row-tickets (&key price-str count-str)
(td :class "p-2 align-top w-1/6" (div :class "text-xs space-y-1"
(div :class "font-medium text-green-600" price-str)
(div :class "text-stone-600" count-str))))
(defcomp ~events-day-row-no-tickets ()
(td :class "p-2 align-top w-1/6" (span :class "text-xs text-stone-400" "No tickets")))
(defcomp ~events-day-row-actions ()
(td :class "p-2 align-top w-1/6"))
(defcomp ~events-day-row (&key tr-cls name-html slot-html state-html cost-html tickets-html actions-html)
(tr :class tr-cls (raw! name-html) (raw! slot-html) (raw! state-html) (raw! cost-html) (raw! tickets-html) (raw! actions-html)))
(defcomp ~events-day-admin-panel ()
(div :class "p-4 text-sm text-stone-500" "Admin options"))
(defcomp ~events-day-entries-nav-oob-empty ()
(div :id "day-entries-nav-wrapper" :hx-swap-oob "true"))
(defcomp ~events-day-entries-nav-oob (&key items-html)
(div :class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"
:id "day-entries-nav-wrapper" :hx-swap-oob "true"
(div :class "flex overflow-x-auto gap-1 scrollbar-thin" (raw! items-html))))
(defcomp ~events-day-nav-entry (&key href nav-btn name time-str)
(a :href href :class nav-btn
(div :class "flex-1 min-w-0"
(div :class "font-medium truncate" name)
(div :class "text-xs text-stone-600 truncate" time-str))))

103
events/sexp/entries.sexpr Normal file
View File

@@ -0,0 +1,103 @@
;; Events entry card components (all events / page summary)
(defcomp ~events-state-badge (&key cls label)
(span :class (str "inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium " cls) label))
(defcomp ~events-entry-title-linked (&key href name)
(a :href href :class "hover:text-emerald-700"
(h2 :class "text-lg font-semibold text-stone-900" name)))
(defcomp ~events-entry-title-plain (&key name)
(h2 :class "text-lg font-semibold text-stone-900" name))
(defcomp ~events-entry-title-tile-linked (&key href name)
(a :href href :class "hover:text-emerald-700"
(h2 :class "text-base font-semibold text-stone-900 line-clamp-2" name)))
(defcomp ~events-entry-title-tile-plain (&key name)
(h2 :class "text-base font-semibold text-stone-900 line-clamp-2" name))
(defcomp ~events-entry-page-badge (&key href title)
(a :href href :class "inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 hover:bg-amber-200" title))
(defcomp ~events-entry-cal-badge (&key name)
(span :class "inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-sky-100 text-sky-700" name))
(defcomp ~events-entry-time-linked (&key href date-str)
(<> (a :href href :class "hover:text-stone-700" date-str) (raw! " &middot; ")))
(defcomp ~events-entry-time-plain (&key date-str)
(<> (span date-str) (raw! " &middot; ")))
(defcomp ~events-entry-cost (&key cost-html)
(div :class "mt-1 text-sm font-medium text-green-600" (raw! cost-html)))
(defcomp ~events-entry-card (&key title-html badges-html time-parts cost-html widget-html)
(article :class "rounded-xl bg-white shadow-sm border border-stone-200 p-4"
(div :class "flex flex-col sm:flex-row sm:items-start justify-between gap-3"
(div :class "flex-1 min-w-0"
(raw! title-html)
(div :class "flex flex-wrap items-center gap-1.5 mt-1" (raw! badges-html))
(div :class "mt-1 text-sm text-stone-500" (raw! time-parts))
(raw! cost-html))
(raw! widget-html))))
(defcomp ~events-entry-card-tile (&key title-html badges-html time-html cost-html widget-html)
(article :class "rounded-xl bg-white shadow-sm border border-stone-200 overflow-hidden"
(div :class "p-3"
(raw! title-html)
(div :class "flex flex-wrap items-center gap-1 mt-1" (raw! badges-html))
(div :class "mt-1 text-xs text-stone-500" (raw! time-html))
(raw! cost-html))
(raw! widget-html)))
(defcomp ~events-entry-tile-widget-wrapper (&key widget-html)
(div :class "border-t border-stone-100 px-3 py-2" (raw! widget-html)))
(defcomp ~events-entry-widget-wrapper (&key widget-html)
(div :class "shrink-0" (raw! widget-html)))
(defcomp ~events-date-separator (&key date-str)
(div :class "pt-2 pb-1"
(h3 :class "text-sm font-semibold text-stone-500 uppercase tracking-wide" date-str)))
(defcomp ~events-sentinel (&key page next-url)
(div :id (str "sentinel-" page) :class "h-4 opacity-0 pointer-events-none"
:hx-get next-url :hx-trigger "intersect once delay:250ms" :hx-swap "outerHTML"
:role "status" :aria-hidden "true"
(div :class "text-center text-xs text-stone-400" "loading...")))
(defcomp ~events-list-svg ()
(svg :xmlns "http://www.w3.org/2000/svg" :class "h-5 w-5" :fill "none"
:viewBox "0 0 24 24" :stroke "currentColor" :stroke-width "2"
(path :stroke-linecap "round" :stroke-linejoin "round" :d "M4 6h16M4 12h16M4 18h16")))
(defcomp ~events-tile-svg ()
(svg :xmlns "http://www.w3.org/2000/svg" :class "h-5 w-5" :fill "none"
:viewBox "0 0 24 24" :stroke "currentColor" :stroke-width "2"
(path :stroke-linecap "round" :stroke-linejoin "round"
:d "M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z")))
(defcomp ~events-view-toggle (&key list-href tile-href hx-select list-active tile-active list-svg tile-svg)
(div :class "hidden md:flex justify-end px-3 pt-3 gap-1"
(a :href list-href :hx-get list-href :hx-target "#main-panel" :hx-select hx-select
:hx-swap "outerHTML" :hx-push-url "true"
:class (str "p-1.5 rounded " list-active) :title "List view"
:_ "on click js localStorage.removeItem('events_view') end"
(raw! list-svg))
(a :href tile-href :hx-get tile-href :hx-target "#main-panel" :hx-select hx-select
:hx-swap "outerHTML" :hx-push-url "true"
:class (str "p-1.5 rounded " tile-active) :title "Tile view"
:_ "on click js localStorage.setItem('events_view','tile') end"
(raw! tile-svg))))
(defcomp ~events-grid (&key grid-cls cards-html)
(div :class grid-cls (raw! cards-html)))
(defcomp ~events-empty ()
(div :class "px-3 py-12 text-center text-stone-400"
(i :class "fa fa-calendar-xmark text-4xl mb-3" :aria-hidden "true")
(p :class "text-lg" "No upcoming events")))
(defcomp ~events-main-panel-body (&key toggle-html body-html)
(<> (raw! toggle-html) (raw! body-html) (div :class "pb-8")))

46
events/sexp/header.sexpr Normal file
View File

@@ -0,0 +1,46 @@
;; Events header components
(defcomp ~events-oob-header (&key parent-id child-id row-html)
(div :id parent-id :hx-swap-oob "outerHTML" :class "w-full"
(div :class "w-full"
(raw! row-html)
(div :id child-id))))
(defcomp ~events-post-label (&key feature-image title)
(<> (when feature-image (img :src feature-image :class "h-8 w-8 rounded-full object-cover border border-stone-300 flex-shrink-0"))
(span title)))
(defcomp ~events-post-cart-link (&key href count)
(a :href href :class "relative inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-full border border-emerald-300 bg-emerald-50 text-emerald-800 hover:bg-emerald-100 transition"
(i :class "fa fa-shopping-cart" :aria-hidden "true")
(span count)))
(defcomp ~events-calendars-label ()
(<> (i :class "fa fa-calendar" :aria-hidden "true") (div "Calendars")))
(defcomp ~events-markets-label ()
(<> (i :class "fa fa-shopping-bag" :aria-hidden "true") (div "Markets")))
(defcomp ~events-payments-label ()
(<> (i :class "fa fa-credit-card" :aria-hidden "true") (div "Payments")))
(defcomp ~events-calendar-label (&key name description)
(div :class "flex flex-col md:flex-row md:gap-2 items-center min-w-0"
(div :class "flex flex-row items-center gap-2"
(i :class "fa fa-calendar")
(div :class "shrink-0" name))
(div :id "calendar-description-title"
:class "text-base font-normal break-words whitespace-normal min-w-0 break-all w-full text-center block"
description)))
(defcomp ~events-day-label (&key date-str)
(div :class "flex gap-1 items-center"
(i :class "fa fa-calendar-day")
(span date-str)))
(defcomp ~events-entry-label (&key entry-id title-html times-html)
(div :id (str "entry-title-" entry-id) :class "flex gap-1 items-center"
(raw! title-html) (raw! times-html)))
(defcomp ~events-header-child (&key inner-html)
(div :id "root-header-child" :class "w-full" (raw! inner-html)))

386
events/sexp/page.sexpr Normal file
View File

@@ -0,0 +1,386 @@
;; Events page-level components (slots, ticket types, buy form, cart, posts nav)
(defcomp ~events-slot-days-pills (&key days-inner-html)
(div :class "flex flex-wrap gap-1" (raw! days-inner-html)))
(defcomp ~events-slot-day-pill (&key day)
(span :class "px-2 py-0.5 rounded-full text-xs bg-slate-200" day))
(defcomp ~events-slot-no-days ()
(span :class "text-xs text-slate-400" "No days"))
(defcomp ~events-slot-panel (&key slot-id list-container days-html flexible time-str cost-str pre-action edit-url)
(section :id (str "slot-" slot-id) :class list-container
(div :class "flex flex-col"
(div :class "text-xs font-semibold uppercase tracking-wide text-stone-500" "Days")
(div :class "mt-1" (raw! days-html)))
(div :class "flex flex-col"
(div :class "text-xs font-semibold uppercase tracking-wide text-stone-500" "Flexible")
(div :class "mt-1" flexible))
(div :class "grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm"
(div :class "flex flex-col"
(div :class "text-xs font-semibold uppercase tracking-wide text-stone-500" "Time")
(div :class "mt-1" time-str))
(div :class "flex flex-col"
(div :class "text-xs font-semibold uppercase tracking-wide text-stone-500" "Cost")
(div :class "mt-1" cost-str)))
(button :type "button" :class pre-action :hx-get edit-url
:hx-target (str "#slot-" slot-id) :hx-swap "outerHTML" "Edit")))
(defcomp ~events-slot-description-oob (&key description)
(div :id "slot-description-title" :hx-swap-oob "outerHTML"
:class "text-base font-normal break-words whitespace-normal min-w-0 break-all w-full text-center block"
description))
(defcomp ~events-slots-empty-row ()
(tr (td :colspan "5" :class "p-3 text-stone-500" "No slots yet.")))
(defcomp ~events-slots-row (&key tr-cls slot-href pill-cls hx-select slot-name description
flexible days-html time-str cost-str action-btn del-url csrf-hdr)
(tr :class tr-cls
(td :class "p-2 align-top w-1/6"
(div :class "font-medium"
(a :href slot-href :class pill-cls :hx-get slot-href :hx-target "#main-panel"
:hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true" slot-name))
(p :class "text-stone-500 whitespace-pre-line break-all w-full" description))
(td :class "p-2 align-top w-1/6" flexible)
(td :class "p-2 align-top w-1/6" (raw! days-html))
(td :class "p-2 align-top w-1/6" time-str)
(td :class "p-2 align-top w-1/6" cost-str)
(td :class "p-2 align-top w-1/6"
(button :class action-btn :type "button"
:data-confirm "true" :data-confirm-title "Delete slot?"
:data-confirm-text "This action cannot be undone."
:data-confirm-icon "warning" :data-confirm-confirm-text "Yes, delete it"
:data-confirm-cancel-text "Cancel" :data-confirm-event "confirmed"
:hx-delete del-url :hx-target "#slots-table" :hx-select "#slots-table"
:hx-swap "outerHTML" :hx-headers csrf-hdr :hx-trigger "confirmed"
(i :class "fa-solid fa-trash")))))
(defcomp ~events-slots-table (&key list-container rows-html pre-action add-url)
(section :id "slots-table" :class list-container
(table :class "w-full text-sm border table-fixed"
(thead :class "bg-stone-100"
(tr (th :class "p-2 text-left w-1/6" "Name")
(th :class "p-2 text-left w-1/6" "Flexible")
(th :class "text-left p-2 w-1/6" "Days")
(th :class "text-left p-2 w-1/6" "Time")
(th :class "text-left p-2 w-1/6" "Cost")
(th :class "text-left p-2 w-1/6" "Actions")))
(tbody (raw! rows-html)))
(div :id "slot-add-container" :class "mt-4"
(button :type "button" :class pre-action
:hx-get add-url :hx-target "#slot-add-container" :hx-swap "innerHTML"
"+ Add slot"))))
(defcomp ~events-ticket-type-col (&key label value)
(div :class "flex flex-col"
(div :class "text-xs font-semibold uppercase tracking-wide text-stone-500" label)
(div :class "mt-1" value)))
(defcomp ~events-ticket-type-panel (&key ticket-id list-container c1 c2 c3 pre-action edit-url)
(section :id (str "ticket-" ticket-id) :class list-container
(div :class "grid grid-cols-1 sm:grid-cols-3 gap-4 text-sm"
(raw! c1) (raw! c2) (raw! c3))
(button :type "button" :class pre-action :hx-get edit-url
:hx-target (str "#ticket-" ticket-id) :hx-swap "outerHTML" "Edit")))
(defcomp ~events-ticket-types-empty-row ()
(tr (td :colspan "4" :class "p-3 text-stone-500" "No ticket types yet.")))
(defcomp ~events-ticket-types-row (&key tr-cls tt-href pill-cls hx-select tt-name cost-str count
action-btn del-url csrf-hdr)
(tr :class tr-cls
(td :class "p-2 align-top w-1/3"
(div :class "font-medium"
(a :href tt-href :class pill-cls :hx-get tt-href :hx-target "#main-panel"
:hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true" tt-name)))
(td :class "p-2 align-top w-1/4" cost-str)
(td :class "p-2 align-top w-1/4" count)
(td :class "p-2 align-top w-1/6"
(button :class action-btn :type "button"
:data-confirm "true" :data-confirm-title "Delete ticket type?"
:data-confirm-text "This action cannot be undone."
:data-confirm-icon "warning" :data-confirm-confirm-text "Yes, delete it"
:data-confirm-cancel-text "Cancel" :data-confirm-event "confirmed"
:hx-delete del-url :hx-target "#tickets-table" :hx-select "#tickets-table"
:hx-swap "outerHTML" :hx-headers csrf-hdr :hx-trigger "confirmed"
(i :class "fa-solid fa-trash")))))
(defcomp ~events-ticket-types-table (&key list-container rows-html action-btn add-url)
(section :id "tickets-table" :class list-container
(table :class "w-full text-sm border table-fixed"
(thead :class "bg-stone-100"
(tr (th :class "p-2 text-left w-1/3" "Name")
(th :class "text-left p-2 w-1/4" "Cost")
(th :class "text-left p-2 w-1/4" "Count")
(th :class "text-left p-2 w-1/6" "Actions")))
(tbody (raw! rows-html)))
(div :id "ticket-add-container" :class "mt-4"
(button :class action-btn :hx-get add-url :hx-target "#ticket-add-container" :hx-swap "innerHTML"
(i :class "fa fa-plus") " Add ticket type"))))
(defcomp ~events-ticket-config-display (&key price-str count-str show-js)
(div :class "space-y-2"
(div :class "flex items-center gap-2"
(span :class "text-sm font-medium text-stone-700" "Price:")
(span :class "font-medium text-green-600" (raw! price-str)))
(div :class "flex items-center gap-2"
(span :class "text-sm font-medium text-stone-700" "Available:")
(span :class "font-medium text-blue-600" count-str))
(button :type "button" :class "text-xs text-blue-600 hover:text-blue-800 underline"
:onclick show-js "Edit ticket config")))
(defcomp ~events-ticket-config-none (&key show-js)
(div :class "space-y-2"
(span :class "text-sm text-stone-400" "No tickets configured")
(button :type "button" :class "block text-xs text-blue-600 hover:text-blue-800 underline"
:onclick show-js "Configure tickets")))
(defcomp ~events-ticket-config-form (&key entry-id hidden-cls update-url csrf price-val count-val hide-js)
(form :id (str "ticket-form-" entry-id) :class (str hidden-cls " space-y-3 mt-2 p-3 border rounded bg-stone-50")
:hx-post update-url :hx-target (str "#entry-tickets-" entry-id) :hx-swap "innerHTML"
(input :type "hidden" :name "csrf_token" :value csrf)
(div (label :for (str "ticket-price-" entry-id) :class "block text-sm font-medium text-stone-700 mb-1"
(raw! "Ticket Price (&pound;)"))
(input :type "number" :id (str "ticket-price-" entry-id) :name "ticket_price"
:step "0.01" :min "0" :value price-val
:class "w-full px-3 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
:placeholder "e.g., 5.00"))
(div (label :for (str "ticket-count-" entry-id) :class "block text-sm font-medium text-stone-700 mb-1"
"Total Tickets")
(input :type "number" :id (str "ticket-count-" entry-id) :name "ticket_count"
:min "0" :value count-val
:class "w-full px-3 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
:placeholder "Leave empty for unlimited"))
(div :class "flex gap-2"
(button :type "submit" :class "px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 text-sm" "Save")
(button :type "button" :class "px-4 py-2 bg-stone-200 text-stone-700 rounded hover:bg-stone-300 text-sm"
:onclick hide-js "Cancel"))))
(defcomp ~events-buy-not-confirmed (&key entry-id)
(div :id (str "ticket-buy-" entry-id) :class "rounded-xl border border-stone-200 bg-stone-50 p-4 text-sm text-stone-500"
(i :class "fa fa-ticket mr-1" :aria-hidden "true")
"Tickets available once this event is confirmed."))
(defcomp ~events-buy-info-sold (&key count)
(span (str count " sold")))
(defcomp ~events-buy-info-remaining (&key count)
(span (str count " remaining")))
(defcomp ~events-buy-info-basket (&key count)
(span :class "text-emerald-600 font-medium"
(i :class "fa fa-shopping-cart text-[0.6rem]" :aria-hidden "true")
(str " " count " in basket")))
(defcomp ~events-buy-info-bar (&key items-html)
(div :class "flex items-center gap-3 mb-3 text-xs text-stone-500" (raw! items-html)))
(defcomp ~events-buy-type-item (&key type-name cost-str adjust-controls-html)
(div :class "flex items-center justify-between p-3 rounded-lg bg-stone-50 border border-stone-100"
(div (div :class "font-medium text-sm" type-name)
(div :class "text-xs text-stone-500" cost-str))
(raw! adjust-controls-html)))
(defcomp ~events-buy-types-wrapper (&key items-html)
(div :class "space-y-2" (raw! items-html)))
(defcomp ~events-buy-default (&key price-str adjust-controls-html)
(<> (div :class "flex items-center justify-between mb-4"
(div (span :class "font-medium text-green-600" price-str)
(span :class "text-sm text-stone-500 ml-2" "per ticket")))
(raw! adjust-controls-html)))
(defcomp ~events-buy-panel (&key entry-id info-html body-html)
(div :id (str "ticket-buy-" entry-id) :class "rounded-xl border border-stone-200 bg-white p-4"
(h3 :class "text-sm font-semibold text-stone-700 mb-3"
(i :class "fa fa-ticket mr-1" :aria-hidden "true") "Tickets")
(raw! info-html) (raw! body-html)))
(defcomp ~events-adjust-form (&key adjust-url target extra-cls csrf entry-id tt-html count-val btn-html)
(form :hx-post adjust-url :hx-target target :hx-swap "outerHTML" :class extra-cls
(input :type "hidden" :name "csrf_token" :value csrf)
(input :type "hidden" :name "entry_id" :value entry-id)
(raw! tt-html)
(input :type "hidden" :name "count" :value count-val)
(raw! btn-html)))
(defcomp ~events-adjust-tt-hidden (&key ticket-type-id)
(input :type "hidden" :name "ticket_type_id" :value ticket-type-id))
(defcomp ~events-adjust-cart-plus ()
(button :type "submit"
:class "relative inline-flex items-center justify-center text-sm font-medium text-stone-500 hover:bg-emerald-50 rounded p-1"
(i :class "fa fa-cart-plus text-2xl" :aria-hidden "true")))
(defcomp ~events-adjust-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"
"-"))
(defcomp ~events-adjust-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"
"+"))
(defcomp ~events-adjust-cart-icon (&key href count)
(a :class "relative inline-flex items-center justify-center text-emerald-700" :href href
(span :class "relative inline-flex items-center justify-center"
(i :class "fa-solid fa-shopping-cart text-2xl" :aria-hidden "true")
(span :class "absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none"
(span :class "flex items-center justify-center bg-black text-white rounded-full w-4 h-4 text-xs font-bold" count)))))
(defcomp ~events-adjust-controls (&key minus-html cart-icon-html plus-html)
(div :class "flex items-center gap-2" (raw! minus-html) (raw! cart-icon-html) (raw! plus-html)))
(defcomp ~events-buy-result (&key entry-id count-label tickets-html remaining-html my-tickets-href)
(div :id (str "ticket-buy-" entry-id) :class "rounded-xl border border-emerald-200 bg-emerald-50 p-4"
(div :class "flex items-center gap-2 mb-3"
(i :class "fa fa-check-circle text-emerald-600" :aria-hidden "true")
(span :class "font-semibold text-emerald-800" count-label))
(div :class "space-y-2 mb-4" (raw! tickets-html))
(raw! remaining-html)
(div :class "mt-3 flex gap-2"
(a :href my-tickets-href :class "text-sm text-emerald-700 hover:text-emerald-900 underline"
"View all my tickets"))))
(defcomp ~events-buy-result-ticket (&key href code-short)
(a :href href :class "flex items-center justify-between p-2 rounded-lg bg-white border border-emerald-100 hover:border-emerald-300 transition text-sm"
(div :class "flex items-center gap-2"
(i :class "fa fa-ticket text-emerald-500" :aria-hidden "true")
(span :class "font-mono text-xs text-stone-500" code-short))
(span :class "text-xs text-emerald-600 font-medium" "View ticket")))
(defcomp ~events-buy-result-remaining (&key text)
(p :class "text-xs text-stone-500" text))
(defcomp ~events-cart-icon-logo (&key blog-href logo)
(div :id "cart-mini" :hx-swap-oob "true"
(div :class "h-12 w-12 rounded-full overflow-hidden border border-stone-300 flex-shrink-0"
(a :href blog-href :class "h-full w-full font-bold text-5xl flex-shrink-0 flex flex-row items-center gap-1"
(img :src logo :class "h-full w-full rounded-full object-cover border border-stone-300 flex-shrink-0")))))
(defcomp ~events-cart-icon-badge (&key cart-href count)
(div :id "cart-mini" :hx-swap-oob "true"
(a :href cart-href :class "relative inline-flex items-center justify-center text-stone-700 hover:text-emerald-700"
(i :class "fa fa-shopping-cart text-5xl" :aria-hidden "true")
(span :class "absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 inline-flex items-center justify-center rounded-full bg-emerald-600 text-white text-sm w-5 h-5"
count))))
;; Inline ticket widget (for all-events/page-summary cards)
(defcomp ~events-tw-form (&key ticket-url target csrf entry-id count-val btn-html)
(form :action ticket-url :method "post" :hx-post ticket-url :hx-target target :hx-swap "outerHTML"
(input :type "hidden" :name "csrf_token" :value csrf)
(input :type "hidden" :name "entry_id" :value entry-id)
(input :type "hidden" :name "count" :value count-val)
(raw! btn-html)))
(defcomp ~events-tw-cart-plus ()
(button :type "submit" :class "relative inline-flex items-center justify-center text-stone-500 hover:bg-emerald-50 rounded p-1"
(i :class "fa fa-cart-plus text-2xl" :aria-hidden "true")))
(defcomp ~events-tw-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" "-"))
(defcomp ~events-tw-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" "+"))
(defcomp ~events-tw-cart-icon (&key qty)
(span :class "relative inline-flex items-center justify-center text-emerald-700"
(span :class "relative inline-flex items-center justify-center"
(i :class "fa-solid fa-shopping-cart text-xl" :aria-hidden "true")
(span :class "absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none"
(span :class "flex items-center justify-center bg-black text-white rounded-full w-4 h-4 text-xs font-bold" qty)))))
(defcomp ~events-tw-widget (&key entry-id price-html inner-html)
(div :id (str "page-ticket-" entry-id) :class "flex items-center gap-2"
(span :class "text-green-600 font-medium text-sm" (raw! price-html))
(raw! inner-html)))
;; Entry posts panel
(defcomp ~events-entry-posts-panel (&key posts-html search-url entry-id)
(div :class "space-y-2"
(raw! posts-html)
(div :class "mt-3 pt-3 border-t"
(label :class "block text-xs font-medium text-stone-700 mb-1" "Add Post")
(input :type "text" :placeholder "Search posts..."
:class "w-full px-3 py-2 border rounded text-sm"
:hx-get search-url :hx-trigger "keyup changed delay:300ms, load"
:hx-target (str "#post-search-results-" entry-id) :hx-swap "innerHTML" :name "q")
(div :id (str "post-search-results-" entry-id) :class "mt-2 max-h-96 overflow-y-auto border rounded"))))
(defcomp ~events-entry-posts-list (&key items-html)
(div :class "space-y-2" (raw! items-html)))
(defcomp ~events-entry-posts-none ()
(p :class "text-sm text-stone-400" "No posts associated"))
(defcomp ~events-entry-post-item (&key img-html title del-url entry-id csrf-hdr)
(div :class "flex items-center justify-between gap-3 p-2 bg-stone-50 rounded border"
(raw! img-html) (span :class "text-sm flex-1" title)
(button :type "button" :class "text-xs text-red-600 hover:text-red-800 flex-shrink-0"
:data-confirm "true" :data-confirm-title "Remove post?"
:data-confirm-text (str "This will remove " title " from this entry")
:data-confirm-icon "warning" :data-confirm-confirm-text "Yes, remove it"
:data-confirm-cancel-text "Cancel" :data-confirm-event "confirmed"
:hx-delete del-url :hx-trigger "confirmed"
:hx-target (str "#entry-posts-" entry-id) :hx-swap "innerHTML"
:hx-headers csrf-hdr
(i :class "fa fa-times") " Remove")))
(defcomp ~events-post-img (&key src alt)
(img :src src :alt alt :class "w-8 h-8 rounded-full object-cover flex-shrink-0"))
(defcomp ~events-post-img-placeholder ()
(div :class "w-8 h-8 rounded-full bg-stone-200 flex-shrink-0"))
;; Entry posts nav OOB
(defcomp ~events-entry-posts-nav-oob-empty ()
(div :id "entry-posts-nav-wrapper" :hx-swap-oob "true"))
(defcomp ~events-entry-posts-nav-oob (&key items-html)
(div :class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"
:id "entry-posts-nav-wrapper" :hx-swap-oob "true"
(div :class "flex overflow-x-auto gap-1 scrollbar-thin" (raw! items-html))))
(defcomp ~events-entry-nav-post (&key href nav-btn img-html title)
(a :href href :class nav-btn (raw! img-html) (div :class "flex-1 min-w-0" (div :class "font-medium truncate" title))))
;; Post nav entries OOB
(defcomp ~events-post-nav-oob-empty ()
(div :id "entries-calendars-nav-wrapper" :hx-swap-oob "true"))
(defcomp ~events-post-nav-entry (&key href nav-btn name time-str)
(a :href href :class nav-btn
(div :class "w-8 h-8 rounded bg-stone-200 flex-shrink-0")
(div :class "flex-1 min-w-0"
(div :class "font-medium truncate" name)
(div :class "text-xs text-stone-600 truncate" time-str))))
(defcomp ~events-post-nav-calendar (&key href nav-btn name)
(a :href href :class nav-btn
(i :class "fa fa-calendar" :aria-hidden "true")
(div name)))
(defcomp ~events-post-nav-wrapper (&key items-html hyperscript)
(div :class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"
:id "entries-calendars-nav-wrapper" :hx-swap-oob "true"
(button :class "entries-nav-arrow hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"
:aria-label "Scroll left"
:_ "on click set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft - 200"
(i :class "fa fa-chevron-left"))
(div :id "associated-items-container"
:class "overflow-y-auto sm:overflow-x-auto sm:overflow-y-visible scrollbar-hide max-h-[50vh] sm:max-h-none"
:style "scroll-behavior: smooth;" :_ hyperscript
(div :class "flex flex-col sm:flex-row gap-1" (raw! items-html)))
(style ".scrollbar-hide::-webkit-scrollbar { display: none; } .scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }")
(button :class "entries-nav-arrow hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"
:aria-label "Scroll right"
:_ "on click set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft + 200"
(i :class "fa fa-chevron-right"))))
;; Entry nav post link (with image)
(defcomp ~events-entry-nav-post-link (&key href img-html title)
(a :href href :class "flex items-center gap-2 px-3 py-2 hover:bg-stone-100 rounded transition text-sm border sm:whitespace-nowrap sm:flex-shrink-0"
(raw! img-html) (div :class "flex-1 min-w-0" (div :class "font-medium truncate" title))))

View File

@@ -0,0 +1,59 @@
;; Events payments components
(defcomp ~events-payments-panel (&key update-url csrf merchant-code placeholder input-cls sumup-configured checkout-prefix)
(section :class "p-4 max-w-lg mx-auto"
(div :id "payments-panel" :class "space-y-4 p-4 bg-white rounded-lg border border-stone-200"
(h3 :class "text-lg font-semibold text-stone-800"
(i :class "fa fa-credit-card text-purple-600 mr-1") " SumUp Payment")
(p :class "text-xs text-stone-400" "Configure per-page SumUp credentials. Leave blank to use the global merchant account.")
(form :hx-put update-url :hx-target "#payments-panel" :hx-swap "outerHTML" :hx-select "#payments-panel" :class "space-y-3"
(input :type "hidden" :name "csrf_token" :value csrf)
(div (label :class "block text-xs font-medium text-stone-600 mb-1" "Merchant Code")
(input :type "text" :name "merchant_code" :value merchant-code :placeholder "e.g. ME4J6100" :class input-cls))
(div (label :class "block text-xs font-medium text-stone-600 mb-1" "API Key")
(input :type "password" :name "api_key" :value "" :placeholder placeholder :class input-cls)
(when sumup-configured (p :class "text-xs text-stone-400 mt-0.5" "Key is set. Leave blank to keep current key.")))
(div (label :class "block text-xs font-medium text-stone-600 mb-1" "Checkout Reference Prefix")
(input :type "text" :name "checkout_prefix" :value checkout-prefix :placeholder "e.g. ROSE-" :class input-cls))
(button :type "submit" :class "px-4 py-1.5 text-sm font-medium text-white bg-purple-600 rounded hover:bg-purple-700 focus:ring-2 focus:ring-purple-500"
"Save SumUp Settings")
(when sumup-configured (span :class "ml-2 text-xs text-green-600"
(i :class "fa fa-check-circle") " Connected"))))))
(defcomp ~events-markets-create-form (&key create-url csrf)
(<>
(div :id "market-create-errors" :class "mt-2 text-sm text-red-600")
(form :class "mt-4 flex gap-2 items-end" :hx-post create-url
:hx-target "#markets-list" :hx-select "#markets-list" :hx-swap "outerHTML"
:hx-on::before-request "document.querySelector('#market-create-errors').textContent='';"
:hx-on::response-error "document.querySelector('#market-create-errors').innerHTML = event.detail.xhr.responseText;"
(input :type "hidden" :name "csrf_token" :value csrf)
(div :class "flex-1"
(label :class "block text-sm text-gray-600" "Name")
(input :name "name" :type "text" :required true :class "w-full border rounded px-3 py-2"
:placeholder "e.g. Farm Shop, Bakery"))
(button :type "submit" :class "border rounded px-3 py-2" "Add market"))))
(defcomp ~events-markets-panel (&key form-html list-html)
(section :class "p-4"
(raw! form-html)
(div :id "markets-list" :class "mt-6" (raw! list-html))))
(defcomp ~events-markets-empty ()
(p :class "text-gray-500 mt-4" "No markets yet. Create one above."))
(defcomp ~events-markets-item (&key href market-name market-slug del-url csrf-hdr)
(div :class "mt-6 border rounded-lg p-4"
(div :class "flex items-center justify-between gap-3"
(a :class "flex items-baseline gap-3" :href href
(h3 :class "font-semibold" market-name)
(h4 :class "text-gray-500" (str "/" market-slug "/")))
(button :class "text-sm border rounded px-3 py-1 hover:bg-red-50 hover:border-red-400"
:data-confirm true :data-confirm-title "Delete market?"
:data-confirm-text "Products will be hidden (soft delete)"
:data-confirm-icon "warning" :data-confirm-confirm-text "Yes, delete it"
:data-confirm-cancel-text "Cancel" :data-confirm-event "confirmed"
:hx-delete del-url :hx-trigger "confirmed"
:hx-target "#markets-list" :hx-select "#markets-list" :hx-swap "outerHTML"
:hx-headers csrf-hdr
(i :class "fa-solid fa-trash")))))

File diff suppressed because it is too large Load Diff

206
events/sexp/tickets.sexpr Normal file
View File

@@ -0,0 +1,206 @@
;; Events ticket components
(defcomp ~events-ticket-card (&key href entry-name type-name time-str cal-name badge-html code-prefix)
(a :href href :class "block rounded-xl border border-stone-200 bg-white p-4 hover:shadow-md transition"
(div :class "flex items-start justify-between gap-4"
(div :class "flex-1 min-w-0"
(div :class "font-semibold text-lg truncate" entry-name)
(when type-name (div :class "text-sm text-stone-600 mt-0.5" type-name))
(when time-str (div :class "text-sm text-stone-500 mt-1" time-str))
(when cal-name (div :class "text-xs text-stone-400 mt-0.5" cal-name)))
(div :class "flex flex-col items-end gap-1 flex-shrink-0"
(raw! badge-html)
(span :class "text-xs text-stone-400 font-mono" (str code-prefix "..."))))))
(defcomp ~events-tickets-panel (&key list-container has-tickets cards-html)
(section :id "tickets-list" :class list-container
(h1 :class "text-2xl font-bold mb-6" "My Tickets")
(if has-tickets
(div :class "space-y-4" (raw! cards-html))
(div :class "text-center py-12 text-stone-500"
(i :class "fa fa-ticket text-4xl mb-4 block" :aria-hidden "true")
(p :class "text-lg" "No tickets yet")
(p :class "text-sm mt-1" "Tickets will appear here after you purchase them.")))))
(defcomp ~events-ticket-detail (&key list-container back-href header-bg entry-name badge-html
type-name code time-date time-range cal-name
type-desc checkin-str qr-script)
(section :id "ticket-detail" :class (str list-container " max-w-lg mx-auto")
(a :href back-href :class "inline-flex items-center gap-1 text-sm text-stone-500 hover:text-stone-700 mb-4"
(i :class "fa fa-arrow-left" :aria-hidden "true") " Back to my tickets")
(div :class "rounded-2xl border border-stone-200 bg-white overflow-hidden"
(div :class (str "px-6 py-4 border-b border-stone-100 " header-bg)
(div :class "flex items-center justify-between"
(h1 :class "text-xl font-bold" entry-name)
(raw! badge-html))
(when type-name (div :class "text-sm text-stone-600 mt-1" type-name)))
(div :class "px-6 py-8 flex flex-col items-center border-b border-stone-100"
(div :id (str "ticket-qr-" code) :class "bg-white p-4 rounded-lg border border-stone-200")
(p :class "text-xs text-stone-400 mt-3 font-mono select-all" code))
(div :class "px-6 py-4 space-y-3"
(when time-date (div :class "flex items-start gap-3"
(i :class "fa fa-calendar text-stone-400 mt-0.5" :aria-hidden "true")
(div (div :class "text-sm font-medium" time-date)
(div :class "text-sm text-stone-500" time-range))))
(when cal-name (div :class "flex items-start gap-3"
(i :class "fa fa-map-pin text-stone-400 mt-0.5" :aria-hidden "true")
(div :class "text-sm" cal-name)))
(when type-desc (div :class "flex items-start gap-3"
(i :class "fa fa-tag text-stone-400 mt-0.5" :aria-hidden "true")
(div :class "text-sm" type-desc)))
(when checkin-str (div :class "flex items-start gap-3"
(i :class "fa fa-check-circle text-blue-500 mt-0.5" :aria-hidden "true")
(div :class "text-sm text-blue-700" checkin-str)))))
(script :src "https://cdn.jsdelivr.net/npm/qrcode@1.5.3/build/qrcode.min.js")
(script qr-script)))
(defcomp ~events-ticket-admin-stat (&key border bg text-cls label-cls value label)
(div :class (str "rounded-xl border " border " " bg " p-4 text-center")
(div :class (str "text-2xl font-bold " text-cls) value)
(div :class (str "text-xs " label-cls " uppercase tracking-wide") label)))
(defcomp ~events-ticket-admin-date (&key date-str)
(div :class "text-xs text-stone-500" date-str))
(defcomp ~events-ticket-admin-checkin-form (&key checkin-url code csrf)
(form :hx-post checkin-url :hx-target (str "#ticket-row-" code) :hx-swap "outerHTML"
(input :type "hidden" :name "csrf_token" :value csrf)
(button :type "submit" :class "px-3 py-1 bg-blue-600 text-white text-xs rounded hover:bg-blue-700 transition"
(i :class "fa fa-check mr-1" :aria-hidden "true") "Check in")))
(defcomp ~events-ticket-admin-checked-in (&key time-str)
(span :class "text-xs text-blue-600"
(i :class "fa fa-check-circle" :aria-hidden "true") (str " " time-str)))
(defcomp ~events-ticket-admin-row (&key code code-short entry-name date-html type-name badge-html action-html)
(tr :class "hover:bg-stone-50 transition" :id (str "ticket-row-" code)
(td :class "px-4 py-3" (span :class "font-mono text-xs" code-short))
(td :class "px-4 py-3" (div :class "font-medium" entry-name) (raw! date-html))
(td :class "px-4 py-3 text-sm" type-name)
(td :class "px-4 py-3" (raw! badge-html))
(td :class "px-4 py-3" (raw! action-html))))
(defcomp ~events-ticket-admin-panel (&key list-container stats-html lookup-url has-tickets rows-html)
(section :id "ticket-admin" :class list-container
(h1 :class "text-2xl font-bold mb-6" "Ticket Admin")
(div :class "grid grid-cols-2 sm:grid-cols-4 gap-3 mb-8" (raw! stats-html))
(div :class "rounded-xl border border-stone-200 bg-white p-6 mb-8"
(h2 :class "text-lg font-semibold mb-4"
(i :class "fa fa-qrcode mr-2" :aria-hidden "true") "Scan / Look Up Ticket")
(div :class "flex gap-3 mb-4"
(input :type "text" :id "ticket-code-input" :name "code"
:placeholder "Enter or scan ticket code..."
:class "flex-1 px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
:hx-get lookup-url :hx-trigger "keyup changed delay:300ms"
:hx-target "#lookup-result" :hx-include "this" :autofocus "true")
(button :type "button"
:class "px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition"
:onclick "document.getElementById('ticket-code-input').dispatchEvent(new Event('keyup'))"
(i :class "fa fa-search" :aria-hidden "true")))
(div :id "lookup-result"
(div :class "text-sm text-stone-400 text-center py-4" "Enter a ticket code to look it up")))
(div :class "rounded-xl border border-stone-200 bg-white overflow-hidden"
(h2 :class "text-lg font-semibold px-6 py-4 border-b border-stone-100" "Recent Tickets")
(if has-tickets
(div :class "overflow-x-auto"
(table :class "w-full text-sm"
(thead :class "bg-stone-50"
(tr (th :class "px-4 py-3 text-left font-medium text-stone-600" "Code")
(th :class "px-4 py-3 text-left font-medium text-stone-600" "Event")
(th :class "px-4 py-3 text-left font-medium text-stone-600" "Type")
(th :class "px-4 py-3 text-left font-medium text-stone-600" "State")
(th :class "px-4 py-3 text-left font-medium text-stone-600" "Actions")))
(tbody :class "divide-y divide-stone-100" (raw! rows-html))))
(div :class "px-6 py-8 text-center text-stone-500" "No tickets yet")))))
(defcomp ~events-checkin-error (&key message)
(div :class "rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-800"
(i :class "fa fa-exclamation-circle mr-2" :aria-hidden "true") message))
(defcomp ~events-checkin-success-row (&key code code-short entry-name date-html type-name badge-html time-str)
(tr :class "bg-blue-50" :id (str "ticket-row-" code)
(td :class "px-4 py-3" (span :class "font-mono text-xs" code-short))
(td :class "px-4 py-3" (div :class "font-medium" entry-name) (raw! date-html))
(td :class "px-4 py-3 text-sm" type-name)
(td :class "px-4 py-3" (raw! badge-html))
(td :class "px-4 py-3"
(span :class "text-xs text-blue-600"
(i :class "fa fa-check-circle" :aria-hidden "true") (str " " time-str)))))
(defcomp ~events-lookup-error (&key message)
(div :class "rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800"
(i :class "fa fa-exclamation-circle mr-2" :aria-hidden "true") message))
(defcomp ~events-lookup-info (&key entry-name)
(div :class "font-semibold text-lg" entry-name))
(defcomp ~events-lookup-type (&key type-name)
(div :class "text-sm text-stone-600" type-name))
(defcomp ~events-lookup-date (&key date-str)
(div :class "text-sm text-stone-500 mt-1" date-str))
(defcomp ~events-lookup-cal (&key cal-name)
(div :class "text-xs text-stone-400 mt-0.5" cal-name))
(defcomp ~events-lookup-status (&key badge-html code)
(div :class "mt-2" (raw! badge-html) (span :class "text-xs text-stone-400 ml-2 font-mono" code)))
(defcomp ~events-lookup-checkin-time (&key date-str)
(div :class "text-xs text-blue-600 mt-1" (str "Checked in: " date-str)))
(defcomp ~events-lookup-checkin-btn (&key checkin-url code csrf)
(form :hx-post checkin-url :hx-target (str "#checkin-action-" code) :hx-swap "innerHTML"
(input :type "hidden" :name "csrf_token" :value csrf)
(button :type "submit"
:class "px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition font-semibold text-lg"
(i :class "fa fa-check mr-2" :aria-hidden "true") "Check In")))
(defcomp ~events-lookup-checked-in ()
(div :class "text-blue-600 text-center"
(i :class "fa fa-check-circle text-3xl" :aria-hidden "true")
(div :class "text-sm font-medium mt-1" "Checked In")))
(defcomp ~events-lookup-cancelled ()
(div :class "text-red-600 text-center"
(i :class "fa fa-times-circle text-3xl" :aria-hidden "true")
(div :class "text-sm font-medium mt-1" "Cancelled")))
(defcomp ~events-lookup-card (&key info-html code action-html)
(div :class "rounded-lg border border-stone-200 bg-stone-50 p-4"
(div :class "flex items-start justify-between gap-4"
(div :class "flex-1" (raw! info-html))
(div :id (str "checkin-action-" code) (raw! action-html)))))
(defcomp ~events-entry-tickets-admin-row (&key code code-short type-name badge-html action-html)
(tr :class "hover:bg-stone-50" :id (str "entry-ticket-row-" code)
(td :class "px-4 py-2 font-mono text-xs" code-short)
(td :class "px-4 py-2" type-name)
(td :class "px-4 py-2" (raw! badge-html))
(td :class "px-4 py-2" (raw! action-html))))
(defcomp ~events-entry-tickets-admin-checkin (&key checkin-url code csrf)
(form :hx-post checkin-url :hx-target (str "#entry-ticket-row-" code) :hx-swap "outerHTML"
(input :type "hidden" :name "csrf_token" :value csrf)
(button :type "submit" :class "px-3 py-1 bg-blue-600 text-white text-xs rounded hover:bg-blue-700"
"Check in")))
(defcomp ~events-entry-tickets-admin-table (&key rows-html)
(div :class "overflow-x-auto rounded-xl border border-stone-200"
(table :class "w-full text-sm"
(thead :class "bg-stone-50"
(tr (th :class "px-4 py-2 text-left font-medium text-stone-600" "Code")
(th :class "px-4 py-2 text-left font-medium text-stone-600" "Type")
(th :class "px-4 py-2 text-left font-medium text-stone-600" "State")
(th :class "px-4 py-2 text-left font-medium text-stone-600" "Actions")))
(tbody :class "divide-y divide-stone-100" (raw! rows-html)))))
(defcomp ~events-entry-tickets-admin-empty ()
(div :class "text-center py-6 text-stone-500 text-sm" "No tickets for this entry"))
(defcomp ~events-entry-tickets-admin-panel (&key entry-name count-label body-html)
(div :class "space-y-4"
(div :class "flex items-center justify-between"
(h3 :class "text-lg font-semibold" (str "Tickets for: " entry-name))
(span :class "text-sm text-stone-500" count-label))
(raw! body-html)))

View File

@@ -422,8 +422,9 @@ def register(url_prefix="/social"):
return Response("0", content_type="text/plain") return Response("0", content_type="text/plain")
count = await services.federation.unread_notification_count(g.s, actor.id) count = await services.federation.unread_notification_count(g.s, actor.id)
if count > 0: if count > 0:
from shared.sexp.jinja_bridge import render as render_comp
return Response( return Response(
f'<span class="bg-red-500 text-white text-xs rounded-full px-1.5 py-0.5">{count}</span>', render_comp("notification-badge", count=str(count)),
content_type="text/html", content_type="text/html",
) )
return Response("", content_type="text/html") return Response("", content_type="text/html")

View File

@@ -0,0 +1,52 @@
;; Auth components (login, check email, choose username)
(defcomp ~federation-error-banner (&key error)
(div :class "bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4" (raw! error)))
(defcomp ~federation-login-form (&key error-html action csrf email)
(div :class "py-8 max-w-md mx-auto"
(h1 :class "text-2xl font-bold mb-6" "Sign in")
(raw! error-html)
(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"))))
(defcomp ~federation-check-email-error (&key error)
(div :class "bg-yellow-50 border border-yellow-200 text-yellow-700 p-3 rounded mt-4" (raw! error)))
(defcomp ~federation-check-email (&key email error-html)
(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! error-html)))
(defcomp ~federation-choose-username (&key domain error-html csrf username check-url)
(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! error-html)
(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 username
: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"))))

View File

@@ -0,0 +1,25 @@
;; Notification components
(defcomp ~federation-notification-preview (&key preview)
(div :class "text-sm text-stone-500 mt-1 truncate" (raw! preview)))
(defcomp ~federation-notification-card (&key cls avatar-html from-name from-username from-domain action-text preview-html time-html)
(div :class cls
(div :class "flex items-start gap-3"
(raw! avatar-html)
(div :class "flex-1"
(div :class "text-sm"
(span :class "font-semibold" (raw! from-name))
" " (span :class "text-stone-500" "@" (raw! from-username) (raw! from-domain))
" " (span :class "text-stone-600" (raw! action-text)))
(raw! preview-html)
(div :class "text-xs text-stone-400 mt-1" (raw! time-html))))))
(defcomp ~federation-notifications-empty ()
(p :class "text-stone-500" "No notifications yet."))
(defcomp ~federation-notifications-list (&key items-html)
(div :class "space-y-2" (raw! items-html)))
(defcomp ~federation-notifications-page (&key notifs-html)
(h1 :class "text-2xl font-bold mb-6" "Notifications") (raw! notifs-html))

View File

@@ -0,0 +1,55 @@
;; Profile and actor timeline components
(defcomp ~federation-actor-profile-header (&key avatar-html display-name username domain summary-html follow-html)
(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-html)
(div :class "flex-1"
(h1 :class "text-xl font-bold" (raw! display-name))
(div :class "text-stone-500" "@" (raw! username) "@" (raw! domain))
(raw! summary-html))
(raw! follow-html))))
(defcomp ~federation-actor-timeline-layout (&key header-html timeline-html)
(raw! header-html)
(div :id "timeline" (raw! timeline-html)))
(defcomp ~federation-follow-form (&key action csrf actor-url label cls)
(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 actor-url)
(button :type "submit" :class cls (raw! label)))))
(defcomp ~federation-profile-summary (&key summary)
(div :class "text-sm text-stone-600 mt-2" (raw! summary)))
;; Public profile page
(defcomp ~federation-activity-obj-type (&key obj-type)
(span :class "text-sm text-stone-500" (raw! obj-type)))
(defcomp ~federation-activity-card (&key activity-type published obj-type-html)
(div :class "bg-white rounded-lg shadow p-4"
(div :class "flex justify-between items-start"
(span :class "font-medium" (raw! activity-type))
(span :class "text-sm text-stone-400" (raw! published)))
(raw! obj-type-html)))
(defcomp ~federation-activities-list (&key items-html)
(div :class "space-y-4" (raw! items-html)))
(defcomp ~federation-activities-empty ()
(p :class "text-stone-500" "No activities yet."))
(defcomp ~federation-profile-page (&key display-name username domain summary-html activities-heading activities-html)
(div :class "py-8"
(div :class "bg-white rounded-lg shadow p-6 mb-6"
(h1 :class "text-2xl font-bold" (raw! display-name))
(p :class "text-stone-500" "@" (raw! username) "@" (raw! domain))
(raw! summary-html))
(h2 :class "text-xl font-bold mb-4" (raw! activities-heading))
(raw! activities-html)))
(defcomp ~federation-profile-summary-text (&key text)
(p :class "mt-2" (raw! text)))

View File

@@ -0,0 +1,61 @@
;; Search and actor card components
(defcomp ~federation-actor-avatar-img (&key src cls)
(img :src src :alt "" :class cls))
(defcomp ~federation-actor-avatar-placeholder (&key cls initial)
(div :class cls (raw! initial)))
(defcomp ~federation-actor-name-link (&key href name)
(a :href href :class "font-semibold text-stone-900 hover:underline" (raw! name)))
(defcomp ~federation-actor-name-link-external (&key href name)
(a :href href :target "_blank" :rel "noopener"
:class "font-semibold text-stone-900 hover:underline" (raw! name)))
(defcomp ~federation-actor-summary (&key summary)
(div :class "text-sm text-stone-600 mt-1 truncate" (raw! summary)))
(defcomp ~federation-unfollow-button (&key action csrf actor-url)
(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 actor-url)
(button :type "submit" :class "text-sm border border-stone-300 rounded px-3 py-1 hover:bg-stone-100" "Unfollow"))))
(defcomp ~federation-follow-button (&key action csrf actor-url label)
(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 actor-url)
(button :type "submit" :class "text-sm bg-stone-800 text-white rounded px-3 py-1 hover:bg-stone-700" (raw! label)))))
(defcomp ~federation-actor-card (&key cls id avatar-html name-html username domain summary-html button-html)
(article :class cls :id id
(raw! avatar-html)
(div :class "flex-1 min-w-0"
(raw! name-html)
(div :class "text-sm text-stone-500" "@" (raw! username) "@" (raw! domain))
(raw! summary-html))
(raw! button-html)))
(defcomp ~federation-search-info (&key cls text)
(p :class cls (raw! text)))
(defcomp ~federation-search-page (&key search-url search-page-url query info-html results-html)
(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-html)
(div :id "search-results" (raw! results-html)))
;; Following / Followers list page
(defcomp ~federation-actor-list-page (&key title count-str items-html)
(h1 :class "text-2xl font-bold mb-6" (raw! title) " "
(span :class "text-stone-400 font-normal" (raw! count-str)))
(div :id "actor-list" (raw! items-html)))

View File

@@ -6,12 +6,16 @@ actor profiles, login, and username selection pages.
""" """
from __future__ import annotations from __future__ import annotations
import os
from typing import Any from typing import Any
from markupsafe import escape from markupsafe import escape
from shared.sexp.jinja_bridge import sexp from shared.sexp.jinja_bridge import render, load_service_components
from shared.sexp.helpers import root_header_html, full_page from shared.sexp.helpers import root_header_html, full_page
# Load federation-specific .sexpr components at import time
load_service_components(os.path.dirname(os.path.dirname(__file__)))
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Social header nav # Social header nav
@@ -23,11 +27,7 @@ def _social_nav_html(actor: Any) -> str:
if not actor: if not actor:
choose_url = url_for("identity.choose_username_form") choose_url = url_for("identity.choose_username_form")
return sexp( return render("federation-nav-choose-username", url=choose_url)
'(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 = [ links = [
("social.home_timeline", "Timeline"), ("social.home_timeline", "Timeline"),
@@ -42,8 +42,8 @@ def _social_nav_html(actor: Any) -> str:
for endpoint, label in links: for endpoint, label in links:
href = url_for(endpoint) href = url_for(endpoint)
bold = " font-bold" if request.path == href else "" bold = " font-bold" if request.path == href else ""
parts.append(sexp( parts.append(render(
'(a :href href :class cls (raw! label))', "federation-nav-link",
href=href, href=href,
cls=f"px-2 py-1 rounded hover:bg-stone-200{bold}", cls=f"px-2 py-1 rounded hover:bg-stone-200{bold}",
label=label, label=label,
@@ -53,47 +53,38 @@ def _social_nav_html(actor: Any) -> str:
notif_url = url_for("social.notifications") notif_url = url_for("social.notifications")
notif_count_url = url_for("social.notification_count") notif_count_url = url_for("social.notification_count")
notif_bold = " font-bold" if request.path == notif_url else "" notif_bold = " font-bold" if request.path == notif_url else ""
parts.append(sexp( parts.append(render(
'(a :href href :class cls "Notifications"' "federation-nav-notification-link",
' (span :hx-get count-url :hx-trigger "load, every 30s" :hx-swap "innerHTML"' href=notif_url,
' :class "absolute -top-2 -right-3 text-xs bg-red-500 text-white rounded-full px-1 empty:hidden"))', cls=f"px-2 py-1 rounded hover:bg-stone-200 relative{notif_bold}",
href=notif_url, cls=f"px-2 py-1 rounded hover:bg-stone-200 relative{notif_bold}", count_url=notif_count_url,
**{"count-url": notif_count_url},
)) ))
# Profile link # Profile link
profile_url = url_for("activitypub.actor_profile", username=actor.preferred_username) profile_url = url_for("activitypub.actor_profile", username=actor.preferred_username)
parts.append(sexp( parts.append(render(
'(a :href href :class "px-2 py-1 rounded hover:bg-stone-200" (raw! label))', "federation-nav-link",
href=profile_url, label=f"@{actor.preferred_username}", href=profile_url,
cls="px-2 py-1 rounded hover:bg-stone-200",
label=f"@{actor.preferred_username}",
)) ))
return sexp( return render("federation-nav-bar", items_html="".join(parts))
'(nav :class "flex gap-3 text-sm items-center flex-wrap" (raw! items))',
items="".join(parts),
)
def _social_header_html(actor: Any) -> str: def _social_header_html(actor: Any) -> str:
"""Build the social section header row.""" """Build the social section header row."""
nav_html = _social_nav_html(actor) nav_html = _social_nav_html(actor)
return sexp( return render("federation-social-header", nav_html=nav_html)
'(div :id "social-row" :class "flex flex-col items-center md:flex-row justify-center md:justify-between w-full p-1 bg-sky-400"'
' (div :class "w-full flex flex-row items-center gap-2 flex-wrap" (raw! nh)))',
nh=nav_html,
)
def _social_page(ctx: dict, actor: Any, *, content_html: str, def _social_page(ctx: dict, actor: Any, *, content_html: str,
title: str = "Rose Ash", meta_html: str = "") -> str: title: str = "Rose Ash", meta_html: str = "") -> str:
"""Render a social page with header and content.""" """Render a social page with header and content."""
hdr = root_header_html(ctx) hdr = root_header_html(ctx)
hdr += sexp( hdr += render("federation-header-child", inner_html=_social_header_html(actor))
'(div :id "root-header-child" :class "flex flex-col w-full items-center" (raw! sh))',
sh=_social_header_html(actor),
)
return full_page(ctx, header_rows_html=hdr, content_html=content_html, return full_page(ctx, header_rows_html=hdr, content_html=content_html,
meta_html=meta_html or sexp('(title (raw! t))', t=escape(title))) meta_html=meta_html or f'<title>{escape(title)}</title>')
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -133,37 +124,25 @@ def _interaction_buttons_html(item: Any, actor: Any) -> str:
boost_cls = "hover:text-green-600" boost_cls = "hover:text-green-600"
reply_url = url_for("social.compose_form", reply_to=oid) if oid else "" reply_url = url_for("social.compose_form", reply_to=oid) if oid else ""
reply_html = sexp( reply_html = render("federation-reply-link", url=reply_url) if reply_url else ""
'(a :href url :class "hover:text-stone-700" "Reply")',
url=reply_url,
) if reply_url else ""
like_form = sexp( like_form = render(
'(form :hx-post action :hx-target target :hx-swap "innerHTML"' "federation-like-form",
' (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, action=like_action, target=target, oid=oid, ainbox=ainbox,
csrf=csrf, cls=f"flex items-center gap-1 {like_cls}", csrf=csrf, cls=f"flex items-center gap-1 {like_cls}",
icon=like_icon, count=str(lcount), icon=like_icon, count=str(lcount),
) )
boost_form = sexp( boost_form = render(
'(form :hx-post action :hx-target target :hx-swap "innerHTML"' "federation-boost-form",
' (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, action=boost_action, target=target, oid=oid, ainbox=ainbox,
csrf=csrf, cls=f"flex items-center gap-1 {boost_cls}", csrf=csrf, cls=f"flex items-center gap-1 {boost_cls}",
count=str(bcount), count=str(bcount),
) )
return sexp( return render(
'(div :class "flex items-center gap-4 mt-3 text-sm text-stone-500"' "federation-interaction-buttons",
' (raw! like) (raw! boost) (raw! reply))', like_html=like_form, boost_html=boost_form, reply_html=reply_html,
like=like_form, boost=boost_form, reply=reply_html,
) )
@@ -180,74 +159,53 @@ def _post_card_html(item: Any, actor: Any) -> str:
url = getattr(item, "url", None) url = getattr(item, "url", None)
post_type = getattr(item, "post_type", "") post_type = getattr(item, "post_type", "")
boost_html = sexp( boost_html = render(
'(div :class "text-sm text-stone-500 mb-2" "Boosted by " (raw! name))', "federation-boost-label", name=str(escape(boosted_by)),
name=str(escape(boosted_by)),
) if boosted_by else "" ) if boosted_by else ""
if actor_icon: if actor_icon:
avatar = sexp( avatar = render("federation-avatar-img", src=actor_icon, cls="w-10 h-10 rounded-full")
'(img :src src :alt "" :class "w-10 h-10 rounded-full")',
src=actor_icon,
)
else: else:
initial = actor_name[0].upper() if actor_name else "?" initial = actor_name[0].upper() if actor_name else "?"
avatar = sexp( avatar = render(
'(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))', "federation-avatar-placeholder",
i=initial, cls="w-10 h-10 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-sm",
initial=initial,
) )
domain_html = f"@{escape(actor_domain)}" if actor_domain else "" domain_html = f"@{escape(actor_domain)}" if actor_domain else ""
time_html = published.strftime("%b %d, %H:%M") if published else "" time_html = published.strftime("%b %d, %H:%M") if published else ""
if summary: if summary:
content_html = sexp( content_html = render(
'(details :class "mt-2"' "federation-content-cw",
' (summary :class "text-stone-500 cursor-pointer" "CW: " (raw! s))' summary=str(escape(summary)), content=content,
' (div :class "mt-2 prose prose-sm prose-stone max-w-none" (raw! c)))',
s=str(escape(summary)), c=content,
) )
else: else:
content_html = sexp( content_html = render("federation-content-plain", content=content)
'(div :class "mt-2 prose prose-sm prose-stone max-w-none" (raw! c))',
c=content,
)
original_html = "" original_html = ""
if url and post_type == "remote": if url and post_type == "remote":
original_html = sexp( original_html = render("federation-original-link", url=url)
'(a :href url :target "_blank" :rel "noopener"'
' :class "text-sm text-stone-400 hover:underline mt-1 inline-block" "original")',
url=url,
)
interactions_html = "" interactions_html = ""
if actor: if actor:
oid = getattr(item, "object_id", "") or "" oid = getattr(item, "object_id", "") or ""
safe_id = oid.replace("/", "_").replace(":", "_") safe_id = oid.replace("/", "_").replace(":", "_")
interactions_html = sexp( interactions_html = render(
'(div :id id (raw! buttons))', "federation-interactions-wrap",
id=f"interactions-{safe_id}", id=f"interactions-{safe_id}",
buttons=_interaction_buttons_html(item, actor), buttons_html=_interaction_buttons_html(item, actor),
) )
return sexp( return render(
'(article :class "bg-white rounded-lg shadow-sm border border-stone-200 p-4 mb-4"' "federation-post-card",
' (raw! boost)' boost_html=boost_html, avatar_html=avatar,
' (div :class "flex items-start gap-3"' actor_name=str(escape(actor_name)),
' (raw! avatar)' actor_username=str(escape(actor_username)),
' (div :class "flex-1 min-w-0"' domain_html=domain_html, time_html=time_html,
' (div :class "flex items-baseline gap-2"' content_html=content_html, original_html=original_html,
' (span :class "font-semibold text-stone-900" (raw! aname))' interactions_html=interactions_html,
' (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,
) )
@@ -269,10 +227,7 @@ def _timeline_items_html(items: list, timeline_type: str, actor: Any,
next_url = url_for("social.actor_timeline_page", id=actor_id, before=before) next_url = url_for("social.actor_timeline_page", id=actor_id, before=before)
else: else:
next_url = url_for(f"social.{timeline_type}_timeline_page", before=before) next_url = url_for(f"social.{timeline_type}_timeline_page", before=before)
parts.append(sexp( parts.append(render("federation-scroll-sentinel", url=next_url))
'(div :hx-get url :hx-trigger "revealed" :hx-swap "outerHTML")',
url=next_url,
))
return "".join(parts) return "".join(parts)
@@ -299,75 +254,54 @@ def _actor_card_html(a: Any, actor: Any, followed_urls: set,
safe_id = actor_url.replace("/", "_").replace(":", "_") safe_id = actor_url.replace("/", "_").replace(":", "_")
if icon_url: if icon_url:
avatar = sexp( avatar = render("federation-actor-avatar-img", src=icon_url, cls="w-12 h-12 rounded-full")
'(img :src src :alt "" :class "w-12 h-12 rounded-full")',
src=icon_url,
)
else: else:
initial = (display_name or username)[0].upper() if (display_name or username) else "?" initial = (display_name or username)[0].upper() if (display_name or username) else "?"
avatar = sexp( avatar = render(
'(div :class "w-12 h-12 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold" (raw! i))', "federation-actor-avatar-placeholder",
i=initial, cls="w-12 h-12 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold",
initial=initial,
) )
# Name link # Name link
if (list_type in ("following", "search")) and aid: if (list_type in ("following", "search")) and aid:
name_html = sexp( name_html = render(
'(a :href href :class "font-semibold text-stone-900 hover:underline" (raw! name))', "federation-actor-name-link",
href=url_for("social.actor_timeline", id=aid), href=url_for("social.actor_timeline", id=aid),
name=str(escape(display_name)), name=str(escape(display_name)),
) )
else: else:
name_html = sexp( name_html = render(
'(a :href href :target "_blank" :rel "noopener"' "federation-actor-name-link-external",
' :class "font-semibold text-stone-900 hover:underline" (raw! name))',
href=f"https://{domain}/@{username}", href=f"https://{domain}/@{username}",
name=str(escape(display_name)), name=str(escape(display_name)),
) )
summary_html = sexp( summary_html = render("federation-actor-summary", summary=summary) if summary else ""
'(div :class "text-sm text-stone-600 mt-1 truncate" (raw! s))',
s=summary,
) if summary else ""
# Follow/unfollow button # Follow/unfollow button
button_html = "" button_html = ""
if actor: if actor:
is_followed = actor_url in (followed_urls or set()) is_followed = actor_url in (followed_urls or set())
if list_type == "following" or is_followed: if list_type == "following" or is_followed:
button_html = sexp( button_html = render(
'(div :class "flex-shrink-0"' "federation-unfollow-button",
' (form :method "post" :action action :hx-post action :hx-target "closest article" :hx-swap "outerHTML"' action=url_for("social.unfollow"), csrf=csrf, actor_url=actor_url,
' (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: else:
label = "Follow Back" if list_type == "followers" else "Follow" label = "Follow Back" if list_type == "followers" else "Follow"
button_html = sexp( button_html = render(
'(div :class "flex-shrink-0"' "federation-follow-button",
' (form :method "post" :action action :hx-post action :hx-target "closest article" :hx-swap "outerHTML"' action=url_for("social.follow"), csrf=csrf, actor_url=actor_url, label=label,
' (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 sexp( return render(
'(article :class cls :id id' "federation-actor-card",
' (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", 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}", id=f"actor-{safe_id}",
avatar=avatar, avatar_html=avatar, name_html=name_html,
**{"name-link": name_html},
username=str(escape(username)), domain=str(escape(domain)), username=str(escape(username)), domain=str(escape(domain)),
summary=summary_html, button=button_html, summary_html=summary_html, button_html=button_html,
) )
@@ -379,10 +313,7 @@ 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] parts = [_actor_card_html(a, actor, followed_urls, list_type="search") for a in actors]
if len(actors) >= 20: if len(actors) >= 20:
next_url = url_for("social.search_page", q=query, page=page + 1) next_url = url_for("social.search_page", q=query, page=page + 1)
parts.append(sexp( parts.append(render("federation-scroll-sentinel", url=next_url))
'(div :hx-get url :hx-trigger "revealed" :hx-swap "outerHTML")',
url=next_url,
))
return "".join(parts) return "".join(parts)
@@ -394,10 +325,7 @@ 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] parts = [_actor_card_html(a, actor, followed_urls, list_type=list_type) for a in actors]
if len(actors) >= 20: if len(actors) >= 20:
next_url = url_for(f"social.{list_type}_list_page", page=page + 1) next_url = url_for(f"social.{list_type}_list_page", page=page + 1)
parts.append(sexp( parts.append(render("federation-scroll-sentinel", url=next_url))
'(div :hx-get url :hx-trigger "revealed" :hx-swap "outerHTML")',
url=next_url,
))
return "".join(parts) return "".join(parts)
@@ -420,15 +348,13 @@ def _notification_html(notif: Any) -> str:
border = " border-l-4 border-l-stone-400" if not read else "" border = " border-l-4 border-l-stone-400" if not read else ""
if from_icon: if from_icon:
avatar = sexp( avatar = render("federation-avatar-img", src=from_icon, cls="w-8 h-8 rounded-full")
'(img :src src :alt "" :class "w-8 h-8 rounded-full")',
src=from_icon,
)
else: else:
initial = from_name[0].upper() if from_name else "?" initial = from_name[0].upper() if from_name else "?"
avatar = sexp( avatar = render(
'(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))', "federation-avatar-placeholder",
i=initial, cls="w-8 h-8 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-xs",
initial=initial,
) )
domain_html = f"@{escape(from_domain)}" if from_domain else "" domain_html = f"@{escape(from_domain)}" if from_domain else ""
@@ -444,29 +370,19 @@ def _notification_html(notif: Any) -> str:
if ntype == "follow" and app_domain and app_domain != "federation": if ntype == "follow" and app_domain and app_domain != "federation":
action += f" on {escape(app_domain)}" action += f" on {escape(app_domain)}"
preview_html = sexp( preview_html = render(
'(div :class "text-sm text-stone-500 mt-1 truncate" (raw! p))', "federation-notification-preview", preview=str(escape(preview)),
p=str(escape(preview)),
) if preview else "" ) if preview else ""
time_html = created.strftime("%b %d, %H:%M") if created else "" time_html = created.strftime("%b %d, %H:%M") if created else ""
return sexp( return render(
'(div :class cls' "federation-notification-card",
' (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}", cls=f"bg-white rounded-lg shadow-sm border border-stone-200 p-4{border}",
avatar=avatar, avatar_html=avatar,
fname=str(escape(from_name)), from_name=str(escape(from_name)),
fusername=str(escape(from_username)), from_username=str(escape(from_username)),
fdomain=domain_html, action=action, from_domain=domain_html, action_text=action,
preview=preview_html, time=time_html, preview_html=preview_html, time_html=time_html,
) )
@@ -494,25 +410,11 @@ async def render_login_page(ctx: dict) -> str:
action = url_for("auth.start_login") action = url_for("auth.start_login")
csrf = generate_csrf_token() csrf = generate_csrf_token()
error_html = sexp( error_html = render("federation-error-banner", error=error) if error else ""
'(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( content = render(
'(div :class "py-8 max-w-md mx-auto"' "federation-login-form",
' (h1 :class "text-2xl font-bold mb-6" "Sign in")' error_html=error_html, action=action, csrf=csrf,
' (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)), email=str(escape(email)),
) )
@@ -526,18 +428,13 @@ async def render_check_email_page(ctx: dict) -> str:
email = ctx.get("email", "") email = ctx.get("email", "")
email_error = ctx.get("email_error") email_error = ctx.get("email_error")
error_html = sexp( error_html = render(
'(div :class "bg-yellow-50 border border-yellow-200 text-yellow-700 p-3 rounded mt-4" (raw! e))', "federation-check-email-error", error=str(escape(email_error)),
e=str(escape(email_error)),
) if email_error else "" ) if email_error else ""
content = sexp( content = render(
'(div :class "py-8 max-w-md mx-auto text-center"' "federation-check-email",
' (h1 :class "text-2xl font-bold mb-4" "Check your email")' email=str(escape(email)), error_html=error_html,
' (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) hdr = root_header_html(ctx)
@@ -558,19 +455,13 @@ async def render_timeline_page(ctx: dict, items: list, timeline_type: str,
compose_html = "" compose_html = ""
if actor: if actor:
compose_url = url_for("social.compose_form") compose_url = url_for("social.compose_form")
compose_html = sexp( compose_html = render("federation-compose-button", url=compose_url)
'(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) timeline_html = _timeline_items_html(items, timeline_type, actor)
content = sexp( content = render(
'(div :class "flex items-center justify-between mb-6"' "federation-timeline-page",
' (h1 :class "text-2xl font-bold" (raw! label) " Timeline")' label=label, compose_html=compose_html, timeline_html=timeline_html,
' (raw! compose))'
'(div :id "timeline" (raw! tl))',
label=label, compose=compose_html, tl=timeline_html,
) )
return _social_page(ctx, actor, content_html=content, return _social_page(ctx, actor, content_html=content,
@@ -597,27 +488,14 @@ async def render_compose_page(ctx: dict, actor: Any, reply_to: str | None) -> st
reply_html = "" reply_html = ""
if reply_to: if reply_to:
reply_html = sexp( reply_html = render(
'(input :type "hidden" :name "in_reply_to" :value val)' "federation-compose-reply",
'(div :class "text-sm text-stone-500" "Replying to " (span :class "font-mono" (raw! rt)))', reply_to=str(escape(reply_to)),
val=str(escape(reply_to)), rt=str(escape(reply_to)),
) )
content = sexp( content = render(
'(h1 :class "text-2xl font-bold mb-6" "Compose")' "federation-compose-form",
'(form :method "post" :action action :class "space-y-4"' action=action, csrf=csrf, reply_html=reply_html,
' (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, return _social_page(ctx, actor, content_html=content,
@@ -641,30 +519,23 @@ async def render_search_page(ctx: dict, query: str, actors: list, total: int,
info_html = "" info_html = ""
if query and total: if query and total:
s = "s" if total != 1 else "" s = "s" if total != 1 else ""
info_html = sexp( info_html = render(
'(p :class "text-sm text-stone-500 mb-4" (raw! t))', "federation-search-info",
t=f"{total} result{s} for <strong>{escape(query)}</strong>", cls="text-sm text-stone-500 mb-4",
text=f"{total} result{s} for <strong>{escape(query)}</strong>",
) )
elif query: elif query:
info_html = sexp( info_html = render(
'(p :class "text-stone-500 mb-4" (raw! t))', "federation-search-info",
t=f"No results found for <strong>{escape(query)}</strong>", cls="text-stone-500 mb-4",
text=f"No results found for <strong>{escape(query)}</strong>",
) )
content = sexp( content = render(
'(h1 :class "text-2xl font-bold mb-6" "Search")' "federation-search-page",
'(form :method "get" :action search-url :class "mb-6"' search_url=search_url, search_page_url=search_page_url,
' :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)), query=str(escape(query)),
info=info_html, results=results_html, info_html=info_html, results_html=results_html,
) )
return _social_page(ctx, actor, content_html=content, return _social_page(ctx, actor, content_html=content,
@@ -685,11 +556,9 @@ async def render_following_page(ctx: dict, actors: list, total: int,
actor: Any) -> str: actor: Any) -> str:
"""Full page: following list.""" """Full page: following list."""
items_html = _actor_list_items_html(actors, 1, "following", set(), actor) items_html = _actor_list_items_html(actors, 1, "following", set(), actor)
content = sexp( content = render(
'(h1 :class "text-2xl font-bold mb-6" "Following "' "federation-actor-list-page",
' (span :class "text-stone-400 font-normal" (raw! count-str)))' title="Following", count_str=f"({total})", items_html=items_html,
'(div :id "actor-list" (raw! items))',
**{"count-str": f"({total})"}, items=items_html,
) )
return _social_page(ctx, actor, content_html=content, return _social_page(ctx, actor, content_html=content,
title="Following \u2014 Rose Ash") title="Following \u2014 Rose Ash")
@@ -704,11 +573,9 @@ async def render_followers_page(ctx: dict, actors: list, total: int,
followed_urls: set, actor: Any) -> str: followed_urls: set, actor: Any) -> str:
"""Full page: followers list.""" """Full page: followers list."""
items_html = _actor_list_items_html(actors, 1, "followers", followed_urls, actor) items_html = _actor_list_items_html(actors, 1, "followers", followed_urls, actor)
content = sexp( content = render(
'(h1 :class "text-2xl font-bold mb-6" "Followers "' "federation-actor-list-page",
' (span :class "text-stone-400 font-normal" (raw! count-str)))' title="Followers", count_str=f"({total})", items_html=items_html,
'(div :id "actor-list" (raw! items))',
**{"count-str": f"({total})"}, items=items_html,
) )
return _social_page(ctx, actor, content_html=content, return _social_page(ctx, actor, content_html=content,
title="Followers \u2014 Rose Ash") title="Followers \u2014 Rose Ash")
@@ -737,61 +604,48 @@ async def render_actor_timeline_page(ctx: dict, remote_actor: Any, items: list,
actor_url = getattr(remote_actor, "actor_url", "") actor_url = getattr(remote_actor, "actor_url", "")
if icon_url: if icon_url:
avatar = sexp( avatar = render("federation-avatar-img", src=icon_url, cls="w-16 h-16 rounded-full")
'(img :src src :alt "" :class "w-16 h-16 rounded-full")',
src=icon_url,
)
else: else:
initial = display_name[0].upper() if display_name else "?" initial = display_name[0].upper() if display_name else "?"
avatar = sexp( avatar = render(
'(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))', "federation-avatar-placeholder",
i=initial, cls="w-16 h-16 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-xl",
initial=initial,
) )
summary_html = sexp( summary_html = render("federation-profile-summary", summary=summary) if summary else ""
'(div :class "text-sm text-stone-600 mt-2" (raw! s))',
s=summary,
) if summary else ""
follow_html = "" follow_html = ""
if actor: if actor:
if is_following: if is_following:
follow_html = sexp( follow_html = render(
'(div :class "flex-shrink-0"' "federation-follow-form",
' (form :method "post" :action action' action=url_for("social.unfollow"), csrf=csrf, actor_url=actor_url,
' (input :type "hidden" :name "csrf_token" :value csrf)' label="Unfollow",
' (input :type "hidden" :name "actor_url" :value aurl)' cls="border border-stone-300 rounded px-4 py-2 hover:bg-stone-100",
' (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: else:
follow_html = sexp( follow_html = render(
'(div :class "flex-shrink-0"' "federation-follow-form",
' (form :method "post" :action action' action=url_for("social.follow"), csrf=csrf, actor_url=actor_url,
' (input :type "hidden" :name "csrf_token" :value csrf)' label="Follow",
' (input :type "hidden" :name "actor_url" :value aurl)' cls="bg-stone-800 text-white rounded px-4 py-2 hover:bg-stone-700",
' (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) timeline_html = _timeline_items_html(items, "actor", actor, remote_actor.id)
content = sexp( header_html = render(
'(div :class "bg-white rounded-lg shadow-sm border border-stone-200 p-6 mb-6"' "federation-actor-profile-header",
' (div :class "flex items-center gap-4"' avatar_html=avatar,
' (raw! avatar)' display_name=str(escape(display_name)),
' (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)), username=str(escape(remote_actor.preferred_username)),
domain=str(escape(remote_actor.domain)), domain=str(escape(remote_actor.domain)),
summary=summary_html, follow=follow_html, summary_html=summary_html, follow_html=follow_html,
tl=timeline_html, )
content = render(
"federation-actor-timeline-layout",
header_html=header_html, timeline_html=timeline_html,
) )
return _social_page(ctx, actor, content_html=content, return _social_page(ctx, actor, content_html=content,
@@ -812,17 +666,14 @@ async def render_notifications_page(ctx: dict, notifications: list,
actor: Any) -> str: actor: Any) -> str:
"""Full page: notifications.""" """Full page: notifications."""
if not notifications: if not notifications:
notif_html = sexp('(p :class "text-stone-500" "No notifications yet.")') notif_html = render("federation-notifications-empty")
else: else:
notif_html = sexp( notif_html = render(
'(div :class "space-y-2" (raw! items))', "federation-notifications-list",
items="".join(_notification_html(n) for n in notifications), items_html="".join(_notification_html(n) for n in notifications),
) )
content = sexp( content = render("federation-notifications-page", notifs_html=notif_html)
'(h1 :class "text-2xl font-bold mb-6" "Notifications") (raw! notifs)',
notifs=notif_html,
)
return _social_page(ctx, actor, content_html=content, return _social_page(ctx, actor, content_html=content,
title="Notifications \u2014 Rose Ash") title="Notifications \u2014 Rose Ash")
@@ -844,37 +695,13 @@ async def render_choose_username_page(ctx: dict) -> str:
check_url = url_for("identity.check_username") check_url = url_for("identity.check_username")
actor = ctx.get("actor") actor = ctx.get("actor")
error_html = sexp( error_html = render("federation-error-banner", error=error) if error else ""
'(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( content = render(
'(div :class "py-8 max-w-md mx-auto"' "federation-choose-username",
' (h1 :class "text-2xl font-bold mb-2" "Choose your username")' domain=str(escape(ap_domain)), error_html=error_html,
' (p :class "text-stone-600 mb-6" "This will be your identity on the fediverse: "' csrf=csrf, username=str(escape(username)),
' (strong "@username@" (raw! domain)))' check_url=check_url,
' (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, return _social_page(ctx, actor, content_html=content,
@@ -892,9 +719,8 @@ async def render_profile_page(ctx: dict, actor: Any, activities: list,
ap_domain = config().get("ap_domain", "rose-ash.com") ap_domain = config().get("ap_domain", "rose-ash.com")
display_name = actor.display_name or actor.preferred_username display_name = actor.display_name or actor.preferred_username
summary_html = sexp( summary_html = render(
'(p :class "mt-2" (raw! s))', "federation-profile-summary-text", text=str(escape(actor.summary)),
s=str(escape(actor.summary)),
) if actor.summary else "" ) if actor.summary else ""
activities_html = "" activities_html = ""
@@ -902,40 +728,26 @@ async def render_profile_page(ctx: dict, actor: Any, activities: list,
parts = [] parts = []
for a in activities: for a in activities:
published = a.published.strftime("%Y-%m-%d %H:%M") if a.published else "" published = a.published.strftime("%Y-%m-%d %H:%M") if a.published else ""
obj_type_html = sexp( obj_type_html = render(
'(span :class "text-sm text-stone-500" (raw! t))', "federation-activity-obj-type", obj_type=a.object_type,
t=a.object_type,
) if a.object_type else "" ) if a.object_type else ""
parts.append(sexp( parts.append(render(
'(div :class "bg-white rounded-lg shadow p-4"' "federation-activity-card",
' (div :class "flex justify-between items-start"' activity_type=a.activity_type, published=published,
' (span :class "font-medium" (raw! atype))' obj_type_html=obj_type_html,
' (span :class "text-sm text-stone-400" (raw! pub)))'
' (raw! otype))',
atype=a.activity_type, pub=published,
otype=obj_type_html,
)) ))
activities_html = sexp( activities_html = render("federation-activities-list", items_html="".join(parts))
'(div :class "space-y-4" (raw! items))',
items="".join(parts),
)
else: else:
activities_html = sexp('(p :class "text-stone-500" "No activities yet.")') activities_html = render("federation-activities-empty")
content = sexp( content = render(
'(div :class "py-8"' "federation-profile-page",
' (div :class "bg-white rounded-lg shadow p-6 mb-6"' display_name=str(escape(display_name)),
' (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)), username=str(escape(actor.preferred_username)),
domain=str(escape(ap_domain)), domain=str(escape(ap_domain)),
summary=summary_html, summary_html=summary_html,
**{"activities-heading": f"Activities ({total})"}, activities_heading=f"Activities ({total})",
activities=activities_html, activities_html=activities_html,
) )
return _social_page(ctx, actor, content_html=content, return _social_page(ctx, actor, content_html=content,

View File

@@ -0,0 +1,121 @@
;; Social navigation, header, post cards, timeline, compose
;; --- Navigation ---
(defcomp ~federation-nav-choose-username (&key url)
(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")))
(defcomp ~federation-nav-link (&key href cls label)
(a :href href :class cls (raw! label)))
(defcomp ~federation-nav-notification-link (&key href cls count-url)
(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")))
(defcomp ~federation-nav-bar (&key items-html)
(nav :class "flex gap-3 text-sm items-center flex-wrap" (raw! items-html)))
(defcomp ~federation-social-header (&key nav-html)
(div :id "social-row" :class "flex flex-col items-center md:flex-row justify-center md:justify-between w-full p-1 bg-sky-400"
(div :class "w-full flex flex-row items-center gap-2 flex-wrap" (raw! nav-html))))
(defcomp ~federation-header-child (&key inner-html)
(div :id "root-header-child" :class "flex flex-col w-full items-center" (raw! inner-html)))
;; --- Post card ---
(defcomp ~federation-boost-label (&key name)
(div :class "text-sm text-stone-500 mb-2" "Boosted by " (raw! name)))
(defcomp ~federation-avatar-img (&key src cls)
(img :src src :alt "" :class cls))
(defcomp ~federation-avatar-placeholder (&key cls initial)
(div :class cls (raw! initial)))
(defcomp ~federation-content-cw (&key summary content)
(details :class "mt-2"
(summary :class "text-stone-500 cursor-pointer" "CW: " (raw! summary))
(div :class "mt-2 prose prose-sm prose-stone max-w-none" (raw! content))))
(defcomp ~federation-content-plain (&key content)
(div :class "mt-2 prose prose-sm prose-stone max-w-none" (raw! content)))
(defcomp ~federation-original-link (&key url)
(a :href url :target "_blank" :rel "noopener"
:class "text-sm text-stone-400 hover:underline mt-1 inline-block" "original"))
(defcomp ~federation-interactions-wrap (&key id buttons-html)
(div :id id (raw! buttons-html)))
(defcomp ~federation-post-card (&key boost-html avatar-html actor-name actor-username domain-html time-html content-html original-html interactions-html)
(article :class "bg-white rounded-lg shadow-sm border border-stone-200 p-4 mb-4"
(raw! boost-html)
(div :class "flex items-start gap-3"
(raw! avatar-html)
(div :class "flex-1 min-w-0"
(div :class "flex items-baseline gap-2"
(span :class "font-semibold text-stone-900" (raw! actor-name))
(span :class "text-sm text-stone-500" "@" (raw! actor-username) (raw! domain-html))
(span :class "text-sm text-stone-400 ml-auto" (raw! time-html)))
(raw! content-html) (raw! original-html) (raw! interactions-html)))))
;; --- Interaction buttons ---
(defcomp ~federation-reply-link (&key url)
(a :href url :class "hover:text-stone-700" "Reply"))
(defcomp ~federation-like-form (&key action target oid ainbox csrf cls icon count)
(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))))
(defcomp ~federation-boost-form (&key action target oid ainbox csrf cls count)
(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))))
(defcomp ~federation-interaction-buttons (&key like-html boost-html reply-html)
(div :class "flex items-center gap-4 mt-3 text-sm text-stone-500"
(raw! like-html) (raw! boost-html) (raw! reply-html)))
;; --- Timeline ---
(defcomp ~federation-scroll-sentinel (&key url)
(div :hx-get url :hx-trigger "revealed" :hx-swap "outerHTML"))
(defcomp ~federation-compose-button (&key url)
(a :href url :class "bg-stone-800 text-white px-4 py-2 rounded hover:bg-stone-700" "Compose"))
(defcomp ~federation-timeline-page (&key label compose-html timeline-html)
(div :class "flex items-center justify-between mb-6"
(h1 :class "text-2xl font-bold" (raw! label) " Timeline")
(raw! compose-html))
(div :id "timeline" (raw! timeline-html)))
;; --- Compose ---
(defcomp ~federation-compose-reply (&key reply-to)
(input :type "hidden" :name "in_reply_to" :value reply-to)
(div :class "text-sm text-stone-500" "Replying to " (span :class "font-mono" (raw! reply-to))))
(defcomp ~federation-compose-form (&key action csrf reply-html)
(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-html)
(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"))))

View File

@@ -35,7 +35,7 @@ def register():
async def _container_nav_handler(): async def _container_nav_handler():
from quart import current_app from quart import current_app
from shared.infrastructure.urls import market_url from shared.infrastructure.urls import market_url
from shared.sexp.jinja_bridge import sexp as render_sexp from shared.sexp.jinja_bridge import render as render_comp
container_type = request.args.get("container_type", "page") container_type = request.args.get("container_type", "page")
container_id = int(request.args.get("container_id", 0)) container_id = int(request.args.get("container_id", 0))
@@ -51,9 +51,9 @@ def register():
parts = [] parts = []
for m in markets: for m in markets:
href = market_url(f"/{post_slug}/{m.slug}/") href = market_url(f"/{post_slug}/{m.slug}/")
parts.append(render_sexp( parts.append(render_comp(
'(~market-link-nav :href href :name name :nav-class nav-class)', "market-link-nav",
href=href, name=m.name, **{"nav-class": nav_class}, href=href, name=m.name, nav_class=nav_class,
)) ))
return "\n".join(parts) return "\n".join(parts)
@@ -65,7 +65,7 @@ def register():
from sqlalchemy import select from sqlalchemy import select
from shared.models.market import Product from shared.models.market import Product
from shared.infrastructure.urls import market_url from shared.infrastructure.urls import market_url
from shared.sexp.jinja_bridge import sexp as render_sexp from shared.sexp.jinja_bridge import render as render_comp
slug = request.args.get("slug", "") slug = request.args.get("slug", "")
keys_raw = request.args.get("keys", "") keys_raw = request.args.get("keys", "")
@@ -86,8 +86,8 @@ def register():
detail = f"<s>{product.regular_price}</s> {product.special_price}" detail = f"<s>{product.regular_price}</s> {product.special_price}"
elif product.regular_price: elif product.regular_price:
detail = str(product.regular_price) detail = str(product.regular_price)
parts.append(render_sexp( parts.append(render_comp(
'(~link-card :title title :image image :subtitle subtitle :detail detail :link link)', "link-card",
title=product.title, image=product.image, title=product.title, image=product.image,
subtitle=subtitle, detail=detail, subtitle=subtitle, detail=detail,
link=market_url(f"/product/{product.slug}/"), link=market_url(f"/product/{product.slug}/"),
@@ -108,8 +108,8 @@ def register():
detail = f"<s>{product.regular_price}</s> {product.special_price}" detail = f"<s>{product.regular_price}</s> {product.special_price}"
elif product.regular_price: elif product.regular_price:
detail = str(product.regular_price) detail = str(product.regular_price)
return render_sexp( return render_comp(
'(~link-card :title title :image image :subtitle subtitle :detail detail :link link)', "link-card",
title=product.title, image=product.image, title=product.title, image=product.image,
subtitle=subtitle, detail=detail, subtitle=subtitle, detail=detail,
link=market_url(f"/product/{product.slug}/"), link=market_url(f"/product/{product.slug}/"),

105
market/sexp/cards.sexpr Normal file
View File

@@ -0,0 +1,105 @@
;; Market card components
(defcomp ~market-label-overlay (&key src)
(img :src src :alt ""
:class "pointer-events-none absolute inset-0 w-full h-full object-contain object-top"))
(defcomp ~market-card-image (&key image labels-html brand-highlight brand)
(div :class "w-full aspect-square bg-stone-100 relative"
(figure :class "inline-block w-full h-full"
(div :class "relative w-full h-full"
(img :src image :alt "no image" :class "absolute inset-0 w-full h-full object-contain object-top" :loading "lazy" :decoding "async" :fetchpriority "low")
(raw! labels-html))
(figcaption :class (str "mt-2 text-sm text-center" brand-highlight " text-stone-600") brand))))
(defcomp ~market-card-no-image (&key labels-html brand)
(div :class "w-full aspect-square bg-stone-100 relative"
(div :class "p-2 flex flex-col items-center justify-center gap-2 text-red-500 h-full relative"
(div :class "text-stone-400 text-xs" "No image")
(ul :class "flex flex-row gap-1" (raw! labels-html))
(div :class "text-stone-900 text-center line-clamp-3 break-words [overflow-wrap:anywhere]" brand))))
(defcomp ~market-card-label-item (&key label)
(li label))
(defcomp ~market-card-sticker (&key src name ring-cls)
(img :src src :alt name :class (str "w-6 h-6" ring-cls)))
(defcomp ~market-card-stickers (&key items-html)
(div :class "flex flex-row justify-center gap-2 p-2" (raw! items-html)))
(defcomp ~market-card-highlight (&key pre mid post)
(<> pre (mark mid) post))
(defcomp ~market-card-text (&key text)
(<> text))
(defcomp ~market-product-card (&key like-html href hx-select image-html price-html add-html stickers-html title-html)
(div :class "flex flex-col rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden relative"
(raw! like-html)
(a :href href :hx-get href :hx-target "#main-panel"
:hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true"
(raw! image-html) (raw! price-html))
(div :class "flex justify-center" (raw! add-html))
(a :href href :hx-get href :hx-target "#main-panel"
:hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true"
(raw! stickers-html)
(div :class "text-sm font-medium text-stone-800 text-center line-clamp-3 break-words [overflow-wrap:anywhere]"
(raw! title-html)))))
(defcomp ~market-like-button (&key form-id action slug csrf icon-cls)
(div :class "absolute top-2 right-2 z-10 text-6xl md:text-xl"
(form :id form-id :action action :method "post"
:hx-post action :hx-target (str "#like-" slug) :hx-swap "outerHTML"
(input :type "hidden" :name "csrf_token" :value csrf)
(button :type "submit" :class "cursor-pointer"
(i :class icon-cls :aria-hidden "true")))))
(defcomp ~market-market-card-title-link (&key href name)
(a :href href :class "hover:text-emerald-700"
(h2 :class "text-lg font-semibold text-stone-900" name)))
(defcomp ~market-market-card-title (&key name)
(h2 :class "text-lg font-semibold text-stone-900" name))
(defcomp ~market-market-card-desc (&key description)
(p :class "text-sm text-stone-600 mt-1 line-clamp-2" description))
(defcomp ~market-market-card-badge (&key href title)
(div :class "flex flex-wrap items-center gap-1.5 mt-3"
(a :href href :class "inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 hover:bg-amber-200"
title)))
(defcomp ~market-market-card (&key title-html desc-html badge-html)
(article :class "rounded-xl bg-white shadow-sm border border-stone-200 p-5 flex flex-col justify-between hover:border-stone-400 transition-colors"
(div (raw! title-html) (raw! desc-html))
(raw! badge-html)))
(defcomp ~market-sentinel-mobile (&key id next-url hyperscript)
(div :id id
:class "block md:hidden h-[60vh] opacity-0 pointer-events-none js-mobile-sentinel"
:hx-get next-url :hx-trigger "intersect once delay:250ms, sentinelmobile:retry"
:hx-swap "outerHTML"
:_ hyperscript
:role "status" :aria-live "polite" :aria-hidden "true"
(div :class "js-loading text-center text-xs text-stone-400" "loading...")
(div :class "js-neterr hidden text-center text-xs text-stone-400" "Retrying...")))
(defcomp ~market-sentinel-desktop (&key id next-url hyperscript)
(div :id id
:class "hidden md:block h-4 opacity-0 pointer-events-none"
:hx-get next-url :hx-trigger "intersect once delay:250ms, sentinel:retry"
:hx-swap "outerHTML"
:_ hyperscript
:role "status" :aria-live "polite" :aria-hidden "true"
(div :class "js-loading text-center text-xs text-stone-400" "loading...")
(div :class "js-neterr hidden text-center text-xs text-stone-400" "Retrying...")))
(defcomp ~market-sentinel-end ()
(div :class "col-span-full mt-4 text-center text-xs text-stone-400" "End of results"))
(defcomp ~market-market-sentinel (&key id next-url)
(div :id id :class "h-4 opacity-0 pointer-events-none"
:hx-get next-url :hx-trigger "intersect once delay:250ms"
:hx-swap "outerHTML" :role "status" :aria-hidden "true"
(div :class "text-center text-xs text-stone-400" "loading...")))

44
market/sexp/cart.sexpr Normal file
View File

@@ -0,0 +1,44 @@
;; Market cart components
(defcomp ~market-cart-add-empty (&key cart-id action csrf)
(div :id cart-id
(form :action action :method "post" :hx-post action :hx-target "#cart-mini" :hx-swap "outerHTML" :class "rounded flex items-center"
(input :type "hidden" :name "csrf_token" :value csrf)
(input :type "hidden" :name "count" :value "1")
(button :type "submit" :class "relative inline-flex items-center justify-center text-sm font-medium text-stone-500 hover:bg-emerald-50"
(span :class "relative inline-flex items-center justify-center"
(i :class "fa fa-cart-plus text-4xl" :aria-hidden "true"))))))
(defcomp ~market-cart-add-quantity (&key cart-id action csrf minus-val plus-val quantity cart-href)
(div :id cart-id
(div :class "rounded flex items-center gap-2"
(form :action action :method "post" :hx-post action :hx-target "#cart-mini" :hx-swap "outerHTML"
(input :type "hidden" :name "csrf_token" :value csrf)
(input :type "hidden" :name "count" :value minus-val)
(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" "-"))
(a :class "relative inline-flex items-center justify-center text-emerald-700" :href cart-href
(span :class "relative inline-flex items-center justify-center"
(i :class "fa-solid fa-shopping-cart text-2xl" :aria-hidden "true")
(span :class "absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none"
(span :class "flex items-center justify-center bg-black text-white rounded-full w-4 h-4 text-xs font-bold" quantity))))
(form :action action :method "post" :hx-post action :hx-target "#cart-mini" :hx-swap "outerHTML"
(input :type "hidden" :name "csrf_token" :value csrf)
(input :type "hidden" :name "count" :value plus-val)
(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" "+")))))
(defcomp ~market-cart-mini-count (&key href count)
(div :id "cart-mini" :hx-swap-oob "outerHTML"
(a :href href :class "relative inline-flex items-center justify-center"
(span :class "relative inline-flex items-center justify-center"
(i :class "fa-solid fa-shopping-cart text-xl" :aria-hidden "true")
(span :class "absolute -top-1.5 -right-2 pointer-events-none"
(span :class "flex items-center justify-center bg-emerald-500 text-white rounded-full min-w-[1.25rem] h-5 text-xs font-bold px-1"
count))))))
(defcomp ~market-cart-mini-empty (&key href logo)
(div :id "cart-mini" :hx-swap-oob "outerHTML"
(a :href href :class "relative inline-flex items-center justify-center"
(img :src logo :class "h-8 w-8 rounded-full object-cover border border-stone-300" :alt ""))))
(defcomp ~market-cart-add-oob (&key id inner-html)
(div :id id :hx-swap-oob "outerHTML" (raw! inner-html)))

94
market/sexp/detail.sexpr Normal file
View File

@@ -0,0 +1,94 @@
;; Market product detail components
(defcomp ~market-detail-gallery-inner (&key like-html image alt labels-html brand)
(<> (raw! like-html)
(figure :class "inline-block"
(div :class "relative w-full aspect-square"
(img :data-main-img "" :src image :alt alt
:class "w-full h-full object-contain object-top" :loading "eager" :decoding "async")
(raw! labels-html))
(figcaption :class "mt-2 text-sm text-stone-600 text-center" brand))))
(defcomp ~market-detail-nav-buttons ()
(<>
(button :type "button" :data-prev ""
:class "absolute left-2 top-1/2 -translate-y-1/2 z-10 grid place-items-center w-12 h-12 md:w-14 md:h-14 rounded-full bg-white/90 hover:bg-white shadow-lg text-3xl md:text-4xl"
:title "Previous" "\u2039")
(button :type "button" :data-next ""
:class "absolute right-2 top-1/2 -translate-y-1/2 z-10 grid place-items-center w-12 h-12 md:w-14 md:h-14 rounded-full bg-white/90 hover:bg-white shadow-lg text-3xl md:text-4xl"
:title "Next" "\u203a")))
(defcomp ~market-detail-gallery (&key inner-html nav-html)
(div :class "relative rounded-xl overflow-hidden bg-stone-100"
(raw! inner-html) (raw! nav-html)))
(defcomp ~market-detail-thumb (&key title src alt)
(<> (button :type "button" :data-thumb ""
:class "shrink-0 rounded-lg overflow-hidden bg-stone-100 hover:opacity-90 ring-offset-2"
:title title
(img :src src :class "h-16 w-16 object-contain" :alt alt :loading "lazy" :decoding "async"))
(span :data-image-src src :class "hidden")))
(defcomp ~market-detail-thumbs (&key thumbs-html)
(div :class "flex flex-row justify-center"
(div :class "mt-3 flex gap-2 overflow-x-auto no-scrollbar" (raw! thumbs-html))))
(defcomp ~market-detail-no-image (&key like-html)
(div :class "relative aspect-square bg-stone-100 rounded-xl flex items-center justify-center text-stone-400"
(raw! like-html) "No image"))
(defcomp ~market-detail-sticker (&key src name)
(img :src src :alt name :class "w-10 h-10"))
(defcomp ~market-detail-stickers (&key items-html)
(div :class "p-2 flex flex-row justify-center gap-2" (raw! items-html)))
(defcomp ~market-detail-unit-price (&key price)
(div (str "Unit price: " price)))
(defcomp ~market-detail-case-size (&key size)
(div (str "Case size: " size)))
(defcomp ~market-detail-extras (&key inner-html)
(div :class "mt-2 space-y-1 text-sm text-stone-600" (raw! inner-html)))
(defcomp ~market-detail-desc-short (&key text)
(p :class "leading-relaxed text-lg" text))
(defcomp ~market-detail-desc-html (&key html)
(div :class "max-w-none text-sm leading-relaxed" (raw! html)))
(defcomp ~market-detail-desc-wrapper (&key inner-html)
(div :class "mt-4 text-stone-800 space-y-3" (raw! inner-html)))
(defcomp ~market-detail-section (&key title html)
(details :class "group rounded-xl border bg-white shadow-sm open:shadow p-0"
(summary :class "cursor-pointer select-none px-4 py-3 flex items-center justify-between"
(span :class "font-medium" title)
(span :class "ml-2 text-xl transition-transform group-open:rotate-180" "\u2304"))
(div :class "px-4 pb-4 max-w-none text-sm leading-relaxed" (raw! html))))
(defcomp ~market-detail-sections (&key items-html)
(div :class "mt-8 space-y-3" (raw! items-html)))
(defcomp ~market-detail-right-col (&key inner-html)
(div :class "md:col-span-3" (raw! inner-html)))
(defcomp ~market-detail-layout (&key gallery-html stickers-html details-html)
(<> (div :class "mt-3 grid grid-cols-1 md:grid-cols-5 gap-6" :data-gallery-root ""
(div :class "md:col-span-2" (raw! gallery-html) (raw! stickers-html))
(raw! details-html))
(div :class "pb-8")))
(defcomp ~market-landing-excerpt (&key text)
(div :class "w-full text-center italic text-3xl p-2" text))
(defcomp ~market-landing-image (&key src)
(div :class "mb-3 flex justify-center"
(img :src src :alt "" :class "rounded-lg w-full md:w-3/4 object-cover")))
(defcomp ~market-landing-html (&key html)
(div :class "blog-content p-2" (raw! html)))
(defcomp ~market-landing-content (&key inner-html)
(<> (article :class "relative w-full" (raw! inner-html)) (div :class "pb-8")))

120
market/sexp/filters.sexpr Normal file
View File

@@ -0,0 +1,120 @@
;; Market filter components
(defcomp ~market-filter-sort-item (&key href hx-select ring-cls src label)
(a :href href :hx-get href :hx-target "#main-panel"
:hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true"
:class (str "flex flex-col items-center gap-1 p-1 cursor-pointer" ring-cls)
(img :src src :alt label :class "w-10 h-10")
(span :class "text-xs" label)))
(defcomp ~market-filter-sort-row (&key items-html)
(div :class "flex flex-row gap-2 justify-center p-1" (raw! items-html)))
(defcomp ~market-filter-like (&key href hx-select icon-cls size-cls)
(a :href href :hx-get href :hx-target "#main-panel"
:hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true"
:class "flex flex-col items-center gap-1 p-1 cursor-pointer"
(i :aria-hidden "true" :class (str icon-cls " " size-cls " leading-none"))))
(defcomp ~market-filter-label-item (&key href hx-select ring-cls src name)
(a :href href :hx-get href :hx-target "#main-panel"
:hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true"
:class (str "flex flex-col items-center gap-1 p-1 cursor-pointer" ring-cls)
(img :src src :alt name :class "w-10 h-10")))
(defcomp ~market-filter-sticker-item (&key href hx-select ring-cls src name count-cls count)
(a :href href :hx-get href :hx-target "#main-panel"
:hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true"
:class (str "flex flex-col items-center gap-1 p-1 cursor-pointer" ring-cls)
(img :src src :alt name :class "w-6 h-6")
(span :class count-cls count)))
(defcomp ~market-filter-stickers-row (&key items-html)
(div :class "flex flex-wrap gap-2 justify-center p-1" (raw! items-html)))
(defcomp ~market-filter-brand-item (&key href hx-select bg-cls name-cls name count)
(a :href href :hx-get href :hx-target "#main-panel"
:hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true"
:class (str "flex flex-row items-center gap-2 px-2 py-1 rounded hover:bg-stone-100" bg-cls)
(div :class name-cls name) (div :class name-cls count)))
(defcomp ~market-filter-brands-panel (&key items-html)
(div :class "space-y-1 p-2" (raw! items-html)))
(defcomp ~market-filter-category-label (&key label)
(div :class "mb-4" (div :class "text-2xl uppercase tracking-wide text-black-500" label)))
(defcomp ~market-filter-like-labels-nav (&key inner-html)
(nav :aria-label "like" :class "flex flex-row justify-center w-full p-0 m-0 border bg-white shadow-sm rounded-xl gap-1"
(raw! inner-html)))
(defcomp ~market-desktop-category-summary (&key inner-html)
(div :id "category-summary-desktop" :hxx-swap-oob "outerHTML" (raw! inner-html)))
(defcomp ~market-desktop-brand-summary (&key inner-html)
(div :id "filter-summary-desktop" :hxx-swap-oob "outerHTML" (raw! inner-html)))
(defcomp ~market-filter-subcategory-item (&key href hx-select active-cls name)
(a :href href :hx-get href :hx-target "#main-panel"
:hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true"
:class (str "block px-2 py-1 rounded hover:bg-stone-100" active-cls)
name))
(defcomp ~market-filter-subcategory-panel (&key items-html)
(div :class "mt-4 space-y-1" (raw! items-html)))
(defcomp ~market-mobile-clear-filters (&key href hx-select)
(div :class "flex flex-row justify-center"
(a :href href :hx-get href :hx-target "#main-panel"
:hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true"
:role "button" :title "clear filters" :aria-label "clear filters"
:class "flex flex-col items-center justify-start p-1 rounded bg-stone-200 text-black cursor-pointer"
(span :class "mt-1 leading-none tabular-nums" "clear filters"))))
(defcomp ~market-mobile-like-labels-row (&key inner-html)
(div :class "flex flex-row gap-2 justify-center items-center" (raw! inner-html)))
(defcomp ~market-mobile-filter-summary (&key search-bar chips-html filter-html)
(details :class "md:hidden group" :id "/filter"
(summary :class "cursor-pointer select-none" :id "filter-summary-mobile"
(raw! search-bar)
(div :class "col-span-12 min-w-0 grid grid-cols-1 gap-1 bg-gray-100 px-2" :role "list"
(raw! chips-html)))
(div :id "filter-details-mobile" :style "display:contents"
(raw! filter-html))))
(defcomp ~market-mobile-chips-row (&key inner-html)
(div :class "flex flex-row items-start gap-2" (raw! inner-html)))
(defcomp ~market-mobile-chip-sort (&key src label)
(ul :class "relative inline-flex items-center justify-center gap-2"
(li :role "listitem" (img :src src :alt label :class "w-10 h-10"))))
(defcomp ~market-mobile-chip-liked-icon ()
(i :aria-hidden "true" :class "fa-solid fa-heart text-red-500 text-[40px] leading-none"))
(defcomp ~market-mobile-chip-count (&key cls count)
(div :class (str cls " mt-1 leading-none tabular-nums") count))
(defcomp ~market-mobile-chip-liked (&key inner-html)
(div :class "flex flex-col items-center gap-1 pb-1" (raw! inner-html)))
(defcomp ~market-mobile-chip-image (&key src name)
(img :src src :alt name :class "w-10 h-10"))
(defcomp ~market-mobile-chip-item (&key inner-html)
(li :role "listitem" :class "flex flex-col items-center gap-1 pb-1" (raw! inner-html)))
(defcomp ~market-mobile-chip-list (&key items-html)
(ul :class "relative inline-flex items-center justify-center gap-2" (raw! items-html)))
(defcomp ~market-mobile-chip-brand (&key name count)
(li :role "listitem" :class "flex flex-row items-center gap-2"
(div :class "text-md" name) (div :class "text-md" count)))
(defcomp ~market-mobile-chip-brand-zero (&key name)
(li :role "listitem" :class "flex flex-row items-center gap-2"
(div :class "text-md text-red-500" name) (div :class "text-xl text-red-500" "0")))
(defcomp ~market-mobile-chip-brand-list (&key items-html)
(ul (raw! items-html)))

22
market/sexp/grids.sexpr Normal file
View File

@@ -0,0 +1,22 @@
;; Market grid and layout components
(defcomp ~market-markets-grid (&key cards-html)
(div :class "max-w-full px-3 py-3 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4" (raw! cards-html)))
(defcomp ~market-no-markets (&key message)
(div :class "px-3 py-12 text-center text-stone-400"
(i :class "fa fa-store text-4xl mb-3" :aria-hidden "true")
(p :class "text-lg" message)))
(defcomp ~market-product-grid (&key cards-html)
(<> (div :class "grid grid-cols-1 sm:grid-cols-3 md:grid-cols-6 gap-3" (raw! cards-html)) (div :class "pb-8")))
(defcomp ~market-bottom-spacer ()
(div :class "pb-8"))
(defcomp ~market-like-toggle-button (&key colour action hx-headers label icon-cls)
(button :class (str "flex items-center gap-1 " colour " hover:text-red-600 transition-colors w-[1em] h-[1em]")
:hx-post action :hx-target "this" :hx-swap "outerHTML" :hx-push-url "false"
:hx-headers hx-headers
:hx-swap-settle "0ms" :aria-label label
(i :aria-hidden "true" :class icon-cls)))

38
market/sexp/headers.sexpr Normal file
View File

@@ -0,0 +1,38 @@
;; Market header components
(defcomp ~market-post-label-image (&key src)
(img :src src :class "h-8 w-8 rounded-full object-cover border border-stone-300 flex-shrink-0"))
(defcomp ~market-post-label-title (&key title)
(span title))
(defcomp ~market-post-cart-badge (&key href count)
(a :href href :class "relative inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-full border border-emerald-300 bg-emerald-50 text-emerald-800 hover:bg-emerald-100 transition"
(i :class "fa fa-shopping-cart" :aria-hidden "true")
(span count)))
(defcomp ~market-shop-label (&key title top-slug sub-div-html)
(div :class "font-bold text-xl flex-shrink-0 flex gap-2 items-center"
(div (i :class "fa fa-shop") " " title)
(div :class "flex flex-col md:flex-row md:gap-2 text-xs"
(div top-slug) (raw! sub-div-html))))
(defcomp ~market-sub-slug (&key sub)
(div sub))
(defcomp ~market-product-label (&key title)
(<> (i :class "fa fa-shopping-bag" :aria-hidden "true") (div title)))
(defcomp ~market-admin-link (&key href hx-select)
(a :href href :hx-get href :hx-target "#main-panel"
:hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true"
:class "px-2 py-1 text-stone-500 hover:text-stone-700"
(i :class "fa fa-cog" :aria-hidden "true")))
(defcomp ~market-oob-header (&key parent-id child-id row-html)
(div :id parent-id :hx-swap-oob "outerHTML" :class "w-full"
(div :class "w-full" (raw! row-html)
(div :id child-id))))
(defcomp ~market-header-child (&key inner-html)
(div :id "root-header-child" :class "w-full" (raw! inner-html)))

19
market/sexp/meta.sexpr Normal file
View File

@@ -0,0 +1,19 @@
;; Market meta/SEO components
(defcomp ~market-meta-title (&key title)
(title title))
(defcomp ~market-meta-description (&key description)
(meta :name "description" :content description))
(defcomp ~market-meta-canonical (&key href)
(link :rel "canonical" :href href))
(defcomp ~market-meta-og (&key property content)
(meta :property property :content content))
(defcomp ~market-meta-twitter (&key name content)
(meta :name name :content content))
(defcomp ~market-meta-jsonld (&key json)
(script :type "application/ld+json" (raw! json)))

View File

@@ -0,0 +1,63 @@
;; Market navigation components
(defcomp ~market-category-link (&key href hx-select active select-colours label)
(div :class "relative nav-group"
(a :href href :hx-get href :hx-target "#main-panel"
:hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true"
:aria-selected (if active "true" "false")
:class (str "block px-2 py-1 rounded text-center whitespace-normal break-words leading-snug bg-stone-200 text-black " select-colours)
label)))
(defcomp ~market-desktop-category-nav (&key links-html admin-html)
(nav :class "hidden md:flex gap-4 text-sm ml-2 w-full justify-end items-center"
(raw! links-html) (raw! admin-html)))
(defcomp ~market-mobile-nav-wrapper (&key items-html)
(div :class "px-4 py-2" (div :class "divide-y" (raw! items-html))))
(defcomp ~market-mobile-all-link (&key href hx-select active select-colours)
(a :role "option" :href href :hx-get href :hx-target "#main-panel"
:hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true"
:aria-selected (if active "true" "false")
:class (str "block rounded-lg px-3 py-3 text-base hover:bg-stone-50 " select-colours)
(div :class "prose prose-stone max-w-none" "All")))
(defcomp ~market-mobile-chevron ()
(svg :class "w-4 h-4 shrink-0 transition-transform group-open/cat:rotate-180"
:viewBox "0 0 20 20" :fill "currentColor"
(path :fill-rule "evenodd" :clip-rule "evenodd"
:d "M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z")))
(defcomp ~market-mobile-cat-summary (&key bg-cls href hx-select select-colours cat-name count-label count-str chevron-html)
(summary :class (str "flex items-center justify-between cursor-pointer select-none block rounded-lg px-3 py-3 text-base hover:bg-stone-50" bg-cls)
(a :href href :hx-get href :hx-target "#main-panel"
:hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true"
:class (str "font-medium " select-colours " flex flex-row gap-2")
(div cat-name)
(div :aria-label count-label count-str))
(raw! chevron-html)))
(defcomp ~market-mobile-sub-link (&key select-colours active href hx-select label count-label count-str)
(a :class (str "snap-start px-2 py-3 rounded " select-colours " flex flex-row gap-2")
:aria-selected (if active "true" "false")
:href href :hx-get href :hx-target "#main-panel"
:hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true"
(div label)
(div :aria-label count-label count-str)))
(defcomp ~market-mobile-subs-panel (&key links-html)
(div :class "pb-3 pl-2"
(div :data-peek-viewport "" :data-peek-size-px "18" :data-peek-edge "bottom" :data-peek-mask "true" :class "m-2 bg-stone-100"
(div :data-peek-inner "" :class "grid grid-cols-1 gap-1 snap-y snap-mandatory pr-1" :aria-label "Subcategories"
(raw! links-html)))))
(defcomp ~market-mobile-view-all (&key href hx-select)
(div :class "pb-3 pl-2"
(a :class "px-2 py-1 rounded hover:bg-stone-100 block"
:href href :hx-get href :hx-target "#main-panel"
:hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true"
"View all")))
(defcomp ~market-mobile-cat-details (&key open summary-html subs-html)
(details :class "group/cat py-1" :open open
(raw! summary-html) (raw! subs-html)))

34
market/sexp/prices.sexpr Normal file
View File

@@ -0,0 +1,34 @@
;; Market price display components
(defcomp ~market-price-special (&key price)
(div :class "text-lg font-semibold text-emerald-700" price))
(defcomp ~market-price-regular-strike (&key price)
(div :class "text-sm line-through text-stone-500" price))
(defcomp ~market-price-regular (&key price)
(div :class "mt-1 text-lg font-semibold" price))
(defcomp ~market-price-line (&key inner-html)
(div :class "mt-1 flex items-baseline gap-2 justify-center" (raw! inner-html)))
(defcomp ~market-header-price-special-label ()
(div :class "text-md font-bold text-emerald-700" "Special price"))
(defcomp ~market-header-price-special (&key price)
(div :class "text-xl font-semibold text-emerald-700" price))
(defcomp ~market-header-price-strike (&key price)
(div :class "text-base text-md line-through text-stone-500" price))
(defcomp ~market-header-price-regular-label ()
(div :class "hidden md:block text-xl font-bold" "Our price"))
(defcomp ~market-header-price-regular (&key price)
(div :class "text-xl font-semibold" price))
(defcomp ~market-header-rrp (&key rrp)
(div :class "text-base text-stone-400" (span "rrp:") " " (span rrp)))
(defcomp ~market-prices-row (&key inner-html)
(div :class "flex flex-row items-center justify-between md:gap-2 md:px-2" (raw! inner-html)))

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,38 @@
;; Checkout error components
(defcomp ~orders-checkout-error-header ()
(header :class "mb-6 sm:mb-8"
(h1 :class "text-xl sm:text-2xl md:text-3xl font-semibold tracking-tight" "Checkout error")
(p :class "text-xs sm:text-sm text-stone-600" "We tried to start your payment with SumUp but hit a problem.")))
(defcomp ~orders-checkout-error-order-id (&key oid)
(p :class "text-xs text-rose-800/80" "Order ID: " (span :class "font-mono" (raw! oid))))
(defcomp ~orders-checkout-error-pay-btn (&key url)
(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"))
(defcomp ~orders-checkout-error-content (&key msg order-html back-url)
(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"))))
(defcomp ~orders-detail-filter (&key created status list-url recheck-url csrf pay-html)
(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-html))))

54
orders/sexp/detail.sexpr Normal file
View File

@@ -0,0 +1,54 @@
;; Order detail components
(defcomp ~orders-item-image (&key src alt)
(img :src src :alt alt :class "w-full h-full object-contain object-center" :loading "lazy" :decoding "async"))
(defcomp ~orders-item-no-image ()
(div :class "w-full h-full flex items-center justify-center text-[9px] text-stone-400" "No image"))
(defcomp ~orders-item-row (&key href img-html title pid qty price)
(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-html))
(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)))))))
(defcomp ~orders-items-section (&key items-html)
(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-html))))
(defcomp ~orders-calendar-item (&key name pill state ds cost)
(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))))
(defcomp ~orders-calendar-section (&key items-html)
(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-html))))
(defcomp ~orders-detail-panel (&key summary-html items-html calendar-html)
(div :class "max-w-full px-3 py-3 space-y-4"
(raw! summary-html) (raw! items-html) (raw! calendar-html)))
(defcomp ~orders-detail-header-stack (&key auth-html orders-html order-html)
(div :id "root-header-child" :class "flex flex-col w-full items-center"
(raw! auth-html)
(div :id "auth-header-child" :class "flex flex-col w-full items-center"
(raw! orders-html)
(div :id "orders-header-child" :class "flex flex-col w-full items-center"
(raw! order-html)))))
(defcomp ~orders-header-child-oob (&key inner-html)
(div :id "orders-header-child" :hx-swap-oob "outerHTML"
:class "flex flex-col w-full items-center"
(raw! inner-html)))

60
orders/sexp/list.sexpr Normal file
View File

@@ -0,0 +1,60 @@
;; Orders list components
(defcomp ~orders-row-desktop (&key oid created desc total pill status url)
(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"))))
(defcomp ~orders-row-mobile (&key oid created total pill status url)
(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"))))))
(defcomp ~orders-end-row ()
(tr (td :colspan "5" :class "px-3 py-4 text-center text-xs text-stone-400" "End of results")))
(defcomp ~orders-empty-state ()
(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.")))
(defcomp ~orders-table (&key rows-html)
(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-html))))))
(defcomp ~orders-summary (&key search-mobile-html)
(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! search-mobile-html))))
;; Header child wrapper
(defcomp ~orders-header-child (&key inner-html)
(div :id "root-header-child" :class "flex flex-col w-full items-center"
(raw! inner-html)))
(defcomp ~orders-auth-header-child-oob (&key inner-html)
(div :id "auth-header-child" :hx-swap-oob "outerHTML"
:class "flex flex-col w-full items-center"
(raw! inner-html)))

View File

@@ -7,23 +7,18 @@ of ``render_template()``.
""" """
from __future__ import annotations from __future__ import annotations
import os
from typing import Any from typing import Any
from shared.sexp.jinja_bridge import sexp, register_components from shared.sexp.jinja_bridge import render, load_service_components
from shared.sexp.helpers import ( from shared.sexp.helpers import (
call_url, get_asset_url, root_header_html, call_url, get_asset_url, root_header_html,
search_mobile_html, search_desktop_html, full_page, oob_page, search_mobile_html, search_desktop_html, full_page, oob_page,
) )
from shared.infrastructure.urls import market_product_url, cart_url from shared.infrastructure.urls import market_product_url, cart_url
# Load orders-specific .sexpr components at import time
# --------------------------------------------------------------------------- load_service_components(os.path.dirname(os.path.dirname(__file__)))
# Service-specific component definitions
# ---------------------------------------------------------------------------
def load_orders_components() -> None:
"""Register orders-specific s-expression components (placeholder for future)."""
pass
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -32,10 +27,11 @@ def load_orders_components() -> None:
def _auth_nav_html(ctx: dict) -> str: def _auth_nav_html(ctx: dict) -> str:
"""Auth section desktop nav items.""" """Auth section desktop nav items."""
html = sexp( html = render(
'(~nav-link :href h :label "newsletters" :select-colours sc)', "nav-link",
h=call_url(ctx, "account_url", "/newsletters/"), href=call_url(ctx, "account_url", "/newsletters/"),
sc=ctx.get("select_colours", ""), label="newsletters",
select_colours=ctx.get("select_colours", ""),
) )
account_nav_html = ctx.get("account_nav_html", "") account_nav_html = ctx.get("account_nav_html", "")
if account_nav_html: if account_nav_html:
@@ -45,23 +41,23 @@ def _auth_nav_html(ctx: dict) -> str:
def _auth_header_html(ctx: dict, *, oob: bool = False) -> str: def _auth_header_html(ctx: dict, *, oob: bool = False) -> str:
"""Build the account section header row.""" """Build the account section header row."""
return sexp( return render(
'(~menu-row :id "auth-row" :level 1 :colour "sky"' "menu-row",
' :link-href lh :link-label "account" :icon "fa-solid fa-user"' id="auth-row", level=1, colour="sky",
' :nav-html nh :child-id "auth-header-child" :oob oob)', link_href=call_url(ctx, "account_url", "/"),
lh=call_url(ctx, "account_url", "/"), link_label="account", icon="fa-solid fa-user",
nh=_auth_nav_html(ctx), nav_html=_auth_nav_html(ctx),
oob=oob, child_id="auth-header-child", oob=oob,
) )
def _orders_header_html(ctx: dict, list_url: str) -> str: def _orders_header_html(ctx: dict, list_url: str) -> str:
"""Build the orders section header row.""" """Build the orders section header row."""
return sexp( return render(
'(~menu-row :id "orders-row" :level 2 :colour "sky"' "menu-row",
' :link-href lh :link-label "Orders" :icon "fa fa-gbp"' id="orders-row", level=2, colour="sky",
' :child-id "orders-header-child")', link_href=list_url, link_label="Orders", icon="fa fa-gbp",
lh=list_url, child_id="orders-header-child",
) )
@@ -86,32 +82,16 @@ 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" 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}" total = f"{order.currency or 'GBP'} {order.total_amount or 0:.2f}"
desktop = sexp( desktop = render(
'(tr :class "hidden sm:table-row border-t border-stone-100 hover:bg-stone-50/60"' "orders-row-desktop",
' (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, oid=f"#{order.id}", created=created,
desc=order.description or "", total=total, 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}", 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, status=status, url=detail_url,
) )
mobile = sexp( mobile = render(
'(tr :class "sm:hidden border-t border-stone-100"' "orders-row-mobile",
' (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, 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}", pill=f"inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] {pill}",
status=status, url=detail_url, status=status, url=detail_url,
@@ -133,14 +113,12 @@ def _orders_rows_html(orders: list, page: int, total_pages: int,
if page < total_pages: if page < total_pages:
next_url = pfx + url_for_fn("orders.list_orders") + qs_fn(page=page + 1) next_url = pfx + url_for_fn("orders.list_orders") + qs_fn(page=page + 1)
parts.append(sexp( parts.append(render(
'(~infinite-scroll :url u :page p :total-pages tp :id-prefix "orders" :colspan 5)', "infinite-scroll",
u=next_url, p=page, **{"total-pages": total_pages}, url=next_url, page=page, total_pages=total_pages, id_prefix="orders", colspan=5,
)) ))
else: else:
parts.append(sexp( parts.append(render("orders-end-row"))
'(tr (td :colspan "5" :class "px-3 py-4 text-center text-xs text-stone-400" "End of results"))',
))
return "".join(parts) return "".join(parts)
@@ -148,36 +126,13 @@ def _orders_rows_html(orders: list, page: int, total_pages: int,
def _orders_main_panel_html(orders: list, rows_html: str) -> str: def _orders_main_panel_html(orders: list, rows_html: str) -> str:
"""Main panel with table or empty state.""" """Main panel with table or empty state."""
if not orders: if not orders:
return sexp( return render("orders-empty-state")
'(div :class "max-w-full px-3 py-3 space-y-3"' return render("orders-table", rows_html=rows_html)
' (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 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: def _orders_summary_html(ctx: dict) -> str:
"""Filter section for orders list.""" """Filter section for orders list."""
return sexp( return render("orders-summary", search_mobile_html=search_mobile_html(ctx))
'(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),
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -199,9 +154,9 @@ async def render_orders_page(ctx: dict, orders: list, page: int,
main = _orders_main_panel_html(orders, rows) main = _orders_main_panel_html(orders, rows)
hdr = root_header_html(ctx) hdr = root_header_html(ctx)
hdr += sexp( hdr += render(
'(div :id "root-header-child" :class "flex flex-col w-full items-center" (raw! a) (raw! o))', "orders-header-child",
a=_auth_header_html(ctx), o=_orders_header_html(ctx, list_url), inner_html=_auth_header_html(ctx) + _orders_header_html(ctx, list_url),
) )
return full_page(ctx, header_rows_html=hdr, return full_page(ctx, header_rows_html=hdr,
@@ -233,10 +188,9 @@ async def render_orders_oob(ctx: dict, orders: list, page: int,
oobs = ( oobs = (
_auth_header_html(ctx, oob=True) _auth_header_html(ctx, oob=True)
+ sexp( + render(
'(div :id "auth-header-child" :hx-swap-oob "outerHTML"' "orders-auth-header-child-oob",
' :class "flex flex-col w-full items-center" (raw! o))', inner_html=_orders_header_html(ctx, list_url),
o=_orders_header_html(ctx, list_url),
) )
+ root_header_html(ctx, oob=True) + root_header_html(ctx, oob=True)
) )
@@ -259,36 +213,23 @@ def _order_items_html(order: Any) -> str:
for item in order.items: for item in order.items:
prod_url = market_product_url(item.product_slug) prod_url = market_product_url(item.product_slug)
if item.product_image: if item.product_image:
img = sexp( img = render(
'(img :src src :alt alt :class "w-full h-full object-contain object-center" :loading "lazy" :decoding "async")', "orders-item-image",
src=item.product_image, alt=item.product_title or "Product image", src=item.product_image, alt=item.product_title or "Product image",
) )
else: else:
img = sexp('(div :class "w-full h-full flex items-center justify-center text-[9px] text-stone-400" "No image")') img = render("orders-item-no-image")
items.append(sexp( items.append(render(
'(li (a :class "w-full py-2 flex gap-3" :href href' "orders-item-row",
' (div :class "w-12 h-12 sm:w-14 sm:h-14 rounded-md bg-stone-100 flex-shrink-0 overflow-hidden" (raw! img))' href=prod_url, img_html=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", title=item.product_title or "Unknown product",
pid=str(item.product_id), pid=str(item.product_id),
qty=str(item.quantity), qty=str(item.quantity),
price=f"{item.currency or order.currency or 'GBP'} {item.unit_price or 0:.2f}", price=f"{item.currency or order.currency or 'GBP'} {item.unit_price or 0:.2f}",
)) ))
return sexp( return render("orders-items-section", items_html="".join(items))
'(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),
)
def _calendar_items_html(calendar_entries: list | None) -> str: def _calendar_items_html(calendar_entries: list | None) -> str:
@@ -307,41 +248,30 @@ def _calendar_items_html(calendar_entries: list | None) -> str:
ds = e.start_at.strftime("%-d %b %Y, %H:%M") if e.start_at else "" ds = e.start_at.strftime("%-d %b %Y, %H:%M") if e.start_at else ""
if e.end_at: if e.end_at:
ds += f" \u2013 {e.end_at.strftime('%-d %b %Y, %H:%M')}" ds += f" \u2013 {e.end_at.strftime('%-d %b %Y, %H:%M')}"
items.append(sexp( items.append(render(
'(li :class "px-4 py-3 flex items-start justify-between text-sm"' "orders-calendar-item",
' (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, name=e.name,
pill=f"inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium {pill}", pill=f"inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium {pill}",
state=st.capitalize(), ds=ds, state=st.capitalize(), ds=ds,
cost=f"\u00a3{e.cost or 0:.2f}", cost=f"\u00a3{e.cost or 0:.2f}",
)) ))
return sexp( return render("orders-calendar-section", items_html="".join(items))
'(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),
)
def _order_main_html(order: Any, calendar_entries: list | None) -> str: def _order_main_html(order: Any, calendar_entries: list | None) -> str:
"""Main panel for single order detail.""" """Main panel for single order detail."""
summary = sexp( summary = render(
'(~order-summary-card :order-id oid :created-at ca :description d :status s :currency c :total-amount ta)', "order-summary-card",
oid=order.id, order_id=order.id,
ca=order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else None, created_at=order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else None,
d=order.description, s=order.status, c=order.currency, description=order.description, status=order.status, currency=order.currency,
ta=f"{order.total_amount:.2f}" if order.total_amount else None, total_amount=f"{order.total_amount:.2f}" if order.total_amount else None,
) )
return sexp( return render(
'(div :class "max-w-full px-3 py-3 space-y-4" (raw! summary) (raw! items) (raw! cal))', "orders-detail-panel",
summary=summary, items=_order_items_html(order), summary_html=summary, items_html=_order_items_html(order),
cal=_calendar_items_html(calendar_entries), calendar_html=_calendar_items_html(calendar_entries),
) )
@@ -353,28 +283,13 @@ def _order_filter_html(order: Any, list_url: str, recheck_url: str,
pay_html = "" pay_html = ""
if status != "paid": if status != "paid":
pay_html = sexp( pay_html = render("orders-checkout-error-pay-btn", url=pay_url)
'(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( return render(
'(header :class "mb-6 sm:mb-8 flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4"' "orders-detail-filter",
' (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, created=created, status=status,
**{"list-url": list_url, "recheck-url": recheck_url}, list_url=list_url, recheck_url=recheck_url,
csrf=csrf_token, pay=pay_html, csrf=csrf_token, pay_html=pay_html,
) )
@@ -396,17 +311,16 @@ async def render_order_page(ctx: dict, order: Any,
# Header stack: root -> auth -> orders -> order # Header stack: root -> auth -> orders -> order
hdr = root_header_html(ctx) hdr = root_header_html(ctx)
order_row = sexp( order_row = render(
'(~menu-row :id "order-row" :level 3 :colour "sky" :link-href lh :link-label "Order" :icon "fa fa-gbp")', "menu-row",
lh=detail_url, id="order-row", level=3, colour="sky", link_href=detail_url,
link_label="Order", icon="fa fa-gbp",
) )
hdr += sexp( hdr += render(
'(div :id "root-header-child" :class "flex flex-col w-full items-center" (raw! a)' "orders-detail-header-stack",
' (div :id "auth-header-child" :class "flex flex-col w-full items-center" (raw! b)' auth_html=_auth_header_html(ctx),
' (div :id "orders-header-child" :class "flex flex-col w-full items-center" (raw! c))))', orders_html=_orders_header_html(ctx, list_url),
a=_auth_header_html(ctx), order_html=order_row,
b=_orders_header_html(ctx, list_url),
c=order_row,
) )
return full_page(ctx, header_rows_html=hdr, filter_html=filt, content_html=main) return full_page(ctx, header_rows_html=hdr, filter_html=filt, content_html=main)
@@ -428,12 +342,13 @@ async def render_order_oob(ctx: dict, order: Any,
main = _order_main_html(order, calendar_entries) main = _order_main_html(order, calendar_entries)
filt = _order_filter_html(order, list_url, recheck_url, pay_url, generate_csrf_token()) filt = _order_filter_html(order, list_url, recheck_url, pay_url, generate_csrf_token())
order_row_oob = sexp( order_row_oob = render(
'(~menu-row :id "order-row" :level 3 :colour "sky" :link-href lh :link-label "Order" :icon "fa fa-gbp" :oob true)', "menu-row",
lh=detail_url, id="order-row", level=3, colour="sky", link_href=detail_url,
link_label="Order", icon="fa fa-gbp", oob=True,
) )
oobs = ( oobs = (
sexp('(div :id "orders-header-child" :hx-swap-oob "outerHTML" :class "flex flex-col w-full items-center" (raw! o))', o=order_row_oob) render("orders-header-child-oob", inner_html=order_row_oob)
+ root_header_html(ctx, oob=True) + root_header_html(ctx, oob=True)
) )
@@ -445,42 +360,27 @@ async def render_order_oob(ctx: dict, order: Any,
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _checkout_error_filter_html() -> str: def _checkout_error_filter_html() -> str:
return sexp( return render("orders-checkout-error-header")
'(header :class "mb-6 sm:mb-8"'
' (h1 :class "text-xl sm:text-2xl md:text-3xl font-semibold tracking-tight" "Checkout error")'
' (p :class "text-xs sm:text-sm text-stone-600" "We tried to start your payment with SumUp but hit a problem."))',
)
def _checkout_error_content_html(error: str | None, order: Any | None) -> str: def _checkout_error_content_html(error: str | None, order: Any | None) -> str:
err_msg = error or "Unexpected error while creating the hosted checkout session." err_msg = error or "Unexpected error while creating the hosted checkout session."
order_html = "" order_html = ""
if order: if order:
order_html = sexp( order_html = render("orders-checkout-error-order-id", oid=f"#{order.id}")
'(p :class "text-xs text-rose-800/80" "Order ID: " (span :class "font-mono" (raw! oid)))',
oid=f"#{order.id}",
)
back_url = cart_url("/") back_url = cart_url("/")
return sexp( return render(
'(div :class "max-w-full px-3 py-3 space-y-4"' "orders-checkout-error-content",
' (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"' msg=err_msg, order_html=order_html, back_url=back_url,
' (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},
) )
async def render_checkout_error_page(ctx: dict, error: str | None = None, order: Any | None = None) -> str: async def render_checkout_error_page(ctx: dict, error: str | None = None, order: Any | None = None) -> str:
"""Full page: checkout error.""" """Full page: checkout error."""
hdr = root_header_html(ctx) hdr = root_header_html(ctx)
hdr += sexp( hdr += render(
'(div :id "root-header-child" :class "flex flex-col w-full items-center" (raw! c))', "orders-header-child",
c=_auth_header_html(ctx), inner_html=_auth_header_html(ctx),
) )
filt = _checkout_error_filter_html() filt = _checkout_error_filter_html()
content = _checkout_error_content_html(error, order) content = _checkout_error_content_html(error, order)

View File

@@ -138,16 +138,12 @@ def errors(app):
messages = getattr(e, "messages", [str(e)]) messages = getattr(e, "messages", [str(e)])
if request.headers.get("HX-Request") == "true": if request.headers.get("HX-Request") == "true":
# Build a little styled <ul><li>...</li></ul> snippet from shared.sexp.jinja_bridge import render as render_comp
lis = "".join( items = "".join(
f"<li>{escape(m)}</li>" render_comp("error-list-item", message=str(escape(m)))
for m in messages if m for m in messages if m
) )
html = ( html = render_comp("error-list", items_html=items)
"<ul class='list-disc pl-5 space-y-1 text-sm text-red-600'>"
f"{lis}"
"</ul>"
)
return await make_response(html, status) return await make_response(html, status)
# Non-HTMX: show a nicer page with error messages # Non-HTMX: show a nicer page with error messages
@@ -164,8 +160,9 @@ def errors(app):
# Extract service name from "Fragment account/auth-menu failed: ..." # Extract service name from "Fragment account/auth-menu failed: ..."
service = msg.split("/")[0].replace("Fragment ", "") if "/" in msg else "unknown" service = msg.split("/")[0].replace("Fragment ", "") if "/" in msg else "unknown"
if request.headers.get("HX-Request") == "true": if request.headers.get("HX-Request") == "true":
from shared.sexp.jinja_bridge import render as render_comp
return await make_response( return await make_response(
f"<p class='text-sm text-red-600'>Service <b>{escape(service)}</b> is unavailable.</p>", render_comp("fragment-error", service=str(escape(service))),
503, 503,
) )
# Raw HTML — cannot use render_template here because the context # Raw HTML — cannot use render_template here because the context

View File

@@ -8,7 +8,7 @@ from __future__ import annotations
from typing import Any from typing import Any
from .jinja_bridge import sexp from .jinja_bridge import render
from .page import SEARCH_HEADERS_MOBILE, SEARCH_HEADERS_DESKTOP from .page import SEARCH_HEADERS_MOBILE, SEARCH_HEADERS_DESKTOP
@@ -31,43 +31,39 @@ def get_asset_url(ctx: dict) -> str:
def root_header_html(ctx: dict, *, oob: bool = False) -> str: def root_header_html(ctx: dict, *, oob: bool = False) -> str:
"""Build the root header row HTML.""" """Build the root header row HTML."""
return sexp( return render(
'(~header-row :cart-mini-html cmi :blog-url bu :site-title st' "header-row",
' :nav-tree-html nth :auth-menu-html amh :nav-panel-html nph' cart_mini_html=ctx.get("cart_mini_html", ""),
' :oob oob)', blog_url=call_url(ctx, "blog_url", ""),
cmi=ctx.get("cart_mini_html", ""), site_title=ctx.get("base_title", ""),
bu=call_url(ctx, "blog_url", ""), nav_tree_html=ctx.get("nav_tree_html", ""),
st=ctx.get("base_title", ""), auth_menu_html=ctx.get("auth_menu_html", ""),
nth=ctx.get("nav_tree_html", ""), nav_panel_html=ctx.get("nav_panel_html", ""),
amh=ctx.get("auth_menu_html", ""),
nph=ctx.get("nav_panel_html", ""),
oob=oob, oob=oob,
) )
def search_mobile_html(ctx: dict) -> str: def search_mobile_html(ctx: dict) -> str:
"""Build mobile search input HTML.""" """Build mobile search input HTML."""
return sexp( return render(
'(~search-mobile :current-local-href clh :search s :search-count sc' "search-mobile",
' :hx-select hs :search-headers-mobile shm)', current_local_href=ctx.get("current_local_href", "/"),
clh=ctx.get("current_local_href", "/"), search=ctx.get("search", ""),
s=ctx.get("search", ""), search_count=ctx.get("search_count", ""),
sc=ctx.get("search_count", ""), hx_select=ctx.get("hx_select", "#main-panel"),
hs=ctx.get("hx_select", "#main-panel"), search_headers_mobile=SEARCH_HEADERS_MOBILE,
shm=SEARCH_HEADERS_MOBILE,
) )
def search_desktop_html(ctx: dict) -> str: def search_desktop_html(ctx: dict) -> str:
"""Build desktop search input HTML.""" """Build desktop search input HTML."""
return sexp( return render(
'(~search-desktop :current-local-href clh :search s :search-count sc' "search-desktop",
' :hx-select hs :search-headers-desktop shd)', current_local_href=ctx.get("current_local_href", "/"),
clh=ctx.get("current_local_href", "/"), search=ctx.get("search", ""),
s=ctx.get("search", ""), search_count=ctx.get("search_count", ""),
sc=ctx.get("search_count", ""), hx_select=ctx.get("hx_select", "#main-panel"),
hs=ctx.get("hx_select", "#main-panel"), search_headers_desktop=SEARCH_HEADERS_DESKTOP,
shd=SEARCH_HEADERS_DESKTOP,
) )
@@ -76,19 +72,17 @@ def full_page(ctx: dict, *, header_rows_html: str,
content_html: str = "", menu_html: str = "", content_html: str = "", menu_html: str = "",
body_end_html: str = "", meta_html: str = "") -> str: body_end_html: str = "", meta_html: str = "") -> str:
"""Render a full app page with the standard layout.""" """Render a full app page with the standard layout."""
return sexp( return render(
'(~app-layout :title t :asset-url au :meta-html mh' "app-layout",
' :header-rows-html hrh :menu-html muh :filter-html fh' title=ctx.get("base_title", "Rose Ash"),
' :aside-html ash :content-html ch :body-end-html beh)', asset_url=get_asset_url(ctx),
t=ctx.get("base_title", "Rose Ash"), meta_html=meta_html,
au=get_asset_url(ctx), header_rows_html=header_rows_html,
mh=meta_html, menu_html=menu_html,
hrh=header_rows_html, filter_html=filter_html,
muh=menu_html, aside_html=aside_html,
fh=filter_html, content_html=content_html,
ash=aside_html, body_end_html=body_end_html,
ch=content_html,
beh=body_end_html,
) )
@@ -96,12 +90,11 @@ def oob_page(ctx: dict, *, oobs_html: str = "",
filter_html: str = "", aside_html: str = "", filter_html: str = "", aside_html: str = "",
content_html: str = "", menu_html: str = "") -> str: content_html: str = "", menu_html: str = "") -> str:
"""Render an OOB response with standard swap targets.""" """Render an OOB response with standard swap targets."""
return sexp( return render(
'(~oob-response :oobs-html oh :filter-html fh :aside-html ash' "oob-response",
' :menu-html mh :content-html ch)', oobs_html=oobs_html,
oh=oobs_html, filter_html=filter_html,
fh=filter_html, aside_html=aside_html,
ash=aside_html, menu_html=menu_html,
mh=menu_html, content_html=content_html,
ch=content_html,
) )

View File

@@ -24,9 +24,9 @@ import glob
import os import os
from typing import Any from typing import Any
from .types import NIL, Symbol from .types import NIL, Component, Keyword, Symbol
from .parser import parse from .parser import parse
from .html import render as html_render from .html import render as html_render, _render_component
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -44,12 +44,22 @@ def get_component_env() -> dict[str, Any]:
def load_sexp_dir(directory: str) -> None: def load_sexp_dir(directory: str) -> None:
"""Load all .sexp files from a directory and register components.""" """Load all .sexp and .sexpr files from a directory and register components."""
for filepath in sorted(glob.glob(os.path.join(directory, "*.sexp"))): for filepath in sorted(
glob.glob(os.path.join(directory, "*.sexp"))
+ glob.glob(os.path.join(directory, "*.sexpr"))
):
with open(filepath, encoding="utf-8") as f: with open(filepath, encoding="utf-8") as f:
register_components(f.read()) register_components(f.read())
def load_service_components(service_dir: str) -> None:
"""Load service-specific s-expression components from {service_dir}/sexp/."""
sexp_dir = os.path.join(service_dir, "sexp")
if os.path.isdir(sexp_dir):
load_sexp_dir(sexp_dir)
def register_components(sexp_source: str) -> None: def register_components(sexp_source: str) -> None:
"""Parse and evaluate s-expression component definitions into the """Parse and evaluate s-expression component definitions into the
shared environment. shared environment.
@@ -96,6 +106,28 @@ def sexp(source: str, **kwargs: Any) -> str:
return html_render(expr, env) return html_render(expr, env)
def render(component_name: str, **kwargs: Any) -> str:
"""Call a registered component by name with Python kwargs.
Automatically converts Python snake_case to sexp kebab-case.
No sexp strings needed — just a function call.
"""
name = component_name if component_name.startswith("~") else f"~{component_name}"
comp = _COMPONENT_ENV.get(name)
if not isinstance(comp, Component):
raise ValueError(f"Unknown component: {name}")
env = dict(_COMPONENT_ENV)
args: list[Any] = []
for key, val in kwargs.items():
kw_name = key.replace("_", "-")
args.append(Keyword(kw_name))
args.append(val)
env[kw_name] = val
return _render_component(comp, args, env)
async def sexp_async(source: str, **kwargs: Any) -> str: async def sexp_async(source: str, **kwargs: Any) -> str:
"""Async version of ``sexp()`` — resolves I/O primitives (frag, query) """Async version of ``sexp()`` — resolves I/O primitives (frag, query)
before rendering. before rendering.
@@ -144,4 +176,5 @@ def setup_sexp_bridge(app: Any) -> None:
- ``sexp_async(source, **kwargs)`` — async render (with I/O resolution) - ``sexp_async(source, **kwargs)`` — async render (with I/O resolution)
""" """
app.jinja_env.globals["sexp"] = sexp app.jinja_env.globals["sexp"] = sexp
app.jinja_env.globals["render"] = render
app.jinja_env.globals["sexp_async"] = sexp_async app.jinja_env.globals["sexp_async"] = sexp_async

View File

@@ -46,7 +46,7 @@ class Tokenizer:
COMMENT = re.compile(r";[^\n]*") COMMENT = re.compile(r";[^\n]*")
STRING = re.compile(r'"(?:[^"\\]|\\.)*"') STRING = re.compile(r'"(?:[^"\\]|\\.)*"')
NUMBER = re.compile(r"-?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?") NUMBER = re.compile(r"-?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?")
KEYWORD = re.compile(r":[a-zA-Z_][a-zA-Z0-9_>-]*") KEYWORD = re.compile(r":[a-zA-Z_][a-zA-Z0-9_>:-]*")
# Symbols may start with alpha, _, or common operator chars, plus ~ for components, # Symbols may start with alpha, _, or common operator chars, plus ~ for components,
# <> for the fragment symbol, and & for &key/&rest. # <> for the fragment symbol, and & for &key/&rest.
SYMBOL = re.compile(r"[a-zA-Z_~*+\-><=/!?&][a-zA-Z0-9_~*+\-><=/!?.:&]*") SYMBOL = re.compile(r"[a-zA-Z_~*+\-><=/!?&][a-zA-Z0-9_~*+\-><=/!?.:&]*")

View File

@@ -0,0 +1,30 @@
;; Miscellaneous shared components for Phase 3 conversion
(defcomp ~error-inline (&key message)
(div :class "text-red-600 text-sm" (raw! message)))
(defcomp ~notification-badge (&key count)
(span :class "bg-red-500 text-white text-xs rounded-full px-1.5 py-0.5" (raw! count)))
(defcomp ~cache-cleared (&key time-str)
(span :class "text-green-600 font-bold" "Cache cleared at " (raw! time-str)))
(defcomp ~error-list (&key items-html)
(ul :class "list-disc pl-5 space-y-1 text-sm text-red-600"
(raw! items-html)))
(defcomp ~error-list-item (&key message)
(li (raw! message)))
(defcomp ~fragment-error (&key service)
(p :class "text-sm text-red-600" "Service " (b (raw! service)) " is unavailable."))
(defcomp ~htmx-sentinel (&key id hx-get hx-trigger hx-swap class extra-attrs)
(div :id id :hx-get hx-get :hx-trigger hx-trigger :hx-swap hx-swap :class class))
(defcomp ~nav-group-link (&key href hx-select nav-class label)
(div :class "relative nav-group"
(a :href href :hx-get href :hx-target "#main-panel"
:hx-select hx-select :hx-swap "outerHTML"
:hx-push-url "true" :class nav-class
(raw! label))))