diff --git a/events/sexp/sexp_components.py b/events/sexp/sexp_components.py
index 3b07fd8..5694058 100644
--- a/events/sexp/sexp_components.py
+++ b/events/sexp/sexp_components.py
@@ -994,51 +994,53 @@ def _tickets_main_panel_html(ctx: dict, tickets: list) -> str:
"""Render my tickets list."""
from quart import url_for
- parts = [f'']
- parts.append('My Tickets ')
-
+ ticket_cards = []
if tickets:
- parts.append('')
for ticket in tickets:
href = url_for("tickets.ticket_detail", code=ticket.code)
entry = getattr(ticket, "entry", None)
entry_name = entry.name if entry else "Unknown event"
tt = getattr(ticket, "ticket_type", None)
state = getattr(ticket, "state", "")
+ cal = getattr(entry, "calendar", None) if entry else None
- parts.append(
- f'
'
- ''
- f'
{escape(entry_name)}
'
- )
- if tt:
- parts.append(f'
{escape(tt.name)}
')
+ time_str = ""
if entry and entry.start_at:
- parts.append(
- '
'
- f'{entry.start_at.strftime("%A, %B %d, %Y at %H:%M")}'
- )
+ time_str = entry.start_at.strftime("%A, %B %d, %Y at %H:%M")
if entry.end_at:
- parts.append(f' – {entry.end_at.strftime("%H:%M")}')
- parts.append('
')
- cal = getattr(entry, "calendar", None)
- if cal:
- parts.append(f'
{escape(cal.name)}
')
+ time_str += f" \u2013 {entry.end_at.strftime('%H:%M')}"
- parts.append('
')
- parts.append(_ticket_state_badge_html(state))
- parts.append(f'{ticket.code[:8]}... ')
- parts.append('
')
- parts.append('
')
- else:
- parts.append(
- ''
- '
'
- '
No tickets yet
'
- '
Tickets will appear here after you purchase them.
'
- )
- parts.append(' ')
- return "".join(parts)
+ ticket_cards.append(sexp(
+ '(a :href h :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" en)'
+ ' (when tn (div :class "text-sm text-stone-600 mt-0.5" tn))'
+ ' (when ts (div :class "text-sm text-stone-500 mt-1" ts))'
+ ' (when cn (div :class "text-xs text-stone-400 mt-0.5" cn)))'
+ ' (div :class "flex flex-col items-end gap-1 flex-shrink-0"'
+ ' (raw! sb)'
+ ' (span :class "text-xs text-stone-400 font-mono" (str cc "...")))))',
+ h=href, en=entry_name,
+ tn=tt.name if tt else None,
+ ts=time_str or None,
+ cn=cal.name if cal else None,
+ sb=_ticket_state_badge_html(state),
+ cc=ticket.code[:8],
+ ))
+
+ cards_html = "".join(ticket_cards)
+ return sexp(
+ '(section :id "tickets-list" :class lc'
+ ' (h1 :class "text-2xl font-bold mb-6" "My Tickets")'
+ ' (if has'
+ ' (div :class "space-y-4" (raw! ch))'
+ ' (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."))))',
+ lc=_list_container(ctx), has=bool(tickets), ch=cards_html,
+ )
# ---------------------------------------------------------------------------
@@ -1053,82 +1055,70 @@ def _ticket_detail_panel_html(ctx: dict, ticket) -> str:
tt = getattr(ticket, "ticket_type", None)
state = getattr(ticket, "state", "")
code = ticket.code
+ cal = getattr(entry, "calendar", None) if entry else None
+ checked_in_at = getattr(ticket, "checked_in_at", None)
- # Background color for header
bg_map = {"confirmed": "bg-emerald-50", "checked_in": "bg-blue-50", "reserved": "bg-amber-50"}
header_bg = bg_map.get(state, "bg-stone-50")
-
entry_name = entry.name if entry else "Ticket"
back_href = url_for("tickets.my_tickets")
- parts = [f'']
- parts.append(
- f''
- ' Back to my tickets '
- )
+ # Badge with larger sizing
+ badge = _ticket_state_badge_html(state).replace('px-2 py-0.5 text-xs', 'px-3 py-1 text-sm')
- parts.append('')
- # Header
- parts.append(f'')
+ # Time info
+ time_date = entry.start_at.strftime("%A, %B %d, %Y") if entry and entry.start_at else None
+ time_range = entry.start_at.strftime("%H:%M") if entry and entry.start_at else None
+ if time_range and entry.end_at:
+ time_range += f" \u2013 {entry.end_at.strftime('%H:%M')}"
- # QR code
- parts.append(
- f'
'
- )
+ tt_desc = f"{tt.name} \u2014 \u00a3{tt.cost:.2f}" if tt and getattr(tt, "cost", None) else None
+ checkin_str = checked_in_at.strftime("Checked in: %B %d, %Y at %H:%M") if checked_in_at else None
- # Event details
- parts.append('
')
- if entry and entry.start_at:
- parts.append(
- '
'
- f'
{entry.start_at.strftime("%A, %B %d, %Y")}
'
- f'
{entry.start_at.strftime("%H:%M")}'
- )
- if entry.end_at:
- parts.append(f' – {entry.end_at.strftime("%H:%M")}')
- parts.append('
')
-
- cal = getattr(entry, "calendar", None)
- if cal:
- parts.append(
- '
'
- )
-
- if tt and getattr(tt, "cost", None):
- parts.append(
- '
'
- f'
{escape(tt.name)} — £{tt.cost:.2f}
'
- )
-
- checked_in_at = getattr(ticket, "checked_in_at", None)
- if checked_in_at:
- parts.append(
- '
'
- f'
Checked in: {checked_in_at.strftime("%B %d, %Y at %H:%M")}
'
- )
- parts.append('
')
-
- # QR code script
- parts.append(
- ''
- ''
+ "}})()"
+ )
+
+ return sexp(
+ '(section :id "ticket-detail" :class (str lc " max-w-lg mx-auto")'
+ ' (a :href bh :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 " hbg)'
+ ' (div :class "flex items-center justify-between"'
+ ' (h1 :class "text-xl font-bold" en)'
+ ' (raw! bdg))'
+ ' (when tn (div :class "text-sm text-stone-600 mt-1" tn)))'
+ ' (div :class "px-6 py-8 flex flex-col items-center border-b border-stone-100"'
+ ' (div :id (str "ticket-qr-" cd) :class "bg-white p-4 rounded-lg border border-stone-200")'
+ ' (p :class "text-xs text-stone-400 mt-3 font-mono select-all" cd))'
+ ' (div :class "px-6 py-4 space-y-3"'
+ ' (when td (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" td)'
+ ' (div :class "text-sm text-stone-500" tr))))'
+ ' (when cn (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" cn)))'
+ ' (when ttd (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" ttd)))'
+ ' (when cs (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" cs)))))'
+ ' (script :src "https://cdn.jsdelivr.net/npm/qrcode@1.5.3/build/qrcode.min.js")'
+ ' (script qs))',
+ lc=_list_container(ctx), bh=back_href, hbg=header_bg,
+ en=entry_name, bdg=badge,
+ tn=tt.name if tt else None,
+ cd=code, td=time_date, tr=time_range,
+ cn=cal.name if cal else None,
+ ttd=tt_desc, cs=checkin_str, qs=qr_script,
)
- parts.append(' ')
- return "".join(parts)
# ---------------------------------------------------------------------------
@@ -1142,11 +1132,8 @@ def _ticket_admin_main_panel_html(ctx: dict, tickets: list, stats: dict) -> str:
csrf = csrf_token() if callable(csrf_token) else (csrf_token or "")
lookup_url = url_for("ticket_admin.lookup")
- parts = [f'']
- parts.append('Ticket Admin ')
-
- # Stats
- parts.append('')
+ # Stats cards
+ stats_html = ""
for label, key, border, bg, text_cls in [
("Total", "total", "border-stone-200", "", "text-stone-900"),
("Confirmed", "confirmed", "border-emerald-200", "bg-emerald-50", "text-emerald-700"),
@@ -1155,76 +1142,96 @@ def _ticket_admin_main_panel_html(ctx: dict, tickets: list, stats: dict) -> str:
]:
val = stats.get(key, 0)
lbl_cls = text_cls.replace("700", "600").replace("900", "500") if "stone" not in text_cls else "text-stone-500"
- parts.append(
- f'
'
+ stats_html += sexp(
+ '(div :class (str "rounded-xl border " b " " bg " p-4 text-center")'
+ ' (div :class (str "text-2xl font-bold " tc) v)'
+ ' (div :class (str "text-xs " lc " uppercase tracking-wide") l))',
+ b=border, bg=bg, tc=text_cls, v=str(val), lc=lbl_cls, l=label,
)
- parts.append('
')
- # Scanner
- parts.append(
- ''
- '
Scan / Look Up Ticket'
- '
'
- f' '
- '"""
- '
'
- '
'
- 'Enter a ticket code to look it up
'
+ # Ticket rows
+ rows_html = ""
+ for ticket in tickets:
+ entry = getattr(ticket, "entry", None)
+ tt = getattr(ticket, "ticket_type", None)
+ state = getattr(ticket, "state", "")
+ code = ticket.code
+
+ date_html = ""
+ if entry and entry.start_at:
+ date_html = sexp(
+ '(div :class "text-xs text-stone-500" d)',
+ d=entry.start_at.strftime("%d %b %Y, %H:%M"),
+ )
+
+ action_html = ""
+ if state in ("confirmed", "reserved"):
+ checkin_url = url_for("ticket_admin.do_checkin", code=code)
+ action_html = sexp(
+ '(form :hx-post cu :hx-target (str "#ticket-row-" c) :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"))',
+ cu=checkin_url, c=code, csrf=csrf,
+ )
+ elif state == "checked_in":
+ checked_in_at = getattr(ticket, "checked_in_at", None)
+ t_str = checked_in_at.strftime("%H:%M") if checked_in_at else ""
+ action_html = sexp(
+ '(span :class "text-xs text-blue-600"'
+ ' (i :class "fa fa-check-circle" :aria-hidden "true") (str " " ts))',
+ ts=t_str,
+ )
+
+ rows_html += sexp(
+ '(tr :class "hover:bg-stone-50 transition" :id (str "ticket-row-" c)'
+ ' (td :class "px-4 py-3" (span :class "font-mono text-xs" cs))'
+ ' (td :class "px-4 py-3" (div :class "font-medium" en) (raw! dh))'
+ ' (td :class "px-4 py-3 text-sm" tn)'
+ ' (td :class "px-4 py-3" (raw! sb))'
+ ' (td :class "px-4 py-3" (raw! ah)))',
+ c=code, cs=code[:12] + "...",
+ en=entry.name if entry else "—",
+ dh=date_html, tn=tt.name if tt else "—",
+ sb=_ticket_state_badge_html(state), ah=action_html,
+ )
+
+ return sexp(
+ '(section :id "ticket-admin" :class lc'
+ ' (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! sh))'
+ ' (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 lu :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! rh))))'
+ ' (div :class "px-6 py-8 text-center text-stone-500" "No tickets yet"))))',
+ lc=_list_container(ctx), sh=stats_html, lu=lookup_url,
+ **{"has-tickets": bool(tickets)}, rh=rows_html,
)
- # Recent tickets table
- parts.append('')
- parts.append('
Recent Tickets ')
-
- if tickets:
- parts.append('
')
- for col in ["Code", "Event", "Type", "State", "Actions"]:
- parts.append(f'{col} ')
- parts.append(' ')
-
- for ticket in tickets:
- entry = getattr(ticket, "entry", None)
- tt = getattr(ticket, "ticket_type", None)
- state = getattr(ticket, "state", "")
- code = ticket.code
-
- parts.append(f'')
- parts.append(f'{code[:12]}... ')
- parts.append(f'{escape(entry.name) if entry else "—"}
')
- if entry and entry.start_at:
- parts.append(f'{entry.start_at.strftime("%d %b %Y, %H:%M")}
')
- parts.append(' ')
- parts.append(f'{escape(tt.name) if tt else "—"} ')
- parts.append(f'{_ticket_state_badge_html(state)} ')
-
- # Actions
- parts.append('')
- if state in ("confirmed", "reserved"):
- checkin_url = url_for("ticket_admin.do_checkin", code=code)
- parts.append(
- f''
- )
- elif state == "checked_in":
- checked_in_at = getattr(ticket, "checked_in_at", None)
- t_str = checked_in_at.strftime("%H:%M") if checked_in_at else ""
- parts.append(f' {t_str} ')
- parts.append(' ')
-
- parts.append('
')
- else:
- parts.append('
No tickets yet
')
-
- parts.append('
')
- return "".join(parts)
-
# ---------------------------------------------------------------------------
# All events / page summary entry cards
@@ -1246,54 +1253,70 @@ def _entry_card_html(entry, page_info: dict, pending_tickets: dict,
day_href = events_url_fn(f"/{page_slug}/calendars/{entry.calendar_slug}/day/{entry.start_at.strftime('%Y/%-m/%-d')}/")
entry_href = f"{day_href}entries/{entry.id}/" if day_href else ""
- parts = ['']
- parts.append('')
- parts.append('
')
-
- if entry_href:
- parts.append(f'
')
- parts.append(f'{escape(entry.name)} ')
- if entry_href:
- parts.append(' ')
+ # Title (linked or plain)
+ title_html = sexp(
+ '(if eh (a :href eh :class "hover:text-emerald-700"'
+ ' (h2 :class "text-lg font-semibold text-stone-900" n))'
+ ' (h2 :class "text-lg font-semibold text-stone-900" n))',
+ eh=entry_href or False, n=entry.name,
+ )
# Badges
- parts.append('
')
+ badges_html = ""
if page_title and (not is_page_scoped or page_title != (post or {}).get("title")):
page_href = events_url_fn(f"/{page_slug}/")
- parts.append(
- f'
'
- f'{escape(page_title)} '
+ badges_html += sexp(
+ '(a :href ph :class "inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 hover:bg-amber-200" pt)',
+ ph=page_href, pt=page_title,
)
cal_name = getattr(entry, "calendar_name", "")
if cal_name:
- parts.append(f'
{escape(cal_name)} ')
- parts.append('
')
+ badges_html += sexp(
+ '(span :class "inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-sky-100 text-sky-700" cn)',
+ cn=cal_name,
+ )
- # Time
- parts.append('
')
+ # Time line
+ time_parts = ""
if day_href and not is_page_scoped:
- parts.append(f'
{entry.start_at.strftime("%a %-d %b")} · ')
+ time_parts += sexp(
+ '(<> (a :href dh :class "hover:text-stone-700" ds) (raw! " · "))',
+ dh=day_href, ds=entry.start_at.strftime("%a %-d %b"),
+ )
elif not is_page_scoped:
- parts.append(f'{entry.start_at.strftime("%a %-d %b")} · ')
- parts.append(entry.start_at.strftime("%H:%M"))
+ time_parts += sexp(
+ '(<> (span ds) (raw! " · "))',
+ ds=entry.start_at.strftime("%a %-d %b"),
+ )
+ time_parts += entry.start_at.strftime("%H:%M")
if entry.end_at:
- parts.append(f' – {entry.end_at.strftime("%H:%M")}')
- parts.append('
')
+ time_parts += f' \u2013 {entry.end_at.strftime("%H:%M")}'
cost = getattr(entry, "cost", None)
- if cost:
- parts.append(f'
£{cost:.2f}
')
- parts.append('
')
+ cost_html = sexp('(div :class "mt-1 text-sm font-medium text-green-600" (raw! c))',
+ c=f"£{cost:.2f}") if cost else ""
# Ticket widget
tp = getattr(entry, "ticket_price", None)
+ widget_html = ""
if tp is not None:
qty = pending_tickets.get(entry.id, 0)
- parts.append('
')
- parts.append(_ticket_widget_html(entry, qty, ticket_url, ctx={}))
- parts.append('
')
- parts.append('
')
- return "".join(parts)
+ widget_html = sexp(
+ '(div :class "shrink-0" (raw! w))',
+ w=_ticket_widget_html(entry, qty, ticket_url, ctx={}),
+ )
+
+ return sexp(
+ '(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! th)'
+ ' (div :class "flex flex-wrap items-center gap-1.5 mt-1" (raw! bh))'
+ ' (div :class "mt-1 text-sm text-stone-500" (raw! tp))'
+ ' (raw! ch))'
+ ' (raw! wh)))',
+ th=title_html, bh=badges_html, tp=time_parts, ch=cost_html, wh=widget_html,
+ )
def _entry_card_tile_html(entry, page_info: dict, pending_tickets: dict,
@@ -1312,48 +1335,63 @@ def _entry_card_tile_html(entry, page_info: dict, pending_tickets: dict,
day_href = events_url_fn(f"/{page_slug}/calendars/{entry.calendar_slug}/day/{entry.start_at.strftime('%Y/%-m/%-d')}/")
entry_href = f"{day_href}entries/{entry.id}/" if day_href else ""
- parts = ['']
- if entry_href:
- parts.append(f'
')
- parts.append(f'{escape(entry.name)} ')
- if entry_href:
- parts.append(' ')
+ # Title
+ title_html = sexp(
+ '(if eh (a :href eh :class "hover:text-emerald-700"'
+ ' (h2 :class "text-base font-semibold text-stone-900 line-clamp-2" n))'
+ ' (h2 :class "text-base font-semibold text-stone-900 line-clamp-2" n))',
+ eh=entry_href or False, n=entry.name,
+ )
- parts.append('
')
+ # Badges
+ badges_html = ""
if page_title and (not is_page_scoped or page_title != (post or {}).get("title")):
page_href = events_url_fn(f"/{page_slug}/")
- parts.append(
- f'
'
- f'{escape(page_title)} '
+ badges_html += sexp(
+ '(a :href ph :class "inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 hover:bg-amber-200" pt)',
+ ph=page_href, pt=page_title,
)
cal_name = getattr(entry, "calendar_name", "")
if cal_name:
- parts.append(f'
{escape(cal_name)} ')
- parts.append('
')
+ badges_html += sexp(
+ '(span :class "inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-sky-100 text-sky-700" cn)',
+ cn=cal_name,
+ )
- parts.append('
')
+ # Time
+ time_html = ""
if day_href:
- parts.append(f'
{entry.start_at.strftime("%a %-d %b")} ')
+ time_html += sexp('(a :href dh :class "hover:text-stone-700" ds)', dh=day_href, ds=entry.start_at.strftime("%a %-d %b"))
else:
- parts.append(entry.start_at.strftime("%a %-d %b"))
- parts.append(f' · {entry.start_at.strftime("%H:%M")}')
+ time_html += entry.start_at.strftime("%a %-d %b")
+ time_html += f' \u00b7 {entry.start_at.strftime("%H:%M")}'
if entry.end_at:
- parts.append(f' – {entry.end_at.strftime("%H:%M")}')
- parts.append('
')
+ time_html += f' \u2013 {entry.end_at.strftime("%H:%M")}'
cost = getattr(entry, "cost", None)
- if cost:
- parts.append(f'
£{cost:.2f}
')
- parts.append('
')
+ cost_html = sexp('(div :class "mt-1 text-sm font-medium text-green-600" (raw! c))',
+ c=f"£{cost:.2f}") if cost else ""
+ # Ticket widget
tp = getattr(entry, "ticket_price", None)
+ widget_html = ""
if tp is not None:
qty = pending_tickets.get(entry.id, 0)
- parts.append('')
- parts.append(_ticket_widget_html(entry, qty, ticket_url, ctx={}))
- parts.append('
')
- parts.append(' ')
- return "".join(parts)
+ widget_html = sexp(
+ '(div :class "border-t border-stone-100 px-3 py-2" (raw! w))',
+ w=_ticket_widget_html(entry, qty, ticket_url, ctx={}),
+ )
+
+ return sexp(
+ '(article :class "rounded-xl bg-white shadow-sm border border-stone-200 overflow-hidden"'
+ ' (div :class "p-3"'
+ ' (raw! th)'
+ ' (div :class "flex flex-wrap items-center gap-1 mt-1" (raw! bh))'
+ ' (div :class "mt-1 text-xs text-stone-500" (raw! tm))'
+ ' (raw! ch))'
+ ' (raw! wh))',
+ th=title_html, bh=badges_html, tm=time_html, ch=cost_html, wh=widget_html,
+ )
def _ticket_widget_html(entry, qty: int, ticket_url: str, *, ctx: dict) -> str:
@@ -1363,78 +1401,55 @@ def _ticket_widget_html(entry, qty: int, ticket_url: str, *, ctx: dict) -> str:
ct = ctx.get("csrf_token")
csrf_token_val = ct() if callable(ct) else (ct or "")
else:
- from quart import g as _g
- ct = getattr(_g, "_csrf_token", None)
try:
- from quart import current_app
- with current_app.app_context():
- pass
- except Exception:
- pass
- # Use a deferred approach - get CSRF from template context
- csrf_token_val = ""
-
- # For the ticket widget, we need to get csrf token from the app
- try:
- from flask_wtf.csrf import generate_csrf
- csrf_token_val = generate_csrf()
- except Exception:
- pass
-
- if not csrf_token_val:
- try:
- from quart import current_app
- csrf_token_val = current_app.config.get("WTF_CSRF_SECRET_KEY", "")
+ from flask_wtf.csrf import generate_csrf
+ csrf_token_val = generate_csrf()
except Exception:
pass
eid = entry.id
tp = getattr(entry, "ticket_price", 0) or 0
- cart_url_fn = None
+ tgt = f"#page-ticket-{eid}"
- parts = [f'
']
- parts.append(f'£{tp:.2f} ')
+ def _tw_form(count_val, btn_html):
+ return sexp(
+ '(form :action tu :method "post" :hx-post tu :hx-target tgt :hx-swap "outerHTML"'
+ ' (input :type "hidden" :name "csrf_token" :value csrf)'
+ ' (input :type "hidden" :name "entry_id" :value eid)'
+ ' (input :type "hidden" :name "count" :value cv)'
+ ' (raw! bh))',
+ tu=ticket_url, tgt=tgt, csrf=csrf_token_val,
+ eid=str(eid), cv=str(count_val), bh=btn_html,
+ )
if qty == 0:
- parts.append(
- f''
- )
+ inner = _tw_form(1, sexp(
+ '(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"))',
+ ))
else:
- # Minus button
- parts.append(
- f''
+ minus = _tw_form(qty - 1, sexp(
+ '(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" "-")',
+ ))
+ cart_icon = sexp(
+ '(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" q))))',
+ q=str(qty),
)
- # Cart icon with count
- parts.append(
- ''
- ''
- ' '
- ''
- f'{qty} '
- ' '
- )
- # Plus button
- parts.append(
- f''
- )
- parts.append('
')
- return "".join(parts)
+ plus = _tw_form(qty + 1, sexp(
+ '(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" "+")',
+ ))
+ inner = minus + cart_icon + plus
+
+ return sexp(
+ '(div :id (str "page-ticket-" eid) :class "flex items-center gap-2"'
+ ' (span :class "text-green-600 font-medium text-sm" (raw! pr))'
+ ' (raw! inner))',
+ eid=str(eid), pr=f"£{tp:.2f}", inner=inner,
+ )
def _entry_cards_html(entries, page_info, pending_tickets, ticket_url,
@@ -1452,10 +1467,11 @@ def _entry_cards_html(entries, page_info, pending_tickets, ticket_url,
else:
entry_date = entry.start_at.strftime("%A %-d %B %Y") if entry.start_at else ""
if entry_date != last_date:
- parts.append(
- f'
'
- f'{entry_date} '
- )
+ parts.append(sexp(
+ '(div :class "pt-2 pb-1"'
+ ' (h3 :class "text-sm font-semibold text-stone-500 uppercase tracking-wide" d))',
+ d=entry_date,
+ ))
last_date = entry_date
parts.append(_entry_card_html(
entry, page_info, pending_tickets, ticket_url, events_url_fn,
@@ -1463,12 +1479,13 @@ def _entry_cards_html(entries, page_info, pending_tickets, ticket_url,
))
if has_more:
- parts.append(
- f''
- )
+ parts.append(sexp(
+ '(div :id (str "sentinel-" p) :class "h-4 opacity-0 pointer-events-none"'
+ ' :hx-get nu :hx-trigger "intersect once delay:250ms" :hx-swap "outerHTML"'
+ ' :role "status" :aria-hidden "true"'
+ ' (div :class "text-center text-xs text-stone-400" "loading..."))',
+ p=str(page), nu=next_url,
+ ))
return "".join(parts)
@@ -1476,8 +1493,17 @@ def _entry_cards_html(entries, page_info, pending_tickets, ticket_url,
# All events / page summary main panels
# ---------------------------------------------------------------------------
-_LIST_SVG = ' '
-_TILE_SVG = ' '
+_LIST_SVG = sexp(
+ '(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"))',
+)
+_TILE_SVG = sexp(
+ '(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"))',
+)
def _view_toggle_html(ctx: dict, view: str) -> str:
@@ -1485,13 +1511,10 @@ def _view_toggle_html(ctx: dict, view: str) -> str:
from shared.utils import route_prefix
prefix = route_prefix()
clh = ctx.get("current_local_href", "/")
- qs_fn = ctx.get("qs")
hx_select = ctx.get("hx_select_search", "#main-panel")
- # Build hrefs - list removes view param, tile sets view=tile
list_href = prefix + str(clh)
tile_href = prefix + str(clh)
- # Use simple query parameter manipulation
if "?" in list_href:
list_href = list_href.split("?")[0]
if "?" in tile_href:
@@ -1502,14 +1525,21 @@ def _view_toggle_html(ctx: dict, view: str) -> str:
list_active = 'bg-stone-200 text-stone-800' if view != 'tile' else 'text-stone-400 hover:text-stone-600'
tile_active = 'bg-stone-200 text-stone-800' if view == 'tile' else 'text-stone-400 hover:text-stone-600'
- return (
- '"""
+ return sexp(
+ '(div :class "hidden md:flex justify-end px-3 pt-3 gap-1"'
+ ' (a :href lh :hx-get lh :hx-target "#main-panel" :hx-select hs'
+ ' :hx-swap "outerHTML" :hx-push-url "true"'
+ ' :class (str "p-1.5 rounded " la) :title "List view"'
+ """ :_ "on click js localStorage.removeItem('events_view') end" """
+ ' (raw! ls))'
+ ' (a :href th :hx-get th :hx-target "#main-panel" :hx-select hs'
+ ' :hx-swap "outerHTML" :hx-push-url "true"'
+ ' :class (str "p-1.5 rounded " ta) :title "Tile view"'
+ """ :_ "on click js localStorage.setItem('events_view','tile') end" """
+ ' (raw! ts)))',
+ lh=list_href, th=tile_href, hs=hx_select,
+ la=list_active, ta=tile_active,
+ ls=_LIST_SVG, ts=_TILE_SVG,
)
@@ -1517,7 +1547,7 @@ def _events_main_panel_html(ctx: dict, entries, has_more, pending_tickets, page_
page, view, ticket_url, next_url, events_url_fn,
*, is_page_scoped=False, post=None) -> str:
"""Render the events main panel with view toggle + cards."""
- parts = [_view_toggle_html(ctx, view)]
+ toggle = _view_toggle_html(ctx, view)
if entries:
cards = _entry_cards_html(
@@ -1525,18 +1555,20 @@ def _events_main_panel_html(ctx: dict, entries, has_more, pending_tickets, page_
view, page, has_more, next_url,
is_page_scoped=is_page_scoped, post=post,
)
- if view == "tile":
- parts.append(f'{cards}
')
- else:
- parts.append(f'{cards}
')
+ grid_cls = ("max-w-full px-3 py-3 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4"
+ if view == "tile" else "max-w-full px-3 py-3 space-y-3")
+ body = sexp('(div :class gc (raw! c))', gc=grid_cls, c=cards)
else:
- parts.append(
- ''
- '
'
- '
No upcoming events
'
+ body = sexp(
+ '(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"))',
)
- parts.append('
')
- return "".join(parts)
+
+ return sexp(
+ '(<> (raw! tg) (raw! bd) (div :class "pb-8"))',
+ tg=toggle, bd=body,
+ )
# ---------------------------------------------------------------------------
@@ -1934,11 +1966,10 @@ def render_ticket_widget(entry, qty: int, ticket_url: str) -> str:
def render_checkin_result(success: bool, error: str | None, ticket) -> str:
"""Render checkin result: table row on success, error div on failure."""
if not success:
- err_msg = escape(error or "Check-in failed")
- return (
- ''
- f' {err_msg}'
- '
'
+ return sexp(
+ '(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") em)',
+ em=error or "Check-in failed",
)
if not ticket:
return ""
@@ -1948,22 +1979,24 @@ def render_checkin_result(success: bool, error: str | None, ticket) -> str:
checked_in_at = getattr(ticket, "checked_in_at", None)
time_str = checked_in_at.strftime("%H:%M") if checked_in_at else "Just now"
- entry_name = escape(entry.name) if entry else "—"
date_html = ""
if entry and entry.start_at:
- date_html = f'{entry.start_at.strftime("%d %b %Y, %H:%M")}
'
+ date_html = sexp('(div :class "text-xs text-stone-500" d)',
+ d=entry.start_at.strftime("%d %b %Y, %H:%M"))
- tt_name = escape(tt.name) if tt else "—"
-
- return (
- f''
- f'{code[:12]}... '
- f'{entry_name}
{date_html} '
- f'{tt_name} '
- f'{_ticket_state_badge_html("checked_in")} '
- f''
- f' {time_str} '
- ' '
+ return sexp(
+ '(tr :class "bg-blue-50" :id (str "ticket-row-" c)'
+ ' (td :class "px-4 py-3" (span :class "font-mono text-xs" cs))'
+ ' (td :class "px-4 py-3" (div :class "font-medium" en) (raw! dh))'
+ ' (td :class "px-4 py-3 text-sm" tn)'
+ ' (td :class "px-4 py-3" (raw! sb))'
+ ' (td :class "px-4 py-3"'
+ ' (span :class "text-xs text-blue-600"'
+ ' (i :class "fa fa-check-circle" :aria-hidden "true") (str " " ts))))',
+ c=code, cs=code[:12] + "...",
+ en=entry.name if entry else "\u2014",
+ dh=date_html, tn=tt.name if tt else "\u2014",
+ sb=_ticket_state_badge_html("checked_in"), ts=time_str,
)
@@ -1977,10 +2010,10 @@ def render_lookup_result(ticket, error: str | None) -> str:
from shared.browser.app.csrf import generate_csrf_token
if error:
- return (
- ''
- f' {escape(error)}'
- '
'
+ return sexp(
+ '(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") em)',
+ em=error,
)
if not ticket:
return ""
@@ -1992,52 +2025,57 @@ def render_lookup_result(ticket, error: str | None) -> str:
checked_in_at = getattr(ticket, "checked_in_at", None)
csrf = generate_csrf_token()
- entry_name = escape(entry.name) if entry else "Unknown event"
- parts = ['']
- parts.append('
')
- parts.append(f'
{entry_name}
')
+ # Info section
+ info_html = sexp('(div :class "font-semibold text-lg" en)',
+ en=entry.name if entry else "Unknown event")
if tt:
- parts.append(f'
{escape(tt.name)}
')
+ info_html += sexp('(div :class "text-sm text-stone-600" tn)', tn=tt.name)
if entry and entry.start_at:
- parts.append(f'
{entry.start_at.strftime("%A, %B %d, %Y at %H:%M")}
')
+ info_html += sexp('(div :class "text-sm text-stone-500 mt-1" d)',
+ d=entry.start_at.strftime("%A, %B %d, %Y at %H:%M"))
cal = getattr(entry, "calendar", None) if entry else None
if cal:
- parts.append(f'
{escape(cal.name)}
')
-
- parts.append('
')
- parts.append(_ticket_state_badge_html(state))
- parts.append(f'{code} ')
- parts.append('
')
-
+ info_html += sexp('(div :class "text-xs text-stone-400 mt-0.5" cn)', cn=cal.name)
+ info_html += sexp(
+ '(div :class "mt-2" (raw! sb) (span :class "text-xs text-stone-400 ml-2 font-mono" c))',
+ sb=_ticket_state_badge_html(state), c=code,
+ )
if checked_in_at:
- parts.append(f'
Checked in: {checked_in_at.strftime("%B %d, %Y at %H:%M")}
')
-
- parts.append('
')
+ info_html += sexp('(div :class "text-xs text-blue-600 mt-1" (str "Checked in: " d))',
+ d=checked_in_at.strftime("%B %d, %Y at %H:%M"))
# Action area
- parts.append(f'
')
+ action_html = ""
if state in ("confirmed", "reserved"):
checkin_url = url_for("ticket_admin.do_checkin", code=code)
- parts.append(
- f'
'
+ action_html = sexp(
+ '(form :hx-post cu :hx-target (str "#checkin-action-" c) :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"))',
+ cu=checkin_url, c=code, csrf=csrf,
)
elif state == "checked_in":
- parts.append(
- '
'
+ action_html = sexp(
+ '(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"))',
)
elif state == "cancelled":
- parts.append(
- '
'
+ action_html = sexp(
+ '(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"))',
)
- parts.append('
')
- return "".join(parts)
+
+ return sexp(
+ '(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! ih))'
+ ' (div :id (str "checkin-action-" c) (raw! ah))))',
+ ih=info_html, c=code, ah=action_html,
+ )
# ---------------------------------------------------------------------------
@@ -2052,54 +2090,66 @@ def render_entry_tickets_admin(entry, tickets: list) -> str:
count = len(tickets)
suffix = "s" if count != 1 else ""
- parts = ['']
- parts.append(
- '
'
- f'
Tickets for: {escape(entry.name)} '
- f'{count} ticket{suffix} '
- ''
- )
+
+ rows_html = ""
+ for ticket in tickets:
+ tt = getattr(ticket, "ticket_type", None)
+ state = getattr(ticket, "state", "")
+ code = ticket.code
+ checked_in_at = getattr(ticket, "checked_in_at", None)
+
+ action_html = ""
+ if state in ("confirmed", "reserved"):
+ checkin_url = url_for("ticket_admin.do_checkin", code=code)
+ action_html = sexp(
+ '(form :hx-post cu :hx-target (str "#entry-ticket-row-" c) :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"))',
+ cu=checkin_url, c=code, csrf=csrf,
+ )
+ elif state == "checked_in":
+ t_str = checked_in_at.strftime("%H:%M") if checked_in_at else ""
+ action_html = sexp(
+ '(span :class "text-xs text-blue-600"'
+ ' (i :class "fa fa-check-circle" :aria-hidden "true") (str " " ts))',
+ ts=t_str,
+ )
+
+ rows_html += sexp(
+ '(tr :class "hover:bg-stone-50" :id (str "entry-ticket-row-" c)'
+ ' (td :class "px-4 py-2 font-mono text-xs" cs)'
+ ' (td :class "px-4 py-2" tn)'
+ ' (td :class "px-4 py-2" (raw! sb))'
+ ' (td :class "px-4 py-2" (raw! ah)))',
+ c=code, cs=code[:12] + "...",
+ tn=tt.name if tt else "\u2014",
+ sb=_ticket_state_badge_html(state), ah=action_html,
+ )
if tickets:
- parts.append('
')
- parts.append(
- '
'
- 'Code '
- 'Type '
- 'State '
- 'Actions '
- ' '
+ body_html = sexp(
+ '(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! rh))))',
+ rh=rows_html,
)
- for ticket in tickets:
- tt = getattr(ticket, "ticket_type", None)
- state = getattr(ticket, "state", "")
- code = ticket.code
- checked_in_at = getattr(ticket, "checked_in_at", None)
-
- parts.append(f'')
- parts.append(f'{code[:12]}... ')
- parts.append(f'{escape(tt.name) if tt else "—"} ')
- parts.append(f'{_ticket_state_badge_html(state)} ')
- parts.append('')
- if state in ("confirmed", "reserved"):
- checkin_url = url_for("ticket_admin.do_checkin", code=code)
- parts.append(
- f''
- )
- elif state == "checked_in":
- t_str = checked_in_at.strftime("%H:%M") if checked_in_at else ""
- parts.append(f' {t_str} ')
- parts.append(' ')
-
- parts.append('
')
else:
- parts.append('
No tickets for this entry
')
+ body_html = sexp('(div :class "text-center py-6 text-stone-500 text-sm" "No tickets for this entry")')
- parts.append('
')
- return "".join(parts)
+ return sexp(
+ '(div :class "space-y-4"'
+ ' (div :class "flex items-center justify-between"'
+ ' (h3 :class "text-lg font-semibold" (str "Tickets for: " en))'
+ ' (span :class "text-sm text-stone-500" cl))'
+ ' (raw! bh))',
+ en=entry.name, cl=f"{count} ticket{suffix}", bh=body_html,
+ )
# ---------------------------------------------------------------------------
@@ -2119,7 +2169,6 @@ def _entry_main_panel_html(ctx: dict) -> str:
"""Render the entry detail panel (name, slot, time, state, cost, tickets,
buy form, date, posts, options + edit button)."""
from quart import url_for
- from shared.browser.app.csrf import generate_csrf_token
entry = ctx.get("entry")
if not entry:
@@ -2137,116 +2186,100 @@ def _entry_main_panel_html(ctx: dict) -> str:
eid = entry.id
state = getattr(entry, "state", "pending") or "pending"
- parts = [f'']
+ def _field(label, content_html):
+ return sexp(
+ '(div :class "flex flex-col mb-4"'
+ ' (div :class "text-xs font-semibold uppercase tracking-wide text-stone-500" l)'
+ ' (raw! ch))',
+ l=label, ch=content_html,
+ )
# Name
- parts.append(
- ''
- '
Name
'
- f'
{escape(entry.name)}
'
- '
'
- )
+ name_html = _field("Name", sexp('(div :class "mt-1 text-lg font-medium" n)', n=entry.name))
# Slot
slot = getattr(entry, "slot", None)
- parts.append(
- ''
- '
Slot
'
- '
'
- )
if slot:
flex_label = "(flexible)" if getattr(slot, "flexible", False) else "(fixed)"
- parts.append(
- f'{escape(slot.name)} '
- f'{flex_label} '
+ slot_inner = sexp(
+ '(div :class "mt-1"'
+ ' (span :class "px-2 py-1 rounded text-sm bg-blue-100 text-blue-700" sn)'
+ ' (span :class "ml-2 text-xs text-stone-500" fl))',
+ sn=slot.name, fl=flex_label,
)
else:
- parts.append('No slot assigned ')
- parts.append('
')
+ slot_inner = sexp('(div :class "mt-1" (span :class "text-sm text-stone-400" "No slot assigned"))')
+ slot_html = _field("Slot", slot_inner)
# Time Period
start_str = entry.start_at.strftime("%H:%M") if entry.start_at else ""
- end_str = f" – {entry.end_at.strftime('%H:%M')}" if entry.end_at else " – open-ended"
- parts.append(
- ''
- '
Time Period
'
- f'
{start_str}{end_str}
'
- '
'
- )
+ end_str = f" \u2013 {entry.end_at.strftime('%H:%M')}" if entry.end_at else " \u2013 open-ended"
+ time_html = _field("Time Period", sexp('(div :class "mt-1" t)', t=start_str + end_str))
# State
- state_badge = _entry_state_badge_html(state)
- parts.append(
- ''
- )
+ state_html = _field("State", sexp(
+ '(div :class "mt-1" (div :id (str "entry-state-" eid) (raw! sb)))',
+ eid=str(eid), sb=_entry_state_badge_html(state),
+ ))
# Cost
cost = getattr(entry, "cost", None)
cost_str = f"{cost:.2f}" if cost is not None else "0.00"
- parts.append(
- ''
- '
Cost
'
- f'
£{cost_str}
'
- '
'
- )
+ cost_html = _field("Cost", sexp(
+ '(div :class "mt-1" (span :class "font-medium text-green-600" (raw! cs)))',
+ cs=f"£{cost_str}",
+ ))
# Ticket Configuration (admin)
- parts.append(
- ''
- '
Tickets
'
- f'
'
- )
- parts.append(render_entry_tickets_config(entry, calendar, day, month, year))
- parts.append('
')
+ tickets_html = _field("Tickets", sexp(
+ '(div :class "mt-1" :id (str "entry-tickets-" eid) (raw! tc))',
+ eid=str(eid), tc=render_entry_tickets_config(entry, calendar, day, month, year),
+ ))
# Buy Tickets (public-facing)
ticket_remaining = ctx.get("ticket_remaining")
ticket_sold_count = ctx.get("ticket_sold_count", 0)
user_ticket_count = ctx.get("user_ticket_count", 0)
user_ticket_counts_by_type = ctx.get("user_ticket_counts_by_type") or {}
- parts.append(render_buy_form(
+ buy_html = render_buy_form(
entry, ticket_remaining, ticket_sold_count,
user_ticket_count, user_ticket_counts_by_type,
- ))
+ )
# Date
date_str = entry.start_at.strftime("%A, %B %d, %Y") if entry.start_at else ""
- parts.append(
- ''
- '
Date
'
- f'
{date_str}
'
- '
'
- )
+ date_html = _field("Date", sexp('(div :class "mt-1" d)', d=date_str))
# Associated Posts
entry_posts = ctx.get("entry_posts") or []
- parts.append(
- ''
- '
Associated Posts
'
- f'
'
- )
- parts.append(render_entry_posts_panel(entry_posts, entry, calendar, day, month, year))
- parts.append('
')
+ posts_html = _field("Associated Posts", sexp(
+ '(div :class "mt-1" :id (str "entry-posts-" eid) (raw! ph))',
+ eid=str(eid), ph=render_entry_posts_panel(entry_posts, entry, calendar, day, month, year),
+ ))
# Options and Edit Button
- parts.append('')
- parts.append(_entry_options_html(entry, calendar, day, month, year))
-
edit_url = url_for(
"calendars.calendar.day.calendar_entries.calendar_entry.get_edit",
entry_id=eid, calendar_slug=cal_slug,
day=day, month=month, year=year,
)
- parts.append(
- f''
- 'Edit '
+
+ return sexp(
+ '(section :id (str "entry-" eid) :class lc'
+ ' (raw! nh) (raw! slh) (raw! tmh) (raw! sth) (raw! cth)'
+ ' (raw! tkh) (raw! buyh) (raw! dth) (raw! psh)'
+ ' (div :class "flex gap-2 mt-6"'
+ ' (raw! opts)'
+ ' (button :type "button" :class pa'
+ ' :hx-get eu :hx-target (str "#entry-" eid) :hx-swap "outerHTML"'
+ ' "Edit")))',
+ eid=str(eid), lc=list_container,
+ nh=name_html, slh=slot_html, tmh=time_html, sth=state_html,
+ cth=cost_html, tkh=tickets_html, buyh=buy_html,
+ dth=date_html, psh=posts_html,
+ opts=_entry_options_html(entry, calendar, day, month, year),
+ pa=pre_action, eu=edit_url,
)
- parts.append('
')
- return "".join(parts)
# ---------------------------------------------------------------------------
@@ -2274,11 +2307,10 @@ def _entry_header_html(ctx: dict, *, oob: bool = False) -> str:
year=year, month=month, day=day,
entry_id=entry.id,
)
- label_html = (
- f''
- + _entry_title_html(entry)
- + _entry_times_html(entry)
- + '
'
+ label_html = sexp(
+ '(div :id (str "entry-title-" eid) :class "flex gap-1 items-center"'
+ ' (raw! th) (raw! tmh))',
+ eid=str(entry.id), th=_entry_title_html(entry), tmh=_entry_times_html(entry),
)
nav_html = _entry_nav_html(ctx)
@@ -2301,8 +2333,8 @@ def _entry_times_html(entry) -> str:
if not start:
return ""
start_str = start.strftime("%H:%M")
- end_str = f" → {end.strftime('%H:%M')}" if end else ""
- return f'{start_str}{end_str}
'
+ end_str = f" \u2192 {end.strftime('%H:%M')}" if end else ""
+ return sexp('(div :class "text-sm text-gray-600" t)', t=start_str + end_str)
# ---------------------------------------------------------------------------
@@ -2333,26 +2365,30 @@ def _entry_nav_html(ctx: dict) -> str:
# Associated Posts scrolling menu
if entry_posts:
- parts.append(
- '')
+ 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 "entry-posts-nav-wrapper"'
+ ' (div :class "flex overflow-x-auto gap-1 scrollbar-thin" (raw! pl)))',
+ pl=post_links,
+ ))
# Admin link
if is_admin:
@@ -2362,11 +2398,11 @@ def _entry_nav_html(ctx: dict) -> str:
day=day, month=month, year=year,
entry_id=entry.id,
)
- parts.append(
- f''
- ' Admin '
- )
+ parts.append(sexp(
+ '(a :href au :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")',
+ au=admin_url,
+ ))
return "".join(parts)
@@ -2407,19 +2443,19 @@ def render_entry_optioned(entry, calendar, day, month, year) -> str:
title = _entry_title_html(entry)
state = _entry_state_badge_html(getattr(entry, "state", "pending") or "pending")
- return (
- options
- + f'{title}
'
- + f'{state}
'
+ return options + sexp(
+ '(<> (div :id (str "entry-title-" eid) :hx-swap-oob "innerHTML" (raw! th))'
+ ' (div :id (str "entry-state-" eid) :hx-swap-oob "innerHTML" (raw! sh)))',
+ eid=str(entry.id), th=title, sh=state,
)
def _entry_title_html(entry) -> str:
"""Render entry title (icon + name + state badge)."""
state = getattr(entry, "state", "pending") or "pending"
- return (
- f' {escape(entry.name)} '
- + _entry_state_badge_html(state)
+ return sexp(
+ '(<> (i :class "fa fa-clock") " " n " " (raw! sb))',
+ n=entry.name, sb=_entry_state_badge_html(state),
)
@@ -2437,44 +2473,50 @@ def _entry_options_html(entry, calendar, day, month, year) -> str:
state = getattr(entry, "state", "pending") or "pending"
target = f"#calendar_entry_options_{eid}"
- parts = [f'']
-
def _make_button(action_name, label, confirm_title, confirm_text, *, trigger_type="submit"):
url = url_for(
f"calendars.calendar.day.calendar_entries.calendar_entry.{action_name}",
calendar_slug=cal_slug, day=day, month=month, year=year, entry_id=eid,
)
btn_type = "button" if trigger_type == "button" else "submit"
- trigger_attr = ' hx-trigger="confirmed"' if trigger_type == "button" else ""
- return (
- f''
+ return sexp(
+ '(form :hx-post u :hx-select tgt :hx-target tgt :hx-swap "outerHTML"'
+ ' :hx-trigger (if is-btn "confirmed" nil)'
+ ' (input :type "hidden" :name "csrf_token" :value csrf)'
+ ' (button :type bt :class ab'
+ ' :data-confirm "true" :data-confirm-title ct'
+ ' :data-confirm-text cx :data-confirm-icon "question"'
+ ' :data-confirm-confirm-text (str "Yes, " l " 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") l))',
+ u=url, tgt=target, csrf=csrf, bt=btn_type,
+ ab=action_btn, ct=confirm_title, cx=confirm_text,
+ l=label, **{"is-btn": trigger_type == "button"},
)
+ buttons_html = ""
if state == "provisional":
- parts.append(_make_button(
+ buttons_html += _make_button(
"confirm_entry", "confirm",
"Confirm entry?", "Are you sure you want to confirm this entry?",
- ))
- parts.append(_make_button(
+ )
+ buttons_html += _make_button(
"decline_entry", "decline",
"Decline entry?", "Are you sure you want to decline this entry?",
- ))
+ )
elif state == "confirmed":
- parts.append(_make_button(
+ buttons_html += _make_button(
"provisional_entry", "provisional",
"Provisional entry?", "Are you sure you want to provisional this entry?",
trigger_type="button",
- ))
+ )
- parts.append("
")
- return "".join(parts)
+ return sexp(
+ '(div :id (str "calendar_entry_options_" eid) :class "flex flex-col md:flex-row gap-1"'
+ ' (raw! bh))',
+ eid=str(eid), bh=buttons_html,
+ )
# ---------------------------------------------------------------------------
@@ -2491,31 +2533,34 @@ def render_entry_tickets_config(entry, calendar, day, month, year) -> str:
eid = entry.id
tp = getattr(entry, "ticket_price", None)
tc = getattr(entry, "ticket_count", None)
-
- parts = []
+ eid_s = str(eid)
+ show_js = f"document.getElementById('ticket-form-{eid}').classList.remove('hidden'); this.classList.add('hidden');"
+ hide_js = (f"document.getElementById('ticket-form-{eid}').classList.add('hidden'); "
+ f"document.getElementById('entry-tickets-{eid}').querySelectorAll('button:not([type=submit])').forEach(btn => btn.classList.remove('hidden'));")
if tp is not None:
- parts.append('')
- parts.append(f'
Price: ')
- parts.append(f'£{tp:.2f}
')
- parts.append(f'
Available: ')
tc_str = f"{tc} tickets" if tc is not None else "Unlimited"
- parts.append(f'{tc_str}
')
- parts.append(
- f'
"""
- 'Edit ticket config '
+ display_html = sexp(
+ '(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! ps)))'
+ ' (div :class "flex items-center gap-2"'
+ ' (span :class "text-sm font-medium text-stone-700" "Available:")'
+ ' (span :class "font-medium text-blue-600" ts))'
+ ' (button :type "button" :class "text-xs text-blue-600 hover:text-blue-800 underline"'
+ ' :onclick sj "Edit ticket config"))',
+ ps=f"£{tp:.2f}", ts=tc_str, sj=show_js,
)
else:
- parts.append('')
- parts.append('No tickets configured ')
- parts.append(
- f'"""
- 'Configure tickets
'
+ display_html = sexp(
+ '(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 sj "Configure tickets"))',
+ sj=show_js,
)
- # Form
update_url = url_for(
"calendars.calendar.day.calendar_entries.calendar_entry.update_tickets",
entry_id=eid, calendar_slug=cal_slug, day=day, month=month, year=year,
@@ -2524,24 +2569,30 @@ def render_entry_tickets_config(entry, calendar, day, month, year) -> str:
tp_val = f"{tp:.2f}" if tp is not None else ""
tc_val = str(tc) if tc is not None else ""
- parts.append(
- f''
+ form_html = sexp(
+ '(form :id (str "ticket-form-" eid) :class (str hc " space-y-3 mt-2 p-3 border rounded bg-stone-50")'
+ ' :hx-post uu :hx-target (str "#entry-tickets-" eid) :hx-swap "innerHTML"'
+ ' (input :type "hidden" :name "csrf_token" :value csrf)'
+ ' (div (label :for (str "ticket-price-" eid) :class "block text-sm font-medium text-stone-700 mb-1"'
+ ' (raw! "Ticket Price (£)"))'
+ ' (input :type "number" :id (str "ticket-price-" eid) :name "ticket_price"'
+ ' :step "0.01" :min "0" :value tpv'
+ ' :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-" eid) :class "block text-sm font-medium text-stone-700 mb-1"'
+ ' "Total Tickets")'
+ ' (input :type "number" :id (str "ticket-count-" eid) :name "ticket_count"'
+ ' :min "0" :value tcv'
+ ' :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 hj "Cancel")))',
+ eid=eid_s, hc=hidden_cls, uu=update_url, csrf=csrf,
+ tpv=tp_val, tcv=tc_val, hj=hide_js,
)
- return "".join(parts)
+ return display_html + form_html
# ---------------------------------------------------------------------------
@@ -2556,56 +2607,59 @@ def render_entry_posts_panel(entry_posts, entry, calendar, day, month, year) ->
cal_slug = getattr(calendar, "slug", "")
eid = entry.id
+ eid_s = str(eid)
- parts = ['']
+ posts_html = ""
if entry_posts:
- parts.append('
')
+ items = ""
for ep in entry_posts:
- ep_title = escape(getattr(ep, "title", ""))
+ ep_title = getattr(ep, "title", "")
ep_id = getattr(ep, "id", 0)
feat = getattr(ep, "feature_image", None)
- if feat:
- img = f'
'
- else:
- img = '
'
+ img_html = (sexp('(img :src f :alt t :class "w-8 h-8 rounded-full object-cover flex-shrink-0")', f=feat, t=ep_title)
+ if feat else sexp('(div :class "w-8 h-8 rounded-full bg-stone-200 flex-shrink-0")'))
del_url = url_for(
"calendars.calendar.day.calendar_entries.calendar_entry.remove_post",
calendar_slug=cal_slug, day=day, month=month, year=year,
entry_id=eid, post_id=ep_id,
)
- parts.append(
- f'
'
- f'{img}{ep_title} '
- f'"""
- ' Remove
'
+ items += sexp(
+ '(div :class "flex items-center justify-between gap-3 p-2 bg-stone-50 rounded border"'
+ ' (raw! ih) (span :class "text-sm flex-1" t)'
+ ' (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 " t " 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 du :hx-trigger "confirmed"'
+ ' :hx-target (str "#entry-posts-" eid) :hx-swap "innerHTML"'
+ ' :hx-headers hd'
+ ' (i :class "fa fa-times") " Remove"))',
+ ih=img_html, t=ep_title, du=del_url,
+ eid=eid_s, hd=f'{{"X-CSRFToken": "{csrf}"}}',
)
- parts.append('
')
+ posts_html = sexp('(div :class "space-y-2" (raw! it))', it=items)
else:
- parts.append('
No posts associated
')
+ posts_html = sexp('(p :class "text-sm text-stone-400" "No posts associated")')
- # Search to add
search_url = url_for(
"calendars.calendar.day.calendar_entries.calendar_entry.search_posts",
calendar_slug=cal_slug, day=day, month=month, year=year, entry_id=eid,
)
- parts.append(
- '
'
- '
Add Post '
- f'
'
- f'
'
+
+ return sexp(
+ '(div :class "space-y-2"'
+ ' (raw! ph)'
+ ' (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 su :hx-trigger "keyup changed delay:300ms, load"'
+ ' :hx-target (str "#post-search-results-" eid) :hx-swap "innerHTML" :name "q")'
+ ' (div :id (str "post-search-results-" eid) :class "mt-2 max-h-96 overflow-y-auto border rounded")))',
+ ph=posts_html, su=search_url, eid=eid_s,
)
- parts.append('
')
- return "".join(parts)
# ---------------------------------------------------------------------------
@@ -2620,33 +2674,27 @@ def render_entry_posts_nav_oob(entry_posts) -> str:
blog_url_fn = getattr(g, "blog_url", None)
if not entry_posts:
- return '
'
+ return sexp('(div :id "entry-posts-nav-wrapper" :hx-swap-oob "true")')
- parts = [
- '')
- return "".join(parts)
+
+ return 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 "entry-posts-nav-wrapper" :hx-swap-oob "true"'
+ ' (div :class "flex overflow-x-auto gap-1 scrollbar-thin" (raw! it)))',
+ it=items,
+ )
# ---------------------------------------------------------------------------
@@ -2662,13 +2710,9 @@ def render_day_entries_nav_oob(confirmed_entries, calendar, day_date) -> str:
cal_slug = getattr(calendar, "slug", "")
if not confirmed_entries:
- return '
'
+ return sexp('(div :id "day-entries-nav-wrapper" :hx-swap-oob "true")')
- parts = [
- '')
- return "".join(parts)
+
+ return 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" :hx-swap-oob "true"'
+ ' (div :class "flex overflow-x-auto gap-1 scrollbar-thin" (raw! it)))',
+ it=items,
+ )
# ---------------------------------------------------------------------------
@@ -2706,26 +2754,11 @@ def render_post_nav_entries_oob(associated_entries, calendars, post) -> str:
has_items = has_entries or calendars
if not has_items:
- return '
'
+ return sexp('(div :id "entries-calendars-nav-wrapper" :hx-swap-oob "true")')
slug = post.get("slug", "") if isinstance(post, dict) else getattr(post, "slug", "")
- parts = [
- ''
- '
'
- ' '
- '
')
- parts.append(
- ''
+ hs = ("on load or scroll "
+ "if window.innerWidth >= 640 and my.scrollWidth > my.clientWidth "
+ "remove .hidden from .entries-nav-arrow add .flex to .entries-nav-arrow "
+ "else add .hidden to .entries-nav-arrow remove .flex from .entries-nav-arrow end")
+
+ return 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 "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;" :_ hs'
+ ' (div :class "flex flex-col sm:flex-row gap-1" (raw! it)))'
+ ' (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")))',
+ it=items, hs=hs,
)
- parts.append(
- '
'
- ' '
- )
- parts.append('
')
- return "".join(parts)
# ---------------------------------------------------------------------------
@@ -2787,10 +2829,11 @@ def render_calendar_description(calendar, *, oob: bool = False) -> str:
if oob:
desc = getattr(calendar, "description", "") or ""
- html += (
- ''
- f'{escape(desc)}
'
+ html += sexp(
+ '(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"'
+ ' d)',
+ d=desc,
)
return html
@@ -2806,16 +2849,18 @@ def render_calendar_description_edit(calendar) -> str:
save_url = url_for("calendars.calendar.admin.calendar_description_save", calendar_slug=cal_slug)
cancel_url = url_for("calendars.calendar.admin.calendar_description_view", calendar_slug=cal_slug)
- return (
- ''
+ return sexp(
+ '(div :id "calendar-description"'
+ ' (form :hx-post su :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" d)'
+ ' (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 cu :hx-target "#calendar-description" :hx-swap "outerHTML"'
+ ' "Cancel"))))',
+ su=save_url, csrf=csrf, d=desc, cu=cancel_url,
)
@@ -2859,7 +2904,7 @@ def render_slot_main_panel(slot, calendar, *, oob: bool = False) -> str:
pre_action = getattr(styles, "pre_action_button", "") if hasattr(styles, "pre_action_button") else styles.get("pre_action_button", "")
cal_slug = getattr(calendar, "slug", "")
- days_display = getattr(slot, "days_display", "—")
+ days_display = getattr(slot, "days_display", "\u2014")
days = days_display.split(", ")
flexible = getattr(slot, "flexible", False)
time_start = slot.time_start.strftime("%H:%M") if slot.time_start else ""
@@ -2870,56 +2915,49 @@ def render_slot_main_panel(slot, calendar, *, oob: bool = False) -> str:
edit_url = url_for("calendars.calendar.slots.slot.get_edit", slot_id=slot.id, calendar_slug=cal_slug)
- parts = [f'']
-
- # Days
- parts.append(
- ''
- '
Days
'
- '
'
- )
- if days and days[0] != "—":
- parts.append('
')
- for d in days:
- parts.append(f'{escape(d)} ')
- parts.append('
')
+ # Days pills
+ if days and days[0] != "\u2014":
+ days_inner = "".join(
+ sexp('(span :class "px-2 py-0.5 rounded-full text-xs bg-slate-200" d)', d=d) for d in days
+ )
+ days_html = sexp('(div :class "flex flex-wrap gap-1" (raw! di))', di=days_inner)
else:
- parts.append('
No days ')
- parts.append('
')
+ days_html = sexp('(span :class "text-xs text-slate-400" "No days")')
- # Flexible
- parts.append(
- ''
- '
Flexible
'
- f'
{"yes" if flexible else "no"}
'
- )
+ sid = str(slot.id)
- # Time & Cost
- parts.append(
- ''
- '
'
- '
Time
'
- f'
{time_start} — {time_end}
'
- '
'
- '
Cost
'
- f'
{cost_str}
'
+ result = sexp(
+ '(section :id (str "slot-" sid) :class lc'
+ ' (div :class "flex flex-col"'
+ ' (div :class "text-xs font-semibold uppercase tracking-wide text-stone-500" "Days")'
+ ' (div :class "mt-1" (raw! dh)))'
+ ' (div :class "flex flex-col"'
+ ' (div :class "text-xs font-semibold uppercase tracking-wide text-stone-500" "Flexible")'
+ ' (div :class "mt-1" fl))'
+ ' (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" tm))'
+ ' (div :class "flex flex-col"'
+ ' (div :class "text-xs font-semibold uppercase tracking-wide text-stone-500" "Cost")'
+ ' (div :class "mt-1" cs)))'
+ ' (button :type "button" :class pa :hx-get eu'
+ ' :hx-target (str "#slot-" sid) :hx-swap "outerHTML" "Edit"))',
+ sid=sid, lc=list_container, dh=days_html,
+ fl="yes" if flexible else "no",
+ tm=f"{time_start} \u2014 {time_end}", cs=cost_str,
+ pa=pre_action, eu=edit_url,
)
- # Edit button
- parts.append(
- f'Edit '
- )
- parts.append(' ')
-
if oob:
- parts.append(
- f''
- f'{escape(desc)}
'
+ result += sexp(
+ '(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"'
+ ' d)',
+ d=desc,
)
- return "".join(parts)
+ return result
# ---------------------------------------------------------------------------
@@ -2941,74 +2979,75 @@ def render_slots_table(slots, calendar) -> str:
hx_select = getattr(g, "hx_select_search", "#main-panel")
cal_slug = getattr(calendar, "slug", "")
- parts = [f'']
- parts.append(
- ''
- 'Name '
- 'Flexible '
- 'Days '
- 'Time '
- 'Cost '
- 'Actions '
- ' '
- )
-
+ rows_html = ""
if slots:
for s in slots:
slot_href = url_for("calendars.calendar.slots.slot.get", calendar_slug=cal_slug, slot_id=s.id)
del_url = url_for("calendars.calendar.slots.slot.slot_delete", calendar_slug=cal_slug, slot_id=s.id)
desc = getattr(s, "description", "") or ""
- desc_html = f'{escape(desc)}
' if desc else '
'
- days_display = getattr(s, "days_display", "—")
+ days_display = getattr(s, "days_display", "\u2014")
day_list = days_display.split(", ")
- if day_list and day_list[0] != "—":
- days_html = '' + "".join(
- f'{escape(d)} ' for d in day_list
- ) + '
'
+ if day_list and day_list[0] != "\u2014":
+ days_inner = "".join(
+ sexp('(span :class "px-2 py-0.5 rounded-full text-xs bg-slate-200" d)', d=d) for d in day_list
+ )
+ days_html = sexp('(div :class "flex flex-wrap gap-1" (raw! di))', di=days_inner)
else:
- days_html = 'No days '
+ days_html = sexp('(span :class "text-xs text-slate-400" "No days")')
time_start = s.time_start.strftime("%H:%M") if s.time_start else ""
time_end = s.time_end.strftime("%H:%M") if s.time_end else ""
cost = getattr(s, "cost", None)
cost_str = f"{cost:.2f}" if cost is not None else ""
- parts.append(
- f''
- f'{desc_html} '
- f'{"yes" if s.flexible else "no"} '
- f'{days_html} '
- f'{time_start} - {time_end} '
- f'{cost_str} '
- f''
- f' '
+ rows_html += sexp(
+ '(tr :class tc'
+ ' (td :class "p-2 align-top w-1/6"'
+ ' (div :class "font-medium"'
+ ' (a :href sh :class pc :hx-get sh :hx-target "#main-panel"'
+ ' :hx-select hs :hx-swap "outerHTML" :hx-push-url "true" sn))'
+ ' (p :class "text-stone-500 whitespace-pre-line break-all w-full" ds))'
+ ' (td :class "p-2 align-top w-1/6" fl)'
+ ' (td :class "p-2 align-top w-1/6" (raw! dh))'
+ ' (td :class "p-2 align-top w-1/6" tm)'
+ ' (td :class "p-2 align-top w-1/6" cs)'
+ ' (td :class "p-2 align-top w-1/6"'
+ ' (button :class ab :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 du :hx-target "#slots-table" :hx-select "#slots-table"'
+ ' :hx-swap "outerHTML" :hx-headers hd :hx-trigger "confirmed"'
+ ' (i :class "fa-solid fa-trash"))))',
+ tc=tr_cls, sh=slot_href, pc=pill_cls, hs=hx_select,
+ sn=s.name, ds=desc, fl="yes" if s.flexible else "no",
+ dh=days_html, tm=f"{time_start} - {time_end}", cs=cost_str,
+ ab=action_btn, du=del_url, hd=f'{{"X-CSRFToken": "{csrf}"}}',
)
else:
- parts.append('No slots yet. ')
+ rows_html = sexp('(tr (td :colspan "5" :class "p-3 text-stone-500" "No slots yet."))')
- parts.append('
')
-
- # Add button
add_url = url_for("calendars.calendar.slots.add_form", calendar_slug=cal_slug)
- parts.append(
- f''
- f''
- '+ Add slot
'
+
+ return sexp(
+ '(section :id "slots-table" :class lc'
+ ' (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! rh)))'
+ ' (div :id "slot-add-container" :class "mt-4"'
+ ' (button :type "button" :class pa'
+ ' :hx-get au :hx-target "#slot-add-container" :hx-swap "innerHTML"'
+ ' "+ Add slot")))',
+ lc=list_container, rh=rows_html, pa=pre_action, au=add_url,
)
- parts.append(' ')
- return "".join(parts)
# ---------------------------------------------------------------------------
@@ -3024,10 +3063,10 @@ def render_ticket_type_main_panel(ticket_type, entry, calendar, day, month, year
pre_action = getattr(styles, "pre_action_button", "") if hasattr(styles, "pre_action_button") else styles.get("pre_action_button", "")
cal_slug = getattr(calendar, "slug", "")
- name = escape(getattr(ticket_type, "name", ""))
cost = getattr(ticket_type, "cost", None)
- cost_str = f"£{cost:.2f}" if cost is not None else "£0.00"
+ cost_str = f"\u00a3{cost:.2f}" if cost is not None else "\u00a30.00"
count = getattr(ticket_type, "count", 0)
+ tid = str(ticket_type.id)
edit_url = url_for(
"calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.get_edit",
@@ -3035,21 +3074,25 @@ def render_ticket_type_main_panel(ticket_type, entry, calendar, day, month, year
year=year, month=month, day=day, entry_id=entry.id,
)
- return (
- f''
- ''
- '
'
- '
'
- '
Cost
'
- f'
{cost_str}
'
- '
'
- f'Edit '
- ' '
+ def _col(label, val):
+ return sexp(
+ '(div :class "flex flex-col"'
+ ' (div :class "text-xs font-semibold uppercase tracking-wide text-stone-500" l)'
+ ' (div :class "mt-1" v))',
+ l=label, v=val,
+ )
+
+ return sexp(
+ '(section :id (str "ticket-" tid) :class lc'
+ ' (div :class "grid grid-cols-1 sm:grid-cols-3 gap-4 text-sm"'
+ ' (raw! c1) (raw! c2) (raw! c3))'
+ ' (button :type "button" :class pa :hx-get eu'
+ ' :hx-target (str "#ticket-" tid) :hx-swap "outerHTML" "Edit"))',
+ tid=tid, lc=list_container,
+ c1=_col("Name", ticket_type.name),
+ c2=_col("Cost", cost_str),
+ c3=_col("Count", str(count)),
+ pa=pre_action, eu=edit_url,
)
@@ -3072,16 +3115,7 @@ def render_ticket_types_table(ticket_types, entry, calendar, day, month, year) -
cal_slug = getattr(calendar, "slug", "")
eid = entry.id
- parts = [f'']
- parts.append(
- ''
- 'Name '
- 'Cost '
- 'Count '
- 'Actions '
- ' '
- )
-
+ rows_html = ""
if ticket_types:
for tt in ticket_types:
tt_href = url_for(
@@ -3095,44 +3129,51 @@ def render_ticket_types_table(ticket_types, entry, calendar, day, month, year) -
entry_id=eid, ticket_type_id=tt.id,
)
cost = getattr(tt, "cost", None)
- cost_str = f"£{cost:.2f}" if cost is not None else "£0.00"
+ cost_str = f"\u00a3{cost:.2f}" if cost is not None else "\u00a30.00"
- parts.append(
- f''
- f' '
- f'{cost_str} '
- f'{tt.count} '
- f''
- f' '
+ rows_html += sexp(
+ '(tr :class tc'
+ ' (td :class "p-2 align-top w-1/3"'
+ ' (div :class "font-medium"'
+ ' (a :href th :class pc :hx-get th :hx-target "#main-panel"'
+ ' :hx-select hs :hx-swap "outerHTML" :hx-push-url "true" tn)))'
+ ' (td :class "p-2 align-top w-1/4" cs)'
+ ' (td :class "p-2 align-top w-1/4" cnt)'
+ ' (td :class "p-2 align-top w-1/6"'
+ ' (button :class ab :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 du :hx-target "#tickets-table" :hx-select "#tickets-table"'
+ ' :hx-swap "outerHTML" :hx-headers hd :hx-trigger "confirmed"'
+ ' (i :class "fa-solid fa-trash"))))',
+ tc=tr_cls, th=tt_href, pc=pill_cls, hs=hx_select,
+ tn=tt.name, cs=cost_str, cnt=str(tt.count),
+ ab=action_btn, du=del_url, hd=f'{{"X-CSRFToken": "{csrf}"}}',
)
else:
- parts.append('No ticket types yet. ')
+ rows_html = sexp('(tr (td :colspan "4" :class "p-3 text-stone-500" "No ticket types yet."))')
- parts.append('
')
-
- # Add button
add_url = url_for(
"calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.add_form",
calendar_slug=cal_slug, entry_id=eid, year=year, month=month, day=day,
)
- parts.append(
- f''
- f''
- ' Add ticket type
'
+
+ return sexp(
+ '(section :id "tickets-table" :class lc'
+ ' (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! rh)))'
+ ' (div :id "ticket-add-container" :class "mt-4"'
+ ' (button :class ab :hx-get au :hx-target "#ticket-add-container" :hx-swap "innerHTML"'
+ ' (i :class "fa fa-plus") " Add ticket type")))',
+ lc=list_container, rh=rows_html, ab=action_btn, au=add_url,
)
- parts.append(' ')
- return "".join(parts)
# ---------------------------------------------------------------------------
@@ -3143,42 +3184,44 @@ def render_buy_result(entry, created_tickets, remaining, cart_count) -> str:
"""Render buy result card with created tickets + OOB cart icon."""
from quart import url_for
- # OOB cart icon
- html = _cart_icon_oob(cart_count)
+ cart_html = _cart_icon_oob(cart_count)
count = len(created_tickets)
suffix = "s" if count != 1 else ""
- parts = [f'']
- parts.append(
- '
'
- ' '
- f'{count} ticket{suffix} reserved
'
- )
- parts.append('
')
+ tickets_html = ""
for ticket in created_tickets:
href = url_for("tickets.ticket_detail", code=ticket.code)
- parts.append(
- f'
'
- ''
- ' '
- f'{ticket.code[:12]}...
'
- 'View ticket '
+ tickets_html += sexp(
+ '(a :href h :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" cs))'
+ ' (span :class "text-xs text-emerald-600 font-medium" "View ticket"))',
+ h=href, cs=ticket.code[:12] + "...",
)
- parts.append('
')
+ remaining_html = ""
if remaining is not None:
r_suffix = "s" if remaining != 1 else ""
- parts.append(f'
{remaining} ticket{r_suffix} remaining
')
+ remaining_html = sexp('(p :class "text-xs text-stone-500" r)',
+ r=f"{remaining} ticket{r_suffix} remaining")
my_href = url_for("tickets.my_tickets")
- parts.append(
- '
'
+
+ return cart_html + sexp(
+ '(div :id (str "ticket-buy-" eid) :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" cl))'
+ ' (div :class "space-y-2 mb-4" (raw! th))'
+ ' (raw! rh)'
+ ' (div :class "mt-3 flex gap-2"'
+ ' (a :href mh :class "text-sm text-emerald-700 hover:text-emerald-900 underline"'
+ ' "View all my tickets")))',
+ eid=str(entry.id), cl=f"{count} ticket{suffix} reserved",
+ th=tickets_html, rh=remaining_html, mh=my_href,
)
- parts.append('
')
- return html + "".join(parts)
# ---------------------------------------------------------------------------
@@ -3193,6 +3236,7 @@ def render_buy_form(entry, ticket_remaining, ticket_sold_count,
csrf = generate_csrf_token()
eid = entry.id
+ eid_s = str(eid)
tp = getattr(entry, "ticket_price", None)
state = getattr(entry, "state", "")
ticket_types = getattr(entry, "ticket_types", None) or []
@@ -3200,106 +3244,121 @@ def render_buy_form(entry, ticket_remaining, ticket_sold_count,
if tp is None:
return ""
- if tp is not None and state != "confirmed":
- return (
- f''
- ' '
- 'Tickets available once this event is confirmed.
'
+ if state != "confirmed":
+ return sexp(
+ '(div :id (str "ticket-buy-" eid) :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.")',
+ eid=eid_s,
)
adjust_url = url_for("tickets.adjust_quantity")
target = f"#ticket-buy-{eid}"
- parts = [f'']
- parts.append(
- '
'
- ' Tickets '
- )
-
# Info line
- info_parts = []
+ info_html = ""
+ info_items = ""
if ticket_sold_count:
- info_parts.append(f'
{ticket_sold_count} sold ')
+ info_items += sexp('(span (str sc " sold"))', sc=str(ticket_sold_count))
if ticket_remaining is not None:
- info_parts.append(f'
{ticket_remaining} remaining ')
+ info_items += sexp('(span (str tr " remaining"))', tr=str(ticket_remaining))
if user_ticket_count:
- info_parts.append(
- '
'
- f' {user_ticket_count} in basket '
+ info_items += sexp(
+ '(span :class "text-emerald-600 font-medium"'
+ ' (i :class "fa fa-shopping-cart text-[0.6rem]" :aria-hidden "true")'
+ ' (str " " uc " in basket"))',
+ uc=str(user_ticket_count),
)
- if info_parts:
- parts.append(f'
{"".join(info_parts)}
')
+ if info_items:
+ info_html = sexp('(div :class "flex items-center gap-3 mb-3 text-xs text-stone-500" (raw! ii))', ii=info_items)
active_types = [tt for tt in ticket_types if getattr(tt, "deleted_at", None) is None]
+ body_html = ""
if active_types:
- # Multiple ticket types
- parts.append('
')
+ type_items = ""
for tt in active_types:
type_count = user_ticket_counts_by_type.get(tt.id, 0) if user_ticket_counts_by_type else 0
- cost_str = f"£{tt.cost:.2f}" if tt.cost is not None else "£0.00"
-
- parts.append(
- '
'
- f'
{escape(tt.name)}
'
- f'
{cost_str}
'
+ cost_str = f"\u00a3{tt.cost:.2f}" if tt.cost is not None else "\u00a30.00"
+ type_items += sexp(
+ '(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" tn)'
+ ' (div :class "text-xs text-stone-500" cs))'
+ ' (raw! ac))',
+ tn=tt.name, cs=cost_str,
+ ac=_ticket_adjust_controls(csrf, adjust_url, target, eid, type_count, ticket_type_id=tt.id),
)
- parts.append(_ticket_adjust_controls(csrf, adjust_url, target, eid, type_count, ticket_type_id=tt.id))
- parts.append('
')
- parts.append('
')
+ body_html = sexp('(div :class "space-y-2" (raw! ti))', ti=type_items)
else:
- # Simple ticket
- parts.append(
- '
'
- f'£{tp:.2f} '
- 'per ticket
'
- )
qty = user_ticket_count or 0
- parts.append(_ticket_adjust_controls(csrf, adjust_url, target, eid, qty))
+ body_html = sexp(
+ '(<> (div :class "flex items-center justify-between mb-4"'
+ ' (div (span :class "font-medium text-green-600" ps)'
+ ' (span :class "text-sm text-stone-500 ml-2" "per ticket")))'
+ ' (raw! ac))',
+ ps=f"\u00a3{tp:.2f}",
+ ac=_ticket_adjust_controls(csrf, adjust_url, target, eid, qty),
+ )
- parts.append('
')
- return "".join(parts)
+ return sexp(
+ '(div :id (str "ticket-buy-" eid) :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! ih) (raw! bh))',
+ eid=eid_s, ih=info_html, bh=body_html,
+ )
def _ticket_adjust_controls(csrf, adjust_url, target, entry_id, count, *, ticket_type_id=None):
"""Render +/- ticket controls for buy form."""
from quart import url_for
- tt_hidden = f' ' if ticket_type_id else ""
- my_tickets_href = url_for("tickets.my_tickets")
+ tt_html = sexp('(input :type "hidden" :name "ticket_type_id" :value tti)',
+ tti=str(ticket_type_id)) if ticket_type_id else ""
+ eid_s = str(entry_id)
- if count == 0:
- return (
- f''
- f' '
- f' '
- f'{tt_hidden}'
- ' '
- ''
- ' '
+ def _adj_form(count_val, btn_html, *, extra_cls=""):
+ return sexp(
+ '(form :hx-post au :hx-target tgt :hx-swap "outerHTML" :class fc'
+ ' (input :type "hidden" :name "csrf_token" :value csrf)'
+ ' (input :type "hidden" :name "entry_id" :value eid)'
+ ' (raw! tth)'
+ ' (input :type "hidden" :name "count" :value cv)'
+ ' (raw! bh))',
+ au=adjust_url, tgt=target, fc=extra_cls, csrf=csrf,
+ eid=eid_s, tth=tt_html, cv=str(count_val), bh=btn_html,
)
- return (
- ''
+ if count == 0:
+ return _adj_form(1, sexp(
+ '(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"))',
+ ), extra_cls="flex items-center")
+
+ my_tickets_href = url_for("tickets.my_tickets")
+ minus = _adj_form(count - 1, sexp(
+ '(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"'
+ ' "-")',
+ ))
+ cart_icon = sexp(
+ '(a :class "relative inline-flex items-center justify-center text-emerald-700" :href mth'
+ ' (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" c))))',
+ mth=my_tickets_href, c=str(count),
+ )
+ plus = _adj_form(count + 1, sexp(
+ '(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"'
+ ' "+")',
+ ))
+
+ return sexp(
+ '(div :class "flex items-center gap-2" (raw! m) (raw! ci) (raw! p))',
+ m=minus, ci=cart_icon, p=plus,
)
@@ -3333,19 +3392,20 @@ def _cart_icon_oob(count: int) -> str:
if count == 0:
blog_href = blog_url_fn("/") if blog_url_fn else "/"
- return (
- ''
+ return sexp(
+ '(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 bh :class "h-full w-full font-bold text-5xl flex-shrink-0 flex flex-row items-center gap-1"'
+ ' (img :src lg :class "h-full w-full rounded-full object-cover border border-stone-300 flex-shrink-0"))))',
+ bh=blog_href, lg=logo,
)
cart_href = cart_url_fn("/") if cart_url_fn else "/"
- return (
- ''
+ return sexp(
+ '(div :id "cart-mini" :hx-swap-oob "true"'
+ ' (a :href ch :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"'
+ ' c)))',
+ ch=cart_href, c=str(count),
)