Convert events header/panel f-string HTML to sexp calls

Migrates ~20 functions from f-string HTML construction to sexp():
- _oob_header_html, _post_header_html label/cart badge
- _calendars_header_html, _calendar_header_html, _calendar_nav_html
- _day_header_html, _day_nav_html (entries scroll menu + admin cog)
- _markets_header_html, _payments_header_html labels
- _calendars_main_panel_html + _calendars_list_html
- _calendar_main_panel_html (full month grid with day cells + entry badges)
- _day_main_panel_html + _day_row_html (entries table)
- _calendar_admin_main_panel_html + _calendar_description_display_html
- _markets_main_panel_html + _markets_list_html
- _payments_main_panel_html (SumUp config form)
- _entry_state_badge_html, _ticket_state_badge_html
- _day_admin_main_panel_html

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-28 12:19:57 +00:00
parent eda95ec58b
commit 903193d825

View File

@@ -24,10 +24,12 @@ from shared.sexp.helpers import (
def _oob_header_html(parent_id: str, child_id: str, row_html: str) -> str:
"""Wrap a header row in OOB div with child placeholder."""
return (
f'<div id="{parent_id}" hx-swap-oob="outerHTML" class="w-full">'
f'<div class="w-full">{row_html}'
f'<div id="{child_id}"></div></div></div>'
return sexp(
'(div :id pid :hx-swap-oob "outerHTML" :class "w-full"'
' (div :class "w-full"'
' (raw! rh)'
' (div :id cid)))',
pid=parent_id, cid=child_id, rh=row_html,
)
@@ -42,24 +44,23 @@ def _post_header_html(ctx: dict, *, oob: bool = False) -> str:
title = (post.get("title") or "")[:160]
feature_image = post.get("feature_image")
label_parts = []
if feature_image:
label_parts.append(
f'<img src="{feature_image}" class="h-8 w-8 rounded-full object-cover border border-stone-300 flex-shrink-0">'
)
label_parts.append(f"<span>{escape(title)}</span>")
label_html = "".join(label_parts)
label_html = sexp(
'(<> (when fi (img :src fi :class "h-8 w-8 rounded-full object-cover border border-stone-300 flex-shrink-0"))'
' (span t))',
fi=feature_image, t=title,
)
nav_parts = []
page_cart_count = ctx.get("page_cart_count", 0)
if page_cart_count and page_cart_count > 0:
cart_href = call_url(ctx, "cart_url", f"/{slug}/")
nav_parts.append(
f'<a href="{cart_href}" class="relative inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-full'
f' border border-emerald-300 bg-emerald-50 text-emerald-800 hover:bg-emerald-100 transition">'
f'<i class="fa fa-shopping-cart" aria-hidden="true"></i>'
f'<span>{page_cart_count}</span></a>'
)
nav_parts.append(sexp(
'(a :href ch :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 cnt))',
ch=cart_href, cnt=str(page_cart_count),
))
# Post nav: calendar links + admin
nav_parts.append(_post_nav_html(ctx))
@@ -124,7 +125,7 @@ def _calendars_header_html(ctx: dict, *, oob: bool = False) -> str:
' :link-href lh :link-label-html llh'
' :child-id "calendars-header-child" :oob oob)',
lh=link_href,
llh='<i class="fa fa-calendar" aria-hidden="true"></i><div>Calendars</div>',
llh=sexp('(<> (i :class "fa fa-calendar" :aria-hidden "true") (div "Calendars"))'),
oob=oob,
)
@@ -144,14 +145,15 @@ def _calendar_header_html(ctx: dict, *, oob: bool = False) -> str:
cal_desc = getattr(calendar, "description", "") or ""
link_href = url_for("calendars.calendar.get", calendar_slug=cal_slug)
label_html = (
'<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">'
f'<i class="fa fa-calendar"></i>'
f'<div class="shrink-0">{escape(cal_name)}</div>'
'</div>'
f'<div id="calendar-description-title" class="text-base font-normal break-words whitespace-normal min-w-0 break-all w-full text-center block">{escape(cal_desc)}</div>'
'</div>'
label_html = sexp(
'(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" n))'
' (div :id "calendar-description-title"'
' :class "text-base font-normal break-words whitespace-normal min-w-0 break-all w-full text-center block"'
' d))',
n=cal_name, d=cal_desc,
)
# Desktop nav: slots + admin
@@ -188,10 +190,10 @@ def _calendar_nav_html(ctx: dict) -> str:
))
if is_admin:
admin_href = url_for("calendars.calendar.admin.admin", calendar_slug=cal_slug)
parts.append(
f'<a href="{admin_href}" class="flex items-center gap-2 px-3 py-2 rounded text-sm">'
f'<i class="fa fa-cog" aria-hidden="true"></i></a>'
)
parts.append(sexp(
'(~nav-link :href h :icon "fa fa-cog" :select-colours sc)',
h=admin_href, sc=select_colours,
))
return "".join(parts)
@@ -217,11 +219,11 @@ def _day_header_html(ctx: dict, *, oob: bool = False) -> str:
month=day_date.month,
day=day_date.day,
)
label_html = (
'<div class="flex gap-1 items-center">'
f'<i class="fa fa-calendar-day"></i>'
f' {escape(day_date.strftime("%A %d %B %Y"))}'
'</div>'
label_html = sexp(
'(div :class "flex gap-1 items-center"'
' (i :class "fa fa-calendar-day")'
' (span d))',
d=day_date.strftime("%A %d %B %Y"),
)
nav_html = _day_nav_html(ctx)
@@ -252,11 +254,7 @@ def _day_nav_html(ctx: dict) -> str:
parts = []
# Confirmed entries nav (scrolling menu)
if confirmed_entries:
parts.append(
'<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">'
)
entry_links = []
for entry in confirmed_entries:
href = url_for(
"calendars.calendar.day.calendar_entries.calendar_entry.get",
@@ -266,17 +264,23 @@ def _day_nav_html(ctx: dict) -> str:
day=day_date.day,
entry_id=entry.id,
)
name = escape(entry.name)
start = entry.start_at.strftime("%H:%M") if entry.start_at else ""
end = f" {entry.end_at.strftime('%H:%M')}" if entry.end_at else ""
parts.append(
f'<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">'
f'<div class="flex-1 min-w-0">'
f'<div class="font-medium truncate">{name}</div>'
f'<div class="text-xs text-stone-600 truncate">{start}{end}</div>'
f'</div></a>'
)
parts.append('</div></div>')
entry_links.append(sexp(
'(a :href h :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" n)'
' (div :class "text-xs text-stone-600 truncate" t)))',
h=href, n=entry.name, t=f"{start}{end}",
))
inner = "".join(entry_links)
parts.append(sexp(
'(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)))',
inner=inner,
))
if is_admin and day_date:
admin_href = url_for(
@@ -286,10 +290,10 @@ def _day_nav_html(ctx: dict) -> str:
month=day_date.month,
day=day_date.day,
)
parts.append(
f'<a href="{admin_href}" class="flex items-center gap-2 px-3 py-2 rounded text-sm">'
f'<i class="fa fa-cog" aria-hidden="true"></i></a>'
)
parts.append(sexp(
'(~nav-link :href h :icon "fa fa-cog")',
h=admin_href,
))
return "".join(parts)
@@ -370,7 +374,7 @@ def _markets_header_html(ctx: dict, *, oob: bool = False) -> str:
' :link-href lh :link-label-html llh'
' :child-id "markets-header-child" :oob oob)',
lh=link_href,
llh='<i class="fa fa-shopping-bag" aria-hidden="true"></i><div>Markets</div>',
llh=sexp('(<> (i :class "fa fa-shopping-bag" :aria-hidden "true") (div "Markets"))'),
oob=oob,
)
@@ -388,7 +392,7 @@ def _payments_header_html(ctx: dict, *, oob: bool = False) -> str:
' :link-href lh :link-label-html llh'
' :child-id "payments-header-child" :oob oob)',
lh=link_href,
llh='<i class="fa fa-credit-card" aria-hidden="true"></i><div>Payments</div>',
llh=sexp('(<> (i :class "fa fa-credit-card" :aria-hidden "true") (div "Payments"))'),
oob=oob,
)
@@ -408,41 +412,45 @@ def _calendars_main_panel_html(ctx: dict) -> str:
csrf = csrf_token() if callable(csrf_token) else (csrf_token or "")
calendars = ctx.get("calendars") or []
hx_select = ctx.get("hx_select_search", "#main-panel")
parts = ['<section class="p-4">']
form_html = ""
if can_create:
create_url = url_for("calendars.create_calendar")
parts.append(
'<div id="cal-create-errors" class="mt-2 text-sm text-red-600"></div>'
f'<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;">"""
f'<input type="hidden" name="csrf_token" value="{csrf}">'
'<div class="flex-1"><label class="block text-sm text-gray-600">Name</label>'
'<input name="name" type="text" required class="w-full border rounded px-3 py-2" '
'placeholder="e.g. Events, Gigs, Meetings" /></div>'
'<button type="submit" class="border rounded px-3 py-2">Add calendar</button></form>'
form_html = sexp(
'(<>'
' (div :id "cal-create-errors" :class "mt-2 text-sm text-red-600")'
' (form :class "mt-4 flex gap-2 items-end" :hx-post cu'
' :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")))',
cu=create_url, csrf=csrf,
)
parts.append('<div id="calendars-list" class="mt-6">')
parts.append(_calendars_list_html(ctx, calendars))
parts.append('</div></section>')
return "".join(parts)
list_html = _calendars_list_html(ctx, calendars)
return sexp(
'(section :class "p-4"'
' (raw! fh)'
' (div :id "calendars-list" :class "mt-6" (raw! lh)))',
fh=form_html, lh=list_html,
)
def _calendars_list_html(ctx: dict, calendars: list) -> str:
"""Render the calendars list items."""
from quart import url_for
from shared.utils import route_prefix
hx_select = ctx.get("hx_select_search", "#main-panel")
csrf_token = ctx.get("csrf_token")
csrf = csrf_token() if callable(csrf_token) else (csrf_token or "")
prefix = route_prefix()
if not calendars:
return '<p class="text-gray-500 mt-4">No calendars yet. Create one above.</p>'
return sexp('(p :class "text-gray-500 mt-4" "No calendars yet. Create one above.")')
parts = []
for cal in calendars:
@@ -450,22 +458,25 @@ def _calendars_list_html(ctx: dict, calendars: list) -> str:
cal_name = getattr(cal, "name", "")
href = prefix + url_for("calendars.calendar.get", calendar_slug=cal_slug)
del_url = url_for("calendars.calendar.delete", calendar_slug=cal_slug)
parts.append(
f'<div class="mt-6 border rounded-lg p-4"><div class="flex items-center justify-between gap-3">'
f'<a class="flex items-baseline gap-3" href="{href}" '
f'hx-get="{href}" hx-target="#main-panel" hx-select="{hx_select}" hx-swap="outerHTML" hx-push-url="true">'
f'<h3 class="font-semibold">{escape(cal_name)}</h3>'
f'<h4 class="text-gray-500">/{escape(cal_slug)}/</h4></a>'
f'<button class="text-sm border rounded px-3 py-1 hover:bg-red-50 hover:border-red-400" '
f'data-confirm data-confirm-title="Delete calendar?" '
f'data-confirm-text="Entries will be hidden (soft delete)" '
f'data-confirm-icon="warning" data-confirm-confirm-text="Yes, delete it" '
f'data-confirm-cancel-text="Cancel" data-confirm-event="confirmed" '
f'hx-delete="{del_url}" hx-trigger="confirmed" '
f'hx-target="#calendars-list" hx-select="#calendars-list" hx-swap="outerHTML" '
f"""hx-headers='{{"X-CSRFToken":"{csrf}"}}'>"""
f'<i class="fa-solid fa-trash"></i></button></div></div>'
)
csrf_hdr = f'{{"X-CSRFToken":"{csrf}"}}'
parts.append(sexp(
'(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 h'
' :hx-get h :hx-target "#main-panel" :hx-select "#main-panel" :hx-swap "outerHTML" :hx-push-url "true"'
' (h3 :class "font-semibold" cn)'
' (h4 :class "text-gray-500" (str "/" cs "/")))'
' (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 du :hx-trigger "confirmed"'
' :hx-target "#calendars-list" :hx-select "#calendars-list" :hx-swap "outerHTML"'
' :hx-headers ch'
' (i :class "fa-solid fa-trash"))))',
h=href, cn=cal_name, cs=cal_slug, du=del_url, ch=csrf_hdr,
))
return "".join(parts)
@@ -502,50 +513,39 @@ def _calendar_main_panel_html(ctx: dict) -> str:
qs = qsession if "qsession" not in ctx else ctx["qsession"]
def nav_link(y, m):
href = url_for("calendars.calendar.get", calendar_slug=cal_slug, year=y, month=m)
return href
return url_for("calendars.calendar.get", calendar_slug=cal_slug, year=y, month=m)
# Month navigation header
parts = ['<section class="bg-orange-100">']
parts.append('<header class="flex items-center justify-center mt-2">')
parts.append('<nav class="flex items-center gap-2 text-2xl">')
# Year/month nav arrows
# Month navigation arrows
nav_arrows = []
for label, yr, mn in [
("&laquo;", prev_year, month),
("&lsaquo;", prev_month_year, prev_month),
("\u00ab", prev_year, month),
("\u2039", prev_month_year, prev_month),
]:
href = nav_link(yr, mn)
parts.append(
f'<a class="{pill_cls} text-xl" href="{href}" '
f'hx-get="{href}" hx-target="#main-panel" hx-select="{hx_select}" '
f'hx-swap="outerHTML" hx-push-url="true">{label}</a>'
)
nav_arrows.append(sexp(
'(a :class (str pc " text-xl") :href h'
' :hx-get h :hx-target "#main-panel" :hx-select "#main-panel" :hx-swap "outerHTML" :hx-push-url "true" l)',
pc=pill_cls, h=href, l=label,
))
parts.append(f'<div class="px-3 font-medium">{escape(month_name)} {year}</div>')
nav_arrows.append(sexp('(div :class "px-3 font-medium" (str mn " " yr))', mn=month_name, yr=str(year)))
for label, yr, mn in [
("&rsaquo;", next_month_year, next_month),
("&raquo;", next_year, month),
("\u203a", next_month_year, next_month),
("\u00bb", next_year, month),
]:
href = nav_link(yr, mn)
parts.append(
f'<a class="{pill_cls} text-xl" href="{href}" '
f'hx-get="{href}" hx-target="#main-panel" hx-select="{hx_select}" '
f'hx-swap="outerHTML" hx-push-url="true">{label}</a>'
)
parts.append('</nav></header>')
nav_arrows.append(sexp(
'(a :class (str pc " text-xl") :href h'
' :hx-get h :hx-target "#main-panel" :hx-select "#main-panel" :hx-swap "outerHTML" :hx-push-url "true" l)',
pc=pill_cls, h=href, l=label,
))
# Calendar grid
parts.append('<div class="rounded-2xl border border-stone-200 bg-white/80 p-4">')
# Weekday headers
parts.append('<div class="hidden sm:grid grid-cols-7 text-center text-md font-semibold text-stone-700 mb-2">')
for wd in weekday_names:
parts.append(f'<div class="py-1">{wd}</div>')
parts.append('</div>')
wd_html = "".join(sexp('(div :class "py-1" w)', w=wd) for wd in weekday_names)
# Weeks grid
parts.append('<div class="grid grid-cols-1 sm:grid-cols-7 gap-px bg-stone-200 rounded-xl overflow-hidden">')
# Day cells
cells = []
for week in weeks:
for day_cell in week:
if isinstance(day_cell, dict):
@@ -563,29 +563,27 @@ def _calendar_main_panel_html(ctx: dict) -> str:
if is_today:
cell_cls += " ring-2 ring-blue-500 z-10 relative"
parts.append(f'<div class="{cell_cls}">')
parts.append('<div class="flex justify-between items-center"><div class="flex flex-col">')
if day_date:
parts.append(f'<span class="sm:hidden text-[16px] text-stone-500">{day_date.strftime("%a")}</span>')
# Clickable day number
# Day number link
day_num_html = ""
day_short_html = ""
if day_date:
day_href = url_for(
"calendars.calendar.day.show_day",
calendar_slug=cal_slug,
year=day_date.year,
month=day_date.month,
day=day_date.day,
year=day_date.year, month=day_date.month, day=day_date.day,
)
parts.append(
f'<a class="{pill_cls}" href="{day_href}" '
f'hx-get="{day_href}" hx-target="#main-panel" hx-select="{hx_select}" '
f'hx-swap="outerHTML" hx-push-url="true">{day_date.day}</a>'
day_short_html = sexp(
'(span :class "sm:hidden text-[16px] text-stone-500" d)',
d=day_date.strftime("%a"),
)
day_num_html = sexp(
'(a :class pc :href h :hx-get h :hx-target "#main-panel" :hx-select "#main-panel"'
' :hx-swap "outerHTML" :hx-push-url "true" n)',
pc=pill_cls, h=day_href, n=str(day_date.day),
)
parts.append('</div></div>')
# Entries for this day
parts.append('<div class="mt-1 space-y-0.5">')
# Entry badges for this day
entry_badges = []
if day_date:
for e in month_entries:
if e.start_at and e.start_at.date() == day_date:
@@ -597,18 +595,34 @@ def _calendar_main_panel_html(ctx: dict) -> str:
bg_cls = "bg-emerald-200 text-emerald-900" if is_mine else "bg-emerald-100 text-emerald-800"
else:
bg_cls = "bg-sky-100 text-sky-800" if is_mine else "bg-stone-100 text-stone-700"
state_label = (e.state or "pending").replace("_", " ")
parts.append(
f'<div class="flex items-center justify-between gap-1 text-[11px] rounded px-1 py-0.5 {bg_cls}">'
f'<span class="truncate">{escape(e.name)}</span>'
f'<span class="shrink-0 text-[10px] font-semibold uppercase tracking-tight">{state_label}</span>'
f'</div>'
)
parts.append('</div></div>')
entry_badges.append(sexp(
'(div :class (str "flex items-center justify-between gap-1 text-[11px] rounded px-1 py-0.5 " bc)'
' (span :class "truncate" n)'
' (span :class "shrink-0 text-[10px] font-semibold uppercase tracking-tight" sl))',
bc=bg_cls, n=e.name, sl=state_label,
))
parts.append('</div></div></section>')
return "".join(parts)
badges_html = "".join(entry_badges)
cells.append(sexp(
'(div :class cc'
' (div :class "flex justify-between items-center"'
' (div :class "flex flex-col" (raw! dsh) (raw! dnh)))'
' (div :class "mt-1 space-y-0.5" (raw! bh)))',
cc=cell_cls, dsh=day_short_html, dnh=day_num_html, bh=badges_html,
))
cells_html = "".join(cells)
arrows_html = "".join(nav_arrows)
return sexp(
'(section :class "bg-orange-100"'
' (header :class "flex items-center justify-center mt-2"'
' (nav :class "flex items-center gap-2 text-2xl" (raw! ah)))'
' (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! wh))'
' (div :class "grid grid-cols-1 sm:grid-cols-7 gap-px bg-stone-200 rounded-xl overflow-hidden" (raw! ch))))',
ah=arrows_html, wh=wd_html, ch=cells_html,
)
# ---------------------------------------------------------------------------
@@ -634,40 +648,36 @@ def _day_main_panel_html(ctx: dict) -> str:
tr_cls = getattr(styles, "tr", "") if hasattr(styles, "tr") else styles.get("tr", "")
pre_action = getattr(styles, "pre_action_button", "") if hasattr(styles, "pre_action_button") else styles.get("pre_action_button", "")
parts = [f'<section id="day-entries" class="{list_container}">']
parts.append(
'<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>'
'<th class="text-left p-2 w-1/6">Slot/Time</th>'
'<th class="text-left p-2 w-1/6">State</th>'
'<th class="text-left p-2 w-1/6">Cost</th>'
'<th class="text-left p-2 w-1/6">Tickets</th>'
'<th class="text-left p-2 w-1/6">Actions</th>'
'</tr></thead><tbody>'
)
rows_html = ""
if day_entries:
for entry in day_entries:
parts.append(_day_row_html(ctx, entry))
rows_html = "".join(_day_row_html(ctx, entry) for entry in day_entries)
else:
parts.append('<tr><td colspan="6" class="p-3 text-stone-500">No entries yet.</td></tr>')
rows_html = sexp('(tr (td :colspan "6" :class "p-3 text-stone-500" "No entries yet."))')
parts.append('</tbody></table>')
# Add entry button
add_url = url_for(
"calendars.calendar.day.calendar_entries.add_form",
calendar_slug=cal_slug,
day=day, month=month, year=year,
)
parts.append(
f'<div id="entry-add-container" class="mt-4">'
f'<button type="button" class="{pre_action}" '
f'hx-get="{add_url}" hx-target="#entry-add-container" hx-swap="innerHTML">'
f'+ Add entry</button></div>'
return sexp(
'(section :id "day-entries" :class lc'
' (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! rh)))'
' (div :id "entry-add-container" :class "mt-4"'
' (button :type "button" :class pa'
' :hx-get au :hx-target "#entry-add-container" :hx-swap "innerHTML"'
' "+ Add entry")))',
lc=list_container, rh=rows_html, pa=pre_action, au=add_url,
)
parts.append('</section>')
return "".join(parts)
def _day_row_html(ctx: dict, entry) -> str:
@@ -689,11 +699,11 @@ def _day_row_html(ctx: dict, entry) -> str:
)
# Name
name_html = (
f'<td class="p-2 align-top w-2/6"><div class="font-medium">'
f'<a href="{entry_href}" class="{pill_cls}" '
f'hx-get="{entry_href}" hx-target="#main-panel" hx-select="{hx_select}" hx-swap="outerHTML" hx-push-url="true">'
f'{escape(entry.name)}</a></div></td>'
name_html = sexp(
'(td :class "p-2 align-top w-2/6" (div :class "font-medium"'
' (a :href h :class pc :hx-get h :hx-target "#main-panel" :hx-select "#main-panel"'
' :hx-swap "outerHTML" :hx-push-url "true" n)))',
h=entry_href, pc=pill_cls, n=entry.name,
)
# Slot/Time
@@ -701,47 +711,55 @@ def _day_row_html(ctx: dict, entry) -> str:
if slot:
slot_href = url_for("calendars.calendar.slots.slot.get", calendar_slug=cal_slug, slot_id=slot.id)
time_start = slot.time_start.strftime("%H:%M") if slot.time_start else ""
time_end = f" {slot.time_end.strftime('%H:%M')}" if slot.time_end else ""
slot_html = (
f'<td class="p-2 align-top w-1/6"><div class="text-xs font-medium">'
f'<a href="{slot_href}" class="{pill_cls}" '
f'hx-get="{slot_href}" hx-target="#main-panel" hx-select="{hx_select}" hx-swap="outerHTML" hx-push-url="true">'
f'{escape(slot.name)}</a>'
f'<span class="text-stone-600 font-normal">({time_start}{time_end})</span>'
f'</div></td>'
time_end = f" \u2192 {slot.time_end.strftime('%H:%M')}" if slot.time_end else ""
slot_html = sexp(
'(td :class "p-2 align-top w-1/6" (div :class "text-xs font-medium"'
' (a :href h :class pc :hx-get h :hx-target "#main-panel" :hx-select "#main-panel"'
' :hx-swap "outerHTML" :hx-push-url "true" sn)'
' (span :class "text-stone-600 font-normal" (str "(" ts te ")"))))',
h=slot_href, pc=pill_cls, sn=slot.name, ts=time_start, te=time_end,
)
else:
start = entry.start_at.strftime("%H:%M") if entry.start_at else ""
end = f" {entry.end_at.strftime('%H:%M')}" if entry.end_at else ""
slot_html = f'<td class="p-2 align-top w-1/6"><div class="text-xs text-stone-600">{start}{end}</div></td>'
end = f" \u2192 {entry.end_at.strftime('%H:%M')}" if entry.end_at else ""
slot_html = sexp(
'(td :class "p-2 align-top w-1/6" (div :class "text-xs text-stone-600" (str s e)))',
s=start, e=end,
)
# State
state = getattr(entry, "state", "pending") or "pending"
state_html = _entry_state_badge_html(state)
state_td = f'<td class="p-2 align-top w-1/6"><div id="entry-state-{entry.id}">{state_html}</div></td>'
state_badge = _entry_state_badge_html(state)
state_td = sexp(
'(td :class "p-2 align-top w-1/6" (div :id sid (raw! sb)))',
sid=f"entry-state-{entry.id}", sb=state_badge,
)
# Cost
cost = getattr(entry, "cost", None)
cost_str = f"£{cost:.2f}" if cost is not None else "£0.00"
cost_td = f'<td class="p-2 align-top w-1/6"><span class="font-medium text-green-600">{cost_str}</span></td>'
cost_str = f"\u00a3{cost:.2f}" if cost is not None else "\u00a30.00"
cost_td = sexp('(td :class "p-2 align-top w-1/6" (span :class "font-medium text-green-600" c))', c=cost_str)
# Tickets
tp = getattr(entry, "ticket_price", None)
if tp is not None:
tc = getattr(entry, "ticket_count", None)
tc_str = f"{tc} tickets" if tc is not None else "Unlimited"
tickets_td = (
f'<td class="p-2 align-top w-1/6"><div class="text-xs space-y-1">'
f'<div class="font-medium text-green-600"{tp:.2f}</div>'
f'<div class="text-stone-600">{tc_str}</div></div></td>'
tickets_td = sexp(
'(td :class "p-2 align-top w-1/6" (div :class "text-xs space-y-1"'
' (div :class "font-medium text-green-600" tp)'
' (div :class "text-stone-600" tc)))',
tp=f"\u00a3{tp:.2f}", tc=tc_str,
)
else:
tickets_td = '<td class="p-2 align-top w-1/6"><span class="text-xs text-stone-400">No tickets</span></td>'
tickets_td = sexp('(td :class "p-2 align-top w-1/6" (span :class "text-xs text-stone-400" "No tickets"))')
# Actions (entry options) - keep simple, just link to entry
actions_td = f'<td class="p-2 align-top w-1/6"></td>'
actions_td = sexp('(td :class "p-2 align-top w-1/6")')
return f'<tr class="{tr_cls}">{name_html}{slot_html}{state_td}{cost_td}{tickets_td}{actions_td}</tr>'
return sexp(
'(tr :class tc (raw! nh) (raw! sh) (raw! std) (raw! ctd) (raw! ttd) (raw! atd))',
tc=tr_cls, nh=name_html, sh=slot_html, std=state_td, ctd=cost_td, ttd=tickets_td, atd=actions_td,
)
def _entry_state_badge_html(state: str) -> str:
@@ -755,7 +773,10 @@ def _entry_state_badge_html(state: str) -> str:
}
cls = state_classes.get(state, "bg-stone-100 text-stone-700")
label = state.replace("_", " ").capitalize()
return f'<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium {cls}">{label}</span>'
return sexp(
'(span :class (str "inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium " c) l)',
c=cls, l=label,
)
# ---------------------------------------------------------------------------
@@ -764,7 +785,7 @@ def _entry_state_badge_html(state: str) -> str:
def _day_admin_main_panel_html(ctx: dict) -> str:
"""Render day admin panel (placeholder nav)."""
return '<div class="p-4 text-sm text-stone-500">Admin options</div>'
return sexp('(div :class "p-4 text-sm text-stone-500" "Admin options")')
# ---------------------------------------------------------------------------
@@ -786,42 +807,40 @@ def _calendar_admin_main_panel_html(ctx: dict) -> str:
desc_edit_url = url_for("calendars.calendar.admin.calendar_description_edit", calendar_slug=cal_slug)
description_html = _calendar_description_display_html(calendar, desc_edit_url)
parts = ['<section class="max-w-3xl mx-auto p-4 space-y-10">']
parts.append('<div><h2 class="text-xl font-semibold">Calendar configuration</h2>')
parts.append('<div id="cal-put-errors" class="mt-2 text-sm text-red-600"></div>')
parts.append(f'<div><label class="block text-sm font-medium text-stone-700">Description</label>')
parts.append(description_html)
parts.append('</div>')
# Hidden form for direct PUT
parts.append(
f'<form id="calendar-form" method="post" hx-target="#main-panel" hx-select="{hx_select}" '
"""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">'
f'<input type="hidden" name="csrf_token" value="{csrf}">'
'<div><label class="block text-sm font-medium text-stone-700">Description</label>'
f'<div>{escape(desc)}</div>'
f'<textarea name="description" autocomplete="off" rows="4" class="w-full p-2 border rounded">{escape(desc)}</textarea>'
'</div><div><button class="px-3 py-2 rounded bg-stone-800 text-white">Save</button></div></form>'
return sexp(
'(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! dh))'
' (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 d)'
' (textarea :name "description" :autocomplete "off" :rows "4" :class "w-full p-2 border rounded" d))'
' (div (button :class "px-3 py-2 rounded bg-stone-800 text-white" "Save"))))'
' (hr :class "border-stone-200"))',
dh=description_html, csrf=csrf, d=desc,
)
parts.append('</div><hr class="border-stone-200"></section>')
return "".join(parts)
def _calendar_description_display_html(calendar, edit_url: str) -> str:
"""Render calendar description display with edit button."""
desc = getattr(calendar, "description", "") or ""
if desc:
desc_html = f'<p class="text-stone-700 whitespace-pre-line break-all">{escape(desc)}</p>'
else:
desc_html = '<p class="text-stone-400 italic">No description yet.</p>'
return (
f'<div id="calendar-description">{desc_html}'
f'<button type="button" class="mt-2 text-xs underline" '
f'hx-get="{edit_url}" hx-target="#calendar-description" hx-swap="outerHTML">'
f'<i class="fas fa-edit"></i></button></div>'
return sexp(
'(div :id "calendar-description"'
' (if d'
' (p :class "text-stone-700 whitespace-pre-line break-all" d)'
' (p :class "text-stone-400 italic" "No description yet."))'
' (button :type "button" :class "mt-2 text-xs underline"'
' :hx-get eu :hx-target "#calendar-description" :hx-swap "outerHTML"'
' (i :class "fas fa-edit")))',
d=desc, eu=edit_url,
)
@@ -839,27 +858,33 @@ def _markets_main_panel_html(ctx: dict) -> str:
csrf_token = ctx.get("csrf_token")
csrf = csrf_token() if callable(csrf_token) else (csrf_token or "")
markets = ctx.get("markets") or []
post = ctx.get("post") or {}
parts = ['<section class="p-4">']
form_html = ""
if can_create:
create_url = url_for("markets.create_market")
parts.append(
'<div id="market-create-errors" class="mt-2 text-sm text-red-600"></div>'
f'<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;">"""
f'<input type="hidden" name="csrf_token" value="{csrf}">'
'<div class="flex-1"><label class="block text-sm text-gray-600">Name</label>'
'<input name="name" type="text" required class="w-full border rounded px-3 py-2" '
'placeholder="e.g. Farm Shop, Bakery" /></div>'
'<button type="submit" class="border rounded px-3 py-2">Add market</button></form>'
form_html = sexp(
'(<>'
' (div :id "market-create-errors" :class "mt-2 text-sm text-red-600")'
' (form :class "mt-4 flex gap-2 items-end" :hx-post cu'
' :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")))',
cu=create_url, csrf=csrf,
)
parts.append('<div id="markets-list" class="mt-6">')
parts.append(_markets_list_html(ctx, markets))
parts.append('</div></section>')
return "".join(parts)
list_html = _markets_list_html(ctx, markets)
return sexp(
'(section :class "p-4"'
' (raw! fh)'
' (div :id "markets-list" :class "mt-6" (raw! lh)))',
fh=form_html, lh=list_html,
)
def _markets_list_html(ctx: dict, markets: list) -> str:
@@ -871,7 +896,7 @@ def _markets_list_html(ctx: dict, markets: list) -> str:
slug = post.get("slug", "")
if not markets:
return '<p class="text-gray-500 mt-4">No markets yet. Create one above.</p>'
return sexp('(p :class "text-gray-500 mt-4" "No markets yet. Create one above.")')
parts = []
for m in markets:
@@ -879,21 +904,24 @@ def _markets_list_html(ctx: dict, markets: list) -> str:
m_name = getattr(m, "name", "") if hasattr(m, "name") else m.get("name", "")
market_href = call_url(ctx, "market_url", f"/{slug}/{m_slug}/")
del_url = url_for("markets.delete_market", market_slug=m_slug)
parts.append(
f'<div class="mt-6 border rounded-lg p-4"><div class="flex items-center justify-between gap-3">'
f'<a class="flex items-baseline gap-3" href="{market_href}">'
f'<h3 class="font-semibold">{escape(m_name)}</h3>'
f'<h4 class="text-gray-500">/{escape(m_slug)}/</h4></a>'
f'<button class="text-sm border rounded px-3 py-1 hover:bg-red-50 hover:border-red-400" '
f'data-confirm data-confirm-title="Delete market?" '
f'data-confirm-text="Products will be hidden (soft delete)" '
f'data-confirm-icon="warning" data-confirm-confirm-text="Yes, delete it" '
f'data-confirm-cancel-text="Cancel" data-confirm-event="confirmed" '
f'hx-delete="{del_url}" hx-trigger="confirmed" '
f'hx-target="#markets-list" hx-select="#markets-list" hx-swap="outerHTML" '
f"""hx-headers='{{"X-CSRFToken":"{csrf}"}}'>"""
f'<i class="fa-solid fa-trash"></i></button></div></div>'
)
csrf_hdr = f'{{"X-CSRFToken":"{csrf}"}}'
parts.append(sexp(
'(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 h'
' (h3 :class "font-semibold" mn)'
' (h4 :class "text-gray-500" (str "/" ms "/")))'
' (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 du :hx-trigger "confirmed"'
' :hx-target "#markets-list" :hx-select "#markets-list" :hx-swap "outerHTML"'
' :hx-headers ch'
' (i :class "fa-solid fa-trash"))))',
h=market_href, mn=m_name, ms=m_slug, du=del_url, ch=csrf_hdr,
))
return "".join(parts)
@@ -912,30 +940,29 @@ def _payments_main_panel_html(ctx: dict) -> str:
update_url = url_for("payments.update_sumup")
placeholder = "--------" if sumup_configured else "sup_sk_..."
key_note = '<p class="text-xs text-stone-400 mt-0.5">Key is set. Leave blank to keep current key.</p>' if sumup_configured else ""
connected = ('<span class="ml-2 text-xs text-green-600">'
'<i class="fa fa-check-circle"></i> Connected</span>') if sumup_configured else ""
input_cls = "w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500"
return (
'<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"></i> SumUp Payment</h3>'
'<p class="text-xs text-stone-400">Configure per-page SumUp credentials. Leave blank to use the global merchant account.</p>'
f'<form hx-put="{update_url}" hx-target="#payments-panel" hx-swap="outerHTML" hx-select="#payments-panel" class="space-y-3">'
f'<input type="hidden" name="csrf_token" value="{csrf}">'
'<div><label class="block text-xs font-medium text-stone-600 mb-1">Merchant Code</label>'
f'<input type="text" name="merchant_code" value="{escape(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>'
'<div><label class="block text-xs font-medium text-stone-600 mb-1">API Key</label>'
f'<input type="password" name="api_key" value="" placeholder="{placeholder}" '
f'class="w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500">'
f'{key_note}</div>'
'<div><label class="block text-xs font-medium text-stone-600 mb-1">Checkout Reference Prefix</label>'
f'<input type="text" name="checkout_prefix" value="{escape(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"></div>'
'<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">'
f'Save SumUp Settings</button>{connected}</form></div></section>'
return sexp(
'(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 uu :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 mc :placeholder "e.g. ME4J6100" :class ic))'
' (div (label :class "block text-xs font-medium text-stone-600 mb-1" "API Key")'
' (input :type "password" :name "api_key" :value "" :placeholder ph :class ic)'
' (when sc (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 cp :placeholder "e.g. ROSE-" :class ic))'
' (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 sc (span :class "ml-2 text-xs text-green-600"'
' (i :class "fa fa-check-circle") " Connected")))))',
uu=update_url, csrf=csrf, mc=merchant_code, ph=placeholder,
ic=input_cls, sc=sumup_configured, cp=checkout_prefix,
)
@@ -953,7 +980,10 @@ def _ticket_state_badge_html(state: str) -> str:
}
cls = cls_map.get(state, "bg-stone-100 text-stone-700")
label = (state or "").replace("_", " ").capitalize()
return f'<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium {cls}">{label}</span>'
return sexp(
'(span :class (str "inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium " c) l)',
c=cls, l=label,
)
# ---------------------------------------------------------------------------