From 495e6589dcb356cfaa934973ccfade22b72840c2 Mon Sep 17 00:00:00 2001 From: giles Date: Sat, 28 Feb 2026 12:39:37 +0000 Subject: [PATCH] Convert all remaining f-string HTML to sexp() in events/sexp_components.py Eliminates every raw HTML string from the events service component file. Converted ~30 functions including ticket admin, entry cards, ticket widgets, view toggles, entry detail, options, buy forms, slots/ticket-type tables, calendar description forms, nav OOB panels, and cart icon. Zero HTML tags remain in events/sexp/sexp_components.py. Co-Authored-By: Claude Opus 4.6 --- events/sexp/sexp_components.py | 1970 ++++++++++++++++---------------- 1 file changed, 1015 insertions(+), 955 deletions(-) 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('') - 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'
') - parts.append(f'

{escape(entry_name)}

') - parts.append(_ticket_state_badge_html(state).replace('px-2 py-0.5 text-xs', 'px-3 py-1 text-sm')) - parts.append('
') - if tt: - parts.append(f'
{escape(tt.name)}
') - parts.append('
') + # 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'
' - f'
' - f'

{code}

' - ) + 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( - '
' - f'
{escape(cal.name)}
' - ) - - 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'
' - f'
{val}
' - f'
{label}
' + 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'') - 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'') - parts.append(f'') - parts.append(f'') - parts.append(f'') - - # Actions - parts.append('') - - parts.append('
{col}
{code[:12]}...
{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('
{escape(tt.name) if tt else "—"}{_ticket_state_badge_html(state)}') - if state in ("confirmed", "reserved"): - checkin_url = url_for("ticket_admin.do_checkin", code=code) - parts.append( - f'
' - 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('
') - 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'
' - f'' - 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'
' - f'' - f'' - 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'
' - f'' - f'' - 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'
' - 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( - '
' - '' - '
Checked In
' + 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( - '
' - '' - '
Cancelled
' + 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( - '' - '' - '' - '' - '' - '' + 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'') - parts.append(f'') - parts.append(f'') - parts.append('') - - parts.append('
CodeTypeStateActions
{code[:12]}...{escape(tt.name) if tt else "—"}{_ticket_state_badge_html(state)}') - if state in ("confirmed", "reserved"): - checkin_url = url_for("ticket_admin.do_checkin", code=code) - parts.append( - f'
' - 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('
') 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
' - f'
{state_badge}
' - '
' - ) + 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'' + + 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( - '
' - '
' - ) + post_links = "" for ep in entry_posts: slug = getattr(ep, "slug", "") - title = escape(getattr(ep, "title", "")) + title = getattr(ep, "title", "") feat = getattr(ep, "feature_image", None) href = blog_url_fn(f"/{slug}/") if blog_url_fn else f"/{slug}/" if feat: - img = f'{title}' + img_html = sexp( + '(img :src f :alt t :class "w-8 h-8 rounded-full object-cover flex-shrink-0")', + f=feat, t=title, + ) else: - img = '
' - parts.append( - f'' - f'{img}
{title}
' + img_html = sexp('(div :class "w-8 h-8 rounded-full bg-stone-200 flex-shrink-0")') + post_links += 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"' + ' (raw! ih) (div :class "flex-1 min-w-0" (div :class "font-medium truncate" t)))', + h=href, ih=img_html, t=title, ) - 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'
' - f'' - 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'
' + 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'
' + 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'
' - f'' - f'
' - f'
' - f'
' - f'
' - '
' - '' - 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'{ep_title}' - 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'
' + 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( - '
' - '' - 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 = [ - '
' - '
' - ] + items = "" for ep in entry_posts: slug = getattr(ep, "slug", "") - title = escape(getattr(ep, "title", "")) + title = getattr(ep, "title", "") feat = getattr(ep, "feature_image", None) - if blog_url_fn: - href = blog_url_fn(f"/{slug}/") - else: - href = f"/{slug}/" - - if feat: - img = f'{title}' - else: - img = '
' - - parts.append( - f'' - f'{img}
{title}
' + href = blog_url_fn(f"/{slug}/") if blog_url_fn else f"/{slug}/" + img_html = (sexp('(img :src f :alt t :class "w-8 h-8 rounded-full object-cover flex-shrink-0")', f=feat, t=title) + if feat else sexp('(div :class "w-8 h-8 rounded-full bg-stone-200 flex-shrink-0")')) + items += sexp( + '(a :href h :class nb (raw! ih) (div :class "flex-1 min-w-0" (div :class "font-medium truncate" t)))', + h=href, nb=nav_btn, ih=img_html, t=title, ) - parts.append('
') - 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 = [ - '
' - '
' - ] + items = "" for entry in confirmed_entries: href = url_for( "calendars.calendar.day.calendar_entries.calendar_entry.get", @@ -2676,18 +2720,22 @@ def render_day_entries_nav_oob(confirmed_entries, calendar, day_date) -> str: year=day_date.year, month=day_date.month, 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'' - '
' - f'
{name}
' - f'
{start}{end}
' - '
' + end = f" \u2013 {entry.end_at.strftime('%H:%M')}" if entry.end_at else "" + items += sexp( + '(a :href h :class nb' + ' (div :class "flex-1 min-w-0"' + ' (div :class "font-medium truncate" n)' + ' (div :class "text-xs text-stone-600 truncate" t)))', + h=href, nb=nav_btn, n=entry.name, t=start + end, ) - parts.append('
') - 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 = [ - '
' - '' - '
' - '
' - ] - + items = "" if has_entries: for entry in associated_entries.entries: entry_path = ( @@ -2734,43 +2767,52 @@ def render_post_nav_entries_oob(associated_entries, calendars, post) -> str: f"entries/{entry.id}/" ) href = events_url(entry_path) - name = escape(entry.name) time_str = entry.start_at.strftime("%b %d, %Y at %H:%M") - end_str = f" – {entry.end_at.strftime('%H:%M')}" if entry.end_at else "" - parts.append( - f'' - '
' - '
' - f'
{name}
' - f'
{time_str}{end_str}
' - '
' + end_str = f" \u2013 {entry.end_at.strftime('%H:%M')}" if entry.end_at else "" + items += sexp( + '(a :href h :class nb' + ' (div :class "w-8 h-8 rounded bg-stone-200 flex-shrink-0")' + ' (div :class "flex-1 min-w-0"' + ' (div :class "font-medium truncate" n)' + ' (div :class "text-xs text-stone-600 truncate" t)))', + h=href, nb=nav_btn, n=entry.name, t=time_str + end_str, ) if calendars: for cal in calendars: - cal_slug = getattr(cal, "slug", "") - cal_name = escape(getattr(cal, "name", "")) - local_href = events_url(f"/{slug}/calendars/{cal_slug}/") - parts.append( - f'' - f'' - f'
{cal_name}
' + cs = getattr(cal, "slug", "") + local_href = events_url(f"/{slug}/calendars/{cs}/") + items += sexp( + '(a :href lh :class nb' + ' (i :class "fa fa-calendar" :aria-hidden "true")' + ' (div cn))', + lh=local_href, nb=nav_btn, cn=cal.name, ) - parts.append('
') - 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 ( - '
' - f'
' - f'' - f'' - '
' - '' - f'' - '
' + 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'' - ) - 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( - '' - '' - '' - '' - '' - '' - '' - '' - ) - + 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'' - f'' - f'' - f'' - 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('') + rows_html = sexp('(tr (td :colspan "5" :class "p-3 text-stone-500" "No slots yet."))') - parts.append('
NameFlexibleDaysTimeCostActions
{desc_html}{"yes" if s.flexible else "no"}{days_html}{time_start} - {time_end}{cost_str}' - f'
No slots yet.
') - - # Add button add_url = url_for("calendars.calendar.slots.add_form", calendar_slug=cal_slug) - parts.append( - f'
' - f'
' + + 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'
' - '
' - '
' - '
Name
' - f'
{name}
' - '
' - '
Cost
' - f'
{cost_str}
' - '
' - '
Count
' - f'
{count}
' - f'' - '
' + 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( - '' - '' - '' - '' - '' - '' - ) - + 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'' - 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('') + rows_html = sexp('(tr (td :colspan "4" :class "p-3 text-stone-500" "No ticket types yet."))') - parts.append('
NameCostCountActions
{cost_str}{tt.count}' - f'
No ticket types yet.
') - - # 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'
' + + 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 ( - '
' - f'
' - f'' - f'' - f'{tt_hidden}' - f'' - '
' - f'' - '' - '' - '' - f'{count}' - '' - f'
' - f'' - f'' - f'{tt_hidden}' - f'' - '
' - '
' + 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 ( - '
' - '
' - f'' - f'' - '
' + 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), )