Files
rose-ash/events/sexp_components.py
giles d53b9648a9
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m15s
Phase 6: Replace render_template() with s-expression rendering in all GET routes
Migrate ~52 GET route handlers across all 7 services from Jinja
render_template() to s-expression component rendering. Each service
gets a sexp_components.py with page/oob/cards render functions.

- Add per-service sexp_components.py (account, blog, cart, events,
  federation, market, orders) with full page, OOB, and pagination
  card rendering
- Add shared/sexp/helpers.py with call_url, root_header_html,
  full_page, oob_page utilities
- Update all GET routes to use get_template_context() + render fns
- Fix get_template_context() to inject Jinja globals (URL helpers)
- Add qs_filter to base_context for sexp filter URL building
- Mount sexp_components.py in docker-compose.dev.yml for all services
- Import sexp_components in app.py for Hypercorn --reload watching
- Fix route_prefix import (shared.utils not shared.infrastructure.urls)
- Fix federation choose-username missing actor in context
- Fix market page_markets missing post in context

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 23:19:33 +00:00

1873 lines
81 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Events service s-expression page components.
Renders all events, page summary, calendars, calendar month, day, day admin,
calendar admin, tickets, ticket admin, markets, and payments pages.
Called from route handlers in place of ``render_template()``.
"""
from __future__ import annotations
from typing import Any
from markupsafe import escape
from shared.sexp.jinja_bridge import sexp
from shared.sexp.helpers import (
call_url, get_asset_url, root_header_html,
search_mobile_html, search_desktop_html,
full_page, oob_page,
)
# ---------------------------------------------------------------------------
# OOB header helper (same pattern as market)
# ---------------------------------------------------------------------------
def _oob_header_html(parent_id: str, child_id: str, row_html: str) -> str:
"""Wrap a header row in OOB div with child placeholder."""
return (
f'<div id="{parent_id}" hx-swap-oob="outerHTML" class="w-full">'
f'<div class="w-full">{row_html}'
f'<div id="{child_id}"></div></div></div>'
)
# ---------------------------------------------------------------------------
# Post header helpers (mirrors events/templates/_types/post/header/_header.html)
# ---------------------------------------------------------------------------
def _post_header_html(ctx: dict, *, oob: bool = False) -> str:
"""Build the post-level header row."""
post = ctx.get("post") or {}
slug = post.get("slug", "")
title = (post.get("title") or "")[:160]
feature_image = post.get("feature_image")
label_parts = []
if feature_image:
label_parts.append(
f'<img src="{feature_image}" class="h-8 w-8 rounded-full object-cover border border-stone-300 flex-shrink-0">'
)
label_parts.append(f"<span>{escape(title)}</span>")
label_html = "".join(label_parts)
nav_parts = []
page_cart_count = ctx.get("page_cart_count", 0)
if page_cart_count and page_cart_count > 0:
cart_href = call_url(ctx, "cart_url", f"/{slug}/")
nav_parts.append(
f'<a href="{cart_href}" class="relative inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-full'
f' border border-emerald-300 bg-emerald-50 text-emerald-800 hover:bg-emerald-100 transition">'
f'<i class="fa fa-shopping-cart" aria-hidden="true"></i>'
f'<span>{page_cart_count}</span></a>'
)
# Post nav: calendar links + admin
nav_parts.append(_post_nav_html(ctx))
nav_html = "".join(nav_parts)
link_href = call_url(ctx, "blog_url", f"/{slug}/")
return sexp(
'(~menu-row :id "post-row" :level 1'
' :link-href lh :link-label-html llh'
' :nav-html nh :child-id "post-header-child" :oob oob)',
lh=link_href,
llh=label_html,
nh=nav_html,
oob=oob,
)
def _post_nav_html(ctx: dict) -> str:
"""Post desktop nav: calendar links + admin gear."""
from quart import url_for
calendars = ctx.get("calendars") or []
rights = ctx.get("rights") or {}
is_admin = getattr(rights, "admin", False) if hasattr(rights, "admin") else rights.get("admin", False)
hx_select = ctx.get("hx_select_search", "#main-panel")
select_colours = ctx.get("select_colours", "")
post = ctx.get("post") or {}
slug = post.get("slug", "")
parts = []
for cal in calendars:
cal_slug = getattr(cal, "slug", "") if hasattr(cal, "slug") else cal.get("slug", "")
cal_name = getattr(cal, "name", "") if hasattr(cal, "name") else cal.get("name", "")
href = url_for("calendars.calendar.get", calendar_slug=cal_slug)
parts.append(sexp(
'(~nav-link :href h :icon "fa fa-calendar" :label l :select-colours sc)',
h=href,
l=cal_name,
sc=select_colours,
))
if is_admin:
admin_href = call_url(ctx, "blog_url", f"/{slug}/admin/")
parts.append(
f'<a href="{admin_href}" class="flex items-center gap-2 px-3 py-2 rounded">'
f'<i class="fa fa-cog" aria-hidden="true"></i></a>'
)
return "".join(parts)
# ---------------------------------------------------------------------------
# Post admin header
# ---------------------------------------------------------------------------
def _post_admin_header_html(ctx: dict, *, oob: bool = False) -> str:
"""Build the post-admin-level header row."""
post = ctx.get("post") or {}
slug = post.get("slug", "")
link_href = call_url(ctx, "blog_url", f"/{slug}/admin/")
return sexp(
'(~menu-row :id "post-admin-row" :level 2'
' :link-href lh :link-label "admin" :icon "fa fa-cog"'
' :child-id "post-admin-header-child" :oob oob)',
lh=link_href,
oob=oob,
)
# ---------------------------------------------------------------------------
# Calendars header
# ---------------------------------------------------------------------------
def _calendars_header_html(ctx: dict, *, oob: bool = False) -> str:
"""Build the calendars section header row."""
from quart import url_for
link_href = url_for("calendars.home")
return sexp(
'(~menu-row :id "calendars-row" :level 3'
' :link-href lh :link-label-html llh'
' :child-id "calendars-header-child" :oob oob)',
lh=link_href,
llh='<i class="fa fa-calendar" aria-hidden="true"></i><div>Calendars</div>',
oob=oob,
)
# ---------------------------------------------------------------------------
# Calendar header
# ---------------------------------------------------------------------------
def _calendar_header_html(ctx: dict, *, oob: bool = False) -> str:
"""Build a single calendar's header row."""
from quart import url_for
calendar = ctx.get("calendar")
if not calendar:
return ""
cal_slug = getattr(calendar, "slug", "")
cal_name = getattr(calendar, "name", "")
cal_desc = getattr(calendar, "description", "") or ""
link_href = url_for("calendars.calendar.get", calendar_slug=cal_slug)
label_html = (
'<div class="flex flex-col md:flex-row md:gap-2 items-center min-w-0">'
'<div class="flex flex-row items-center gap-2">'
f'<i class="fa fa-calendar"></i>'
f'<div class="shrink-0">{escape(cal_name)}</div>'
'</div>'
f'<div id="calendar-description-title" class="text-base font-normal break-words whitespace-normal min-w-0 break-all w-full text-center block">{escape(cal_desc)}</div>'
'</div>'
)
# Desktop nav: slots + admin
nav_html = _calendar_nav_html(ctx)
return sexp(
'(~menu-row :id "calendar-row" :level 3'
' :link-href lh :link-label-html llh'
' :nav-html nh :child-id "calendar-header-child" :oob oob)',
lh=link_href,
llh=label_html,
nh=nav_html,
oob=oob,
)
def _calendar_nav_html(ctx: dict) -> str:
"""Calendar desktop nav: Slots + admin link."""
from quart import url_for
calendar = ctx.get("calendar")
if not calendar:
return ""
cal_slug = getattr(calendar, "slug", "")
rights = ctx.get("rights") or {}
is_admin = getattr(rights, "admin", False) if hasattr(rights, "admin") else rights.get("admin", False)
select_colours = ctx.get("select_colours", "")
parts = []
slots_href = url_for("calendars.calendar.slots.get", calendar_slug=cal_slug)
parts.append(sexp(
'(~nav-link :href h :icon "fa fa-clock" :label "Slots" :select-colours sc)',
h=slots_href,
sc=select_colours,
))
if is_admin:
admin_href = url_for("calendars.calendar.admin.admin", calendar_slug=cal_slug)
parts.append(
f'<a href="{admin_href}" class="flex items-center gap-2 px-3 py-2 rounded text-sm">'
f'<i class="fa fa-cog" aria-hidden="true"></i></a>'
)
return "".join(parts)
# ---------------------------------------------------------------------------
# Day header
# ---------------------------------------------------------------------------
def _day_header_html(ctx: dict, *, oob: bool = False) -> str:
"""Build day detail header row."""
from quart import url_for
calendar = ctx.get("calendar")
if not calendar:
return ""
cal_slug = getattr(calendar, "slug", "")
day_date = ctx.get("day_date")
if not day_date:
return ""
link_href = url_for(
"calendars.calendar.day.show_day",
calendar_slug=cal_slug,
year=day_date.year,
month=day_date.month,
day=day_date.day,
)
label_html = (
'<div class="flex gap-1 items-center">'
f'<i class="fa fa-calendar-day"></i>'
f' {escape(day_date.strftime("%A %d %B %Y"))}'
'</div>'
)
nav_html = _day_nav_html(ctx)
return sexp(
'(~menu-row :id "day-row" :level 4'
' :link-href lh :link-label-html llh'
' :nav-html nh :child-id "day-header-child" :oob oob)',
lh=link_href,
llh=label_html,
nh=nav_html,
oob=oob,
)
def _day_nav_html(ctx: dict) -> str:
"""Day desktop nav: confirmed entries scrolling menu + admin link."""
from quart import url_for
calendar = ctx.get("calendar")
if not calendar:
return ""
cal_slug = getattr(calendar, "slug", "")
day_date = ctx.get("day_date")
confirmed_entries = ctx.get("confirmed_entries") or []
rights = ctx.get("rights") or {}
is_admin = getattr(rights, "admin", False) if hasattr(rights, "admin") else rights.get("admin", False)
parts = []
# Confirmed entries nav (scrolling menu)
if confirmed_entries:
parts.append(
'<div class="flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"'
' id="day-entries-nav-wrapper">'
'<div class="flex overflow-x-auto gap-1 scrollbar-thin">'
)
for entry in confirmed_entries:
href = url_for(
"calendars.calendar.day.calendar_entries.calendar_entry.get",
calendar_slug=cal_slug,
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'<a href="{href}" class="flex items-center gap-2 px-3 py-2 hover:bg-stone-100 rounded transition text-sm border sm:whitespace-nowrap sm:flex-shrink-0">'
f'<div class="flex-1 min-w-0">'
f'<div class="font-medium truncate">{name}</div>'
f'<div class="text-xs text-stone-600 truncate">{start}{end}</div>'
f'</div></a>'
)
parts.append('</div></div>')
if is_admin and day_date:
admin_href = url_for(
"calendars.calendar.day.admin.admin",
calendar_slug=cal_slug,
year=day_date.year,
month=day_date.month,
day=day_date.day,
)
parts.append(
f'<a href="{admin_href}" class="flex items-center gap-2 px-3 py-2 rounded text-sm">'
f'<i class="fa fa-cog" aria-hidden="true"></i></a>'
)
return "".join(parts)
# ---------------------------------------------------------------------------
# Day admin header
# ---------------------------------------------------------------------------
def _day_admin_header_html(ctx: dict, *, oob: bool = False) -> str:
"""Build day admin header row."""
from quart import url_for
calendar = ctx.get("calendar")
if not calendar:
return ""
cal_slug = getattr(calendar, "slug", "")
day_date = ctx.get("day_date")
if not day_date:
return ""
link_href = url_for(
"calendars.calendar.day.admin.admin",
calendar_slug=cal_slug,
year=day_date.year,
month=day_date.month,
day=day_date.day,
)
return sexp(
'(~menu-row :id "day-admin-row" :level 5'
' :link-href lh :link-label "admin" :icon "fa fa-cog"'
' :child-id "day-admin-header-child" :oob oob)',
lh=link_href,
oob=oob,
)
# ---------------------------------------------------------------------------
# Calendar admin header
# ---------------------------------------------------------------------------
def _calendar_admin_header_html(ctx: dict, *, oob: bool = False) -> str:
"""Build calendar admin header row."""
return sexp(
'(~menu-row :id "calendar-admin-row" :level 4'
' :link-label "admin" :icon "fa fa-cog"'
' :child-id "calendar-admin-header-child" :oob oob)',
oob=oob,
)
# ---------------------------------------------------------------------------
# Markets header
# ---------------------------------------------------------------------------
def _markets_header_html(ctx: dict, *, oob: bool = False) -> str:
"""Build the markets section header row."""
from quart import url_for
link_href = url_for("markets.home")
return sexp(
'(~menu-row :id "markets-row" :level 3'
' :link-href lh :link-label-html llh'
' :child-id "markets-header-child" :oob oob)',
lh=link_href,
llh='<i class="fa fa-shopping-bag" aria-hidden="true"></i><div>Markets</div>',
oob=oob,
)
# ---------------------------------------------------------------------------
# Payments header
# ---------------------------------------------------------------------------
def _payments_header_html(ctx: dict, *, oob: bool = False) -> str:
"""Build the payments section header row."""
from quart import url_for
link_href = url_for("payments.home")
return sexp(
'(~menu-row :id "payments-row" :level 3'
' :link-href lh :link-label-html llh'
' :child-id "payments-header-child" :oob oob)',
lh=link_href,
llh='<i class="fa fa-credit-card" aria-hidden="true"></i><div>Payments</div>',
oob=oob,
)
# ---------------------------------------------------------------------------
# Calendars main panel
# ---------------------------------------------------------------------------
def _calendars_main_panel_html(ctx: dict) -> str:
"""Render the calendars list + create form panel."""
from quart import url_for
rights = ctx.get("rights") or {}
is_admin = getattr(rights, "admin", False) if hasattr(rights, "admin") else rights.get("admin", False)
has_access = ctx.get("has_access")
can_create = has_access("calendars.create_calendar") if callable(has_access) else is_admin
csrf_token = ctx.get("csrf_token")
csrf = csrf_token() if callable(csrf_token) else (csrf_token or "")
calendars = ctx.get("calendars") or []
hx_select = ctx.get("hx_select_search", "#main-panel")
parts = ['<section class="p-4">']
if can_create:
create_url = url_for("calendars.create_calendar")
parts.append(
'<div id="cal-create-errors" class="mt-2 text-sm text-red-600"></div>'
f'<form class="mt-4 flex gap-2 items-end" hx-post="{create_url}" '
'hx-target="#calendars-list" hx-select="#calendars-list" hx-swap="outerHTML" '
"""hx-on::before-request="document.querySelector('#cal-create-errors').textContent='';" """
"""hx-on::response-error="document.querySelector('#cal-create-errors').innerHTML = event.detail.xhr.responseText;">"""
f'<input type="hidden" name="csrf_token" value="{csrf}">'
'<div class="flex-1"><label class="block text-sm text-gray-600">Name</label>'
'<input name="name" type="text" required class="w-full border rounded px-3 py-2" '
'placeholder="e.g. Events, Gigs, Meetings" /></div>'
'<button type="submit" class="border rounded px-3 py-2">Add calendar</button></form>'
)
parts.append('<div id="calendars-list" class="mt-6">')
parts.append(_calendars_list_html(ctx, calendars))
parts.append('</div></section>')
return "".join(parts)
def _calendars_list_html(ctx: dict, calendars: list) -> str:
"""Render the calendars list items."""
from quart import url_for
from shared.utils import route_prefix
hx_select = ctx.get("hx_select_search", "#main-panel")
csrf_token = ctx.get("csrf_token")
csrf = csrf_token() if callable(csrf_token) else (csrf_token or "")
prefix = route_prefix()
if not calendars:
return '<p class="text-gray-500 mt-4">No calendars yet. Create one above.</p>'
parts = []
for cal in calendars:
cal_slug = getattr(cal, "slug", "")
cal_name = getattr(cal, "name", "")
href = prefix + url_for("calendars.calendar.get", calendar_slug=cal_slug)
del_url = url_for("calendars.calendar.delete", calendar_slug=cal_slug)
parts.append(
f'<div class="mt-6 border rounded-lg p-4"><div class="flex items-center justify-between gap-3">'
f'<a class="flex items-baseline gap-3" href="{href}" '
f'hx-get="{href}" hx-target="#main-panel" hx-select="{hx_select}" hx-swap="outerHTML" hx-push-url="true">'
f'<h3 class="font-semibold">{escape(cal_name)}</h3>'
f'<h4 class="text-gray-500">/{escape(cal_slug)}/</h4></a>'
f'<button class="text-sm border rounded px-3 py-1 hover:bg-red-50 hover:border-red-400" '
f'data-confirm data-confirm-title="Delete calendar?" '
f'data-confirm-text="Entries will be hidden (soft delete)" '
f'data-confirm-icon="warning" data-confirm-confirm-text="Yes, delete it" '
f'data-confirm-cancel-text="Cancel" data-confirm-event="confirmed" '
f'hx-delete="{del_url}" hx-trigger="confirmed" '
f'hx-target="#calendars-list" hx-select="#calendars-list" hx-swap="outerHTML" '
f"""hx-headers='{{"X-CSRFToken":"{csrf}"}}'>"""
f'<i class="fa-solid fa-trash"></i></button></div></div>'
)
return "".join(parts)
# ---------------------------------------------------------------------------
# Calendar month grid
# ---------------------------------------------------------------------------
def _calendar_main_panel_html(ctx: dict) -> str:
"""Render the calendar month grid."""
from quart import url_for
from quart import session as qsession
calendar = ctx.get("calendar")
if not calendar:
return ""
cal_slug = getattr(calendar, "slug", "")
hx_select = ctx.get("hx_select_search", "#main-panel")
styles = ctx.get("styles") or {}
pill_cls = getattr(styles, "pill", "") if hasattr(styles, "pill") else styles.get("pill", "")
year = ctx.get("year", 2024)
month = ctx.get("month", 1)
month_name = ctx.get("month_name", "")
weekday_names = ctx.get("weekday_names", [])
weeks = ctx.get("weeks", [])
prev_month = ctx.get("prev_month", 1)
prev_month_year = ctx.get("prev_month_year", year)
next_month = ctx.get("next_month", 1)
next_month_year = ctx.get("next_month_year", year)
prev_year = ctx.get("prev_year", year - 1)
next_year = ctx.get("next_year", year + 1)
month_entries = ctx.get("month_entries") or []
user = ctx.get("user")
qs = qsession if "qsession" not in ctx else ctx["qsession"]
def nav_link(y, m):
href = url_for("calendars.calendar.get", calendar_slug=cal_slug, year=y, month=m)
return href
# Month navigation header
parts = ['<section class="bg-orange-100">']
parts.append('<header class="flex items-center justify-center mt-2">')
parts.append('<nav class="flex items-center gap-2 text-2xl">')
# Year/month nav arrows
for label, yr, mn in [
("&laquo;", prev_year, month),
("&lsaquo;", prev_month_year, prev_month),
]:
href = nav_link(yr, mn)
parts.append(
f'<a class="{pill_cls} text-xl" href="{href}" '
f'hx-get="{href}" hx-target="#main-panel" hx-select="{hx_select}" '
f'hx-swap="outerHTML" hx-push-url="true">{label}</a>'
)
parts.append(f'<div class="px-3 font-medium">{escape(month_name)} {year}</div>')
for label, yr, mn in [
("&rsaquo;", next_month_year, next_month),
("&raquo;", next_year, month),
]:
href = nav_link(yr, mn)
parts.append(
f'<a class="{pill_cls} text-xl" href="{href}" '
f'hx-get="{href}" hx-target="#main-panel" hx-select="{hx_select}" '
f'hx-swap="outerHTML" hx-push-url="true">{label}</a>'
)
parts.append('</nav></header>')
# Calendar grid
parts.append('<div class="rounded-2xl border border-stone-200 bg-white/80 p-4">')
# Weekday headers
parts.append('<div class="hidden sm:grid grid-cols-7 text-center text-md font-semibold text-stone-700 mb-2">')
for wd in weekday_names:
parts.append(f'<div class="py-1">{wd}</div>')
parts.append('</div>')
# Weeks grid
parts.append('<div class="grid grid-cols-1 sm:grid-cols-7 gap-px bg-stone-200 rounded-xl overflow-hidden">')
for week in weeks:
for day_cell in week:
in_month = getattr(day_cell, "in_month", True)
is_today = getattr(day_cell, "is_today", False)
day_date = getattr(day_cell, "date", None)
cell_cls = "min-h-20 sm:min-h-24 bg-white px-3 py-2 text-xs"
if not in_month:
cell_cls += " bg-stone-50 text-stone-400"
if is_today:
cell_cls += " ring-2 ring-blue-500 z-10 relative"
parts.append(f'<div class="{cell_cls}">')
parts.append('<div class="flex justify-between items-center"><div class="flex flex-col">')
parts.append(f'<span class="sm:hidden text-[16px] text-stone-500">{day_date.strftime("%a")}</span>')
# Clickable day number
if day_date:
day_href = url_for(
"calendars.calendar.day.show_day",
calendar_slug=cal_slug,
year=day_date.year,
month=day_date.month,
day=day_date.day,
)
parts.append(
f'<a class="{pill_cls}" href="{day_href}" '
f'hx-get="{day_href}" hx-target="#main-panel" hx-select="{hx_select}" '
f'hx-swap="outerHTML" hx-push-url="true">{day_date.day}</a>'
)
parts.append('</div></div>')
# Entries for this day
parts.append('<div class="mt-1 space-y-0.5">')
if day_date:
for e in month_entries:
if e.start_at.date() == day_date:
is_mine = (
(user and e.user_id == user.id)
or (not user and e.session_id == qs.get("calendar_sid"))
)
if e.state == "confirmed":
bg_cls = "bg-emerald-200 text-emerald-900" if is_mine else "bg-emerald-100 text-emerald-800"
else:
bg_cls = "bg-sky-100 text-sky-800" if is_mine else "bg-stone-100 text-stone-700"
state_label = (e.state or "pending").replace("_", " ")
parts.append(
f'<div class="flex items-center justify-between gap-1 text-[11px] rounded px-1 py-0.5 {bg_cls}">'
f'<span class="truncate">{escape(e.name)}</span>'
f'<span class="shrink-0 text-[10px] font-semibold uppercase tracking-tight">{state_label}</span>'
f'</div>'
)
parts.append('</div></div>')
parts.append('</div></div></section>')
return "".join(parts)
# ---------------------------------------------------------------------------
# Day main panel
# ---------------------------------------------------------------------------
def _day_main_panel_html(ctx: dict) -> str:
"""Render the day entries table + add button."""
from quart import url_for
calendar = ctx.get("calendar")
if not calendar:
return ""
cal_slug = getattr(calendar, "slug", "")
day_entries = ctx.get("day_entries") or []
day = ctx.get("day")
month = ctx.get("month")
year = ctx.get("year")
hx_select = ctx.get("hx_select_search", "#main-panel")
styles = ctx.get("styles") or {}
list_container = getattr(styles, "list_container", "") if hasattr(styles, "list_container") else styles.get("list_container", "")
pill_cls = getattr(styles, "pill", "") if hasattr(styles, "pill") else styles.get("pill", "")
tr_cls = getattr(styles, "tr", "") if hasattr(styles, "tr") else styles.get("tr", "")
pre_action = getattr(styles, "pre_action_button", "") if hasattr(styles, "pre_action_button") else styles.get("pre_action_button", "")
parts = [f'<section id="day-entries" class="{list_container}">']
parts.append(
'<table class="w-full text-sm border table-fixed"><thead class="bg-stone-100"><tr>'
'<th class="p-2 text-left w-2/6">Name</th>'
'<th class="text-left p-2 w-1/6">Slot/Time</th>'
'<th class="text-left p-2 w-1/6">State</th>'
'<th class="text-left p-2 w-1/6">Cost</th>'
'<th class="text-left p-2 w-1/6">Tickets</th>'
'<th class="text-left p-2 w-1/6">Actions</th>'
'</tr></thead><tbody>'
)
if day_entries:
for entry in day_entries:
parts.append(_day_row_html(ctx, entry))
else:
parts.append('<tr><td colspan="6" class="p-3 text-stone-500">No entries yet.</td></tr>')
parts.append('</tbody></table>')
# Add entry button
add_url = url_for(
"calendars.calendar.day.calendar_entries.add_form",
calendar_slug=cal_slug,
day=day, month=month, year=year,
)
parts.append(
f'<div id="entry-add-container" class="mt-4">'
f'<button type="button" class="{pre_action}" '
f'hx-get="{add_url}" hx-target="#entry-add-container" hx-swap="innerHTML">'
f'+ Add entry</button></div>'
)
parts.append('</section>')
return "".join(parts)
def _day_row_html(ctx: dict, entry) -> str:
"""Render a single day table row."""
from quart import url_for
calendar = ctx.get("calendar")
cal_slug = getattr(calendar, "slug", "")
day = ctx.get("day")
month = ctx.get("month")
year = ctx.get("year")
hx_select = ctx.get("hx_select_search", "#main-panel")
styles = ctx.get("styles") or {}
pill_cls = getattr(styles, "pill", "") if hasattr(styles, "pill") else styles.get("pill", "")
tr_cls = getattr(styles, "tr", "") if hasattr(styles, "tr") else styles.get("tr", "")
entry_href = url_for(
"calendars.calendar.day.calendar_entries.calendar_entry.get",
calendar_slug=cal_slug, day=day, month=month, year=year, entry_id=entry.id,
)
# Name
name_html = (
f'<td class="p-2 align-top w-2/6"><div class="font-medium">'
f'<a href="{entry_href}" class="{pill_cls}" '
f'hx-get="{entry_href}" hx-target="#main-panel" hx-select="{hx_select}" hx-swap="outerHTML" hx-push-url="true">'
f'{escape(entry.name)}</a></div></td>'
)
# Slot/Time
slot = getattr(entry, "slot", None)
if slot:
slot_href = url_for("calendars.calendar.slots.slot.get", calendar_slug=cal_slug, slot_id=slot.id)
time_start = slot.time_start.strftime("%H:%M") if slot.time_start else ""
time_end = f"{slot.time_end.strftime('%H:%M')}" if slot.time_end else ""
slot_html = (
f'<td class="p-2 align-top w-1/6"><div class="text-xs font-medium">'
f'<a href="{slot_href}" class="{pill_cls}" '
f'hx-get="{slot_href}" hx-target="#main-panel" hx-select="{hx_select}" hx-swap="outerHTML" hx-push-url="true">'
f'{escape(slot.name)}</a>'
f'<span class="text-stone-600 font-normal">({time_start}{time_end})</span>'
f'</div></td>'
)
else:
start = entry.start_at.strftime("%H:%M") if entry.start_at else ""
end = f"{entry.end_at.strftime('%H:%M')}" if entry.end_at else ""
slot_html = f'<td class="p-2 align-top w-1/6"><div class="text-xs text-stone-600">{start}{end}</div></td>'
# State
state = getattr(entry, "state", "pending") or "pending"
state_html = _entry_state_badge_html(state)
state_td = f'<td class="p-2 align-top w-1/6"><div id="entry-state-{entry.id}">{state_html}</div></td>'
# Cost
cost = getattr(entry, "cost", None)
cost_str = f"£{cost:.2f}" if cost is not None else "£0.00"
cost_td = f'<td class="p-2 align-top w-1/6"><span class="font-medium text-green-600">{cost_str}</span></td>'
# Tickets
tp = getattr(entry, "ticket_price", None)
if tp is not None:
tc = getattr(entry, "ticket_count", None)
tc_str = f"{tc} tickets" if tc is not None else "Unlimited"
tickets_td = (
f'<td class="p-2 align-top w-1/6"><div class="text-xs space-y-1">'
f'<div class="font-medium text-green-600"{tp:.2f}</div>'
f'<div class="text-stone-600">{tc_str}</div></div></td>'
)
else:
tickets_td = '<td class="p-2 align-top w-1/6"><span class="text-xs text-stone-400">No tickets</span></td>'
# Actions (entry options) - keep simple, just link to entry
actions_td = f'<td class="p-2 align-top w-1/6"></td>'
return f'<tr class="{tr_cls}">{name_html}{slot_html}{state_td}{cost_td}{tickets_td}{actions_td}</tr>'
def _entry_state_badge_html(state: str) -> str:
"""Render an entry state badge."""
state_classes = {
"confirmed": "bg-emerald-100 text-emerald-800",
"provisional": "bg-amber-100 text-amber-800",
"ordered": "bg-sky-100 text-sky-800",
"pending": "bg-stone-100 text-stone-700",
"declined": "bg-red-100 text-red-800",
}
cls = state_classes.get(state, "bg-stone-100 text-stone-700")
label = state.replace("_", " ").capitalize()
return f'<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium {cls}">{label}</span>'
# ---------------------------------------------------------------------------
# Day admin main panel
# ---------------------------------------------------------------------------
def _day_admin_main_panel_html(ctx: dict) -> str:
"""Render day admin panel (placeholder nav)."""
return '<div class="p-4 text-sm text-stone-500">Admin options</div>'
# ---------------------------------------------------------------------------
# Calendar admin main panel
# ---------------------------------------------------------------------------
def _calendar_admin_main_panel_html(ctx: dict) -> str:
"""Render calendar admin config panel with description editor."""
from quart import url_for
calendar = ctx.get("calendar")
if not calendar:
return ""
csrf_token = ctx.get("csrf_token")
csrf = csrf_token() if callable(csrf_token) else (csrf_token or "")
cal_slug = getattr(calendar, "slug", "")
desc = getattr(calendar, "description", "") or ""
hx_select = ctx.get("hx_select_search", "#main-panel")
desc_edit_url = url_for("calendars.calendar.admin.calendar_description_edit", calendar_slug=cal_slug)
description_html = _calendar_description_display_html(calendar, desc_edit_url)
parts = ['<section class="max-w-3xl mx-auto p-4 space-y-10">']
parts.append('<div><h2 class="text-xl font-semibold">Calendar configuration</h2>')
parts.append('<div id="cal-put-errors" class="mt-2 text-sm text-red-600"></div>')
parts.append(f'<div><label class="block text-sm font-medium text-stone-700">Description</label>')
parts.append(description_html)
parts.append('</div>')
# Hidden form for direct PUT
parts.append(
f'<form id="calendar-form" method="post" hx-target="#main-panel" hx-select="{hx_select}" '
"""hx-on::before-request="document.querySelector('#cal-put-errors').textContent='';" """
"""hx-on::response-error="document.querySelector('#cal-put-errors').innerHTML = event.detail.xhr.responseText;" """
"""hx-on::after-request="if (event.detail.successful) this.reset()" """
'class="hidden space-y-4 mt-4" autocomplete="off">'
f'<input type="hidden" name="csrf_token" value="{csrf}">'
'<div><label class="block text-sm font-medium text-stone-700">Description</label>'
f'<div>{escape(desc)}</div>'
f'<textarea name="description" autocomplete="off" rows="4" class="w-full p-2 border rounded">{escape(desc)}</textarea>'
'</div><div><button class="px-3 py-2 rounded bg-stone-800 text-white">Save</button></div></form>'
)
parts.append('</div><hr class="border-stone-200"></section>')
return "".join(parts)
def _calendar_description_display_html(calendar, edit_url: str) -> str:
"""Render calendar description display with edit button."""
desc = getattr(calendar, "description", "") or ""
if desc:
desc_html = f'<p class="text-stone-700 whitespace-pre-line break-all">{escape(desc)}</p>'
else:
desc_html = '<p class="text-stone-400 italic">No description yet.</p>'
return (
f'<div id="calendar-description">{desc_html}'
f'<button type="button" class="mt-2 text-xs underline" '
f'hx-get="{edit_url}" hx-target="#calendar-description" hx-swap="outerHTML">'
f'<i class="fas fa-edit"></i></button></div>'
)
# ---------------------------------------------------------------------------
# Markets main panel
# ---------------------------------------------------------------------------
def _markets_main_panel_html(ctx: dict) -> str:
"""Render markets list + create form panel."""
from quart import url_for
rights = ctx.get("rights") or {}
is_admin = getattr(rights, "admin", False) if hasattr(rights, "admin") else rights.get("admin", False)
has_access = ctx.get("has_access")
can_create = has_access("markets.create_market") if callable(has_access) else is_admin
csrf_token = ctx.get("csrf_token")
csrf = csrf_token() if callable(csrf_token) else (csrf_token or "")
markets = ctx.get("markets") or []
post = ctx.get("post") or {}
parts = ['<section class="p-4">']
if can_create:
create_url = url_for("markets.create_market")
parts.append(
'<div id="market-create-errors" class="mt-2 text-sm text-red-600"></div>'
f'<form class="mt-4 flex gap-2 items-end" hx-post="{create_url}" '
'hx-target="#markets-list" hx-select="#markets-list" hx-swap="outerHTML" '
"""hx-on::before-request="document.querySelector('#market-create-errors').textContent='';" """
"""hx-on::response-error="document.querySelector('#market-create-errors').innerHTML = event.detail.xhr.responseText;">"""
f'<input type="hidden" name="csrf_token" value="{csrf}">'
'<div class="flex-1"><label class="block text-sm text-gray-600">Name</label>'
'<input name="name" type="text" required class="w-full border rounded px-3 py-2" '
'placeholder="e.g. Farm Shop, Bakery" /></div>'
'<button type="submit" class="border rounded px-3 py-2">Add market</button></form>'
)
parts.append('<div id="markets-list" class="mt-6">')
parts.append(_markets_list_html(ctx, markets))
parts.append('</div></section>')
return "".join(parts)
def _markets_list_html(ctx: dict, markets: list) -> str:
"""Render markets list items."""
from quart import url_for
csrf_token = ctx.get("csrf_token")
csrf = csrf_token() if callable(csrf_token) else (csrf_token or "")
post = ctx.get("post") or {}
slug = post.get("slug", "")
if not markets:
return '<p class="text-gray-500 mt-4">No markets yet. Create one above.</p>'
parts = []
for m in markets:
m_slug = getattr(m, "slug", "") if hasattr(m, "slug") else m.get("slug", "")
m_name = getattr(m, "name", "") if hasattr(m, "name") else m.get("name", "")
market_href = call_url(ctx, "market_url", f"/{slug}/{m_slug}/")
del_url = url_for("markets.delete_market", market_slug=m_slug)
parts.append(
f'<div class="mt-6 border rounded-lg p-4"><div class="flex items-center justify-between gap-3">'
f'<a class="flex items-baseline gap-3" href="{market_href}">'
f'<h3 class="font-semibold">{escape(m_name)}</h3>'
f'<h4 class="text-gray-500">/{escape(m_slug)}/</h4></a>'
f'<button class="text-sm border rounded px-3 py-1 hover:bg-red-50 hover:border-red-400" '
f'data-confirm data-confirm-title="Delete market?" '
f'data-confirm-text="Products will be hidden (soft delete)" '
f'data-confirm-icon="warning" data-confirm-confirm-text="Yes, delete it" '
f'data-confirm-cancel-text="Cancel" data-confirm-event="confirmed" '
f'hx-delete="{del_url}" hx-trigger="confirmed" '
f'hx-target="#markets-list" hx-select="#markets-list" hx-swap="outerHTML" '
f"""hx-headers='{{"X-CSRFToken":"{csrf}"}}'>"""
f'<i class="fa-solid fa-trash"></i></button></div></div>'
)
return "".join(parts)
# ---------------------------------------------------------------------------
# Payments main panel
# ---------------------------------------------------------------------------
def _payments_main_panel_html(ctx: dict) -> str:
"""Render SumUp payment config form."""
from quart import url_for
csrf_token = ctx.get("csrf_token")
csrf = csrf_token() if callable(csrf_token) else (csrf_token or "")
sumup_configured = ctx.get("sumup_configured", False)
merchant_code = ctx.get("sumup_merchant_code", "")
checkout_prefix = ctx.get("sumup_checkout_prefix", "")
update_url = url_for("payments.update_sumup")
placeholder = "--------" if sumup_configured else "sup_sk_..."
key_note = '<p class="text-xs text-stone-400 mt-0.5">Key is set. Leave blank to keep current key.</p>' if sumup_configured else ""
connected = ('<span class="ml-2 text-xs text-green-600">'
'<i class="fa fa-check-circle"></i> Connected</span>') if sumup_configured else ""
return (
'<section class="p-4 max-w-lg mx-auto">'
'<div id="payments-panel" class="space-y-4 p-4 bg-white rounded-lg border border-stone-200">'
'<h3 class="text-lg font-semibold text-stone-800">'
'<i class="fa fa-credit-card text-purple-600 mr-1"></i> SumUp Payment</h3>'
'<p class="text-xs text-stone-400">Configure per-page SumUp credentials. Leave blank to use the global merchant account.</p>'
f'<form hx-put="{update_url}" hx-target="#payments-panel" hx-swap="outerHTML" hx-select="#payments-panel" class="space-y-3">'
f'<input type="hidden" name="csrf_token" value="{csrf}">'
'<div><label class="block text-xs font-medium text-stone-600 mb-1">Merchant Code</label>'
f'<input type="text" name="merchant_code" value="{escape(merchant_code)}" placeholder="e.g. ME4J6100" '
'class="w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500"></div>'
'<div><label class="block text-xs font-medium text-stone-600 mb-1">API Key</label>'
f'<input type="password" name="api_key" value="" placeholder="{placeholder}" '
f'class="w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500">'
f'{key_note}</div>'
'<div><label class="block text-xs font-medium text-stone-600 mb-1">Checkout Reference Prefix</label>'
f'<input type="text" name="checkout_prefix" value="{escape(checkout_prefix)}" placeholder="e.g. ROSE-" '
'class="w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500"></div>'
'<button type="submit" class="px-4 py-1.5 text-sm font-medium text-white bg-purple-600 rounded hover:bg-purple-700 focus:ring-2 focus:ring-purple-500">'
f'Save SumUp Settings</button>{connected}</form></div></section>'
)
# ---------------------------------------------------------------------------
# Ticket state badge helper
# ---------------------------------------------------------------------------
def _ticket_state_badge_html(state: str) -> str:
"""Render a ticket state badge."""
cls_map = {
"confirmed": "bg-emerald-100 text-emerald-800",
"checked_in": "bg-blue-100 text-blue-800",
"reserved": "bg-amber-100 text-amber-800",
"cancelled": "bg-red-100 text-red-800",
}
cls = cls_map.get(state, "bg-stone-100 text-stone-700")
label = (state or "").replace("_", " ").capitalize()
return f'<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium {cls}">{label}</span>'
# ---------------------------------------------------------------------------
# Tickets main panel (my tickets)
# ---------------------------------------------------------------------------
def _tickets_main_panel_html(ctx: dict, tickets: list) -> str:
"""Render my tickets list."""
from quart import url_for
parts = [f'<section id="tickets-list" class="{_list_container(ctx)}">']
parts.append('<h1 class="text-2xl font-bold mb-6">My Tickets</h1>')
if tickets:
parts.append('<div class="space-y-4">')
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", "")
parts.append(
f'<a href="{href}" class="block rounded-xl border border-stone-200 bg-white p-4 hover:shadow-md transition">'
'<div class="flex items-start justify-between gap-4"><div class="flex-1 min-w-0">'
f'<div class="font-semibold text-lg truncate">{escape(entry_name)}</div>'
)
if tt:
parts.append(f'<div class="text-sm text-stone-600 mt-0.5">{escape(tt.name)}</div>')
if entry:
parts.append(
'<div class="text-sm text-stone-500 mt-1">'
f'{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('</div>')
cal = getattr(entry, "calendar", None)
if cal:
parts.append(f'<div class="text-xs text-stone-400 mt-0.5">{escape(cal.name)}</div>')
parts.append('</div><div class="flex flex-col items-end gap-1 flex-shrink-0">')
parts.append(_ticket_state_badge_html(state))
parts.append(f'<span class="text-xs text-stone-400 font-mono">{ticket.code[:8]}...</span>')
parts.append('</div></div></a>')
parts.append('</div>')
else:
parts.append(
'<div class="text-center py-12 text-stone-500">'
'<i class="fa fa-ticket text-4xl mb-4 block" aria-hidden="true"></i>'
'<p class="text-lg">No tickets yet</p>'
'<p class="text-sm mt-1">Tickets will appear here after you purchase them.</p></div>'
)
parts.append('</section>')
return "".join(parts)
# ---------------------------------------------------------------------------
# Ticket detail panel
# ---------------------------------------------------------------------------
def _ticket_detail_panel_html(ctx: dict, ticket) -> str:
"""Render a single ticket detail with QR code."""
from quart import url_for
entry = getattr(ticket, "entry", None)
tt = getattr(ticket, "ticket_type", None)
state = getattr(ticket, "state", "")
code = ticket.code
# 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'<section id="ticket-detail" class="{_list_container(ctx)} max-w-lg mx-auto">']
parts.append(
f'<a href="{back_href}" class="inline-flex items-center gap-1 text-sm text-stone-500 hover:text-stone-700 mb-4">'
'<i class="fa fa-arrow-left" aria-hidden="true"></i> Back to my tickets</a>'
)
parts.append('<div class="rounded-2xl border border-stone-200 bg-white overflow-hidden">')
# Header
parts.append(f'<div class="px-6 py-4 border-b border-stone-100 {header_bg}">')
parts.append(f'<div class="flex items-center justify-between"><h1 class="text-xl font-bold">{escape(entry_name)}</h1>')
parts.append(_ticket_state_badge_html(state).replace('px-2 py-0.5 text-xs', 'px-3 py-1 text-sm'))
parts.append('</div>')
if tt:
parts.append(f'<div class="text-sm text-stone-600 mt-1">{escape(tt.name)}</div>')
parts.append('</div>')
# QR code
parts.append(
f'<div class="px-6 py-8 flex flex-col items-center border-b border-stone-100">'
f'<div id="ticket-qr-{code}" class="bg-white p-4 rounded-lg border border-stone-200"></div>'
f'<p class="text-xs text-stone-400 mt-3 font-mono select-all">{code}</p></div>'
)
# Event details
parts.append('<div class="px-6 py-4 space-y-3">')
if entry:
parts.append(
'<div class="flex items-start gap-3"><i class="fa fa-calendar text-stone-400 mt-0.5" aria-hidden="true"></i>'
f'<div><div class="text-sm font-medium">{entry.start_at.strftime("%A, %B %d, %Y")}</div>'
f'<div class="text-sm text-stone-500">{entry.start_at.strftime("%H:%M")}'
)
if entry.end_at:
parts.append(f' {entry.end_at.strftime("%H:%M")}')
parts.append('</div></div></div>')
cal = getattr(entry, "calendar", None)
if cal:
parts.append(
'<div class="flex items-start gap-3"><i class="fa fa-map-pin text-stone-400 mt-0.5" aria-hidden="true"></i>'
f'<div class="text-sm">{escape(cal.name)}</div></div>'
)
if tt and getattr(tt, "cost", None):
parts.append(
'<div class="flex items-start gap-3"><i class="fa fa-tag text-stone-400 mt-0.5" aria-hidden="true"></i>'
f'<div class="text-sm">{escape(tt.name)} — £{tt.cost:.2f}</div></div>'
)
checked_in_at = getattr(ticket, "checked_in_at", None)
if checked_in_at:
parts.append(
'<div class="flex items-start gap-3"><i class="fa fa-check-circle text-blue-500 mt-0.5" aria-hidden="true"></i>'
f'<div class="text-sm text-blue-700">Checked in: {checked_in_at.strftime("%B %d, %Y at %H:%M")}</div></div>'
)
parts.append('</div></div>')
# QR code script
parts.append(
'<script src="https://cdn.jsdelivr.net/npm/qrcode@1.5.3/build/qrcode.min.js"></script>'
'<script>(function(){'
f"var c=document.getElementById('ticket-qr-{code}');"
"if(c&&typeof QRCode!=='undefined'){"
"var cv=document.createElement('canvas');"
f"QRCode.toCanvas(cv,'{code}',{{width:200,margin:2,color:{{dark:'#1c1917',light:'#ffffff'}}}},function(e){{if(!e)c.appendChild(cv)}});"
'}})();</script>'
)
parts.append('</section>')
return "".join(parts)
# ---------------------------------------------------------------------------
# Ticket admin main panel
# ---------------------------------------------------------------------------
def _ticket_admin_main_panel_html(ctx: dict, tickets: list, stats: dict) -> str:
"""Render ticket admin dashboard."""
from quart import url_for
csrf_token = ctx.get("csrf_token")
csrf = csrf_token() if callable(csrf_token) else (csrf_token or "")
lookup_url = url_for("ticket_admin.lookup")
parts = [f'<section id="ticket-admin" class="{_list_container(ctx)}">']
parts.append('<h1 class="text-2xl font-bold mb-6">Ticket Admin</h1>')
# Stats
parts.append('<div class="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-8">')
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"),
("Checked In", "checked_in", "border-blue-200", "bg-blue-50", "text-blue-700"),
("Reserved", "reserved", "border-amber-200", "bg-amber-50", "text-amber-700"),
]:
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'<div class="rounded-xl border {border} {bg} p-4 text-center">'
f'<div class="text-2xl font-bold {text_cls}">{val}</div>'
f'<div class="text-xs {lbl_cls} uppercase tracking-wide">{label}</div></div>'
)
parts.append('</div>')
# Scanner
parts.append(
'<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"></i>Scan / Look Up Ticket</h2>'
'<div class="flex gap-3 mb-4">'
f'<input type="text" id="ticket-code-input" name="code" placeholder="Enter or scan ticket code..." '
f'class="flex-1 px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" '
f'hx-get="{lookup_url}" hx-trigger="keyup changed delay:300ms" hx-target="#lookup-result" hx-include="this" autofocus />'
'<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"></i></button></div>'
'<div id="lookup-result"><div class="text-sm text-stone-400 text-center py-4">'
'Enter a ticket code to look it up</div></div></div>'
)
# Recent tickets table
parts.append('<div class="rounded-xl border border-stone-200 bg-white overflow-hidden">')
parts.append('<h2 class="text-lg font-semibold px-6 py-4 border-b border-stone-100">Recent Tickets</h2>')
if tickets:
parts.append('<div class="overflow-x-auto"><table class="w-full text-sm"><thead class="bg-stone-50"><tr>')
for col in ["Code", "Event", "Type", "State", "Actions"]:
parts.append(f'<th class="px-4 py-3 text-left font-medium text-stone-600">{col}</th>')
parts.append('</tr></thead><tbody class="divide-y divide-stone-100">')
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'<tr class="hover:bg-stone-50 transition" id="ticket-row-{code}">')
parts.append(f'<td class="px-4 py-3"><span class="font-mono text-xs">{code[:12]}...</span></td>')
parts.append(f'<td class="px-4 py-3"><div class="font-medium">{escape(entry.name) if entry else ""}</div>')
if entry and entry.start_at:
parts.append(f'<div class="text-xs text-stone-500">{entry.start_at.strftime("%d %b %Y, %H:%M")}</div>')
parts.append('</td>')
parts.append(f'<td class="px-4 py-3 text-sm">{escape(tt.name) if tt else ""}</td>')
parts.append(f'<td class="px-4 py-3">{_ticket_state_badge_html(state)}</td>')
# Actions
parts.append('<td class="px-4 py-3">')
if state in ("confirmed", "reserved"):
checkin_url = url_for("ticket_admin.do_checkin", code=code)
parts.append(
f'<form hx-post="{checkin_url}" hx-target="#ticket-row-{code}" hx-swap="outerHTML">'
f'<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"></i>Check in</button></form>'
)
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'<span class="text-xs text-blue-600"><i class="fa fa-check-circle" aria-hidden="true"></i> {t_str}</span>')
parts.append('</td></tr>')
parts.append('</tbody></table></div>')
else:
parts.append('<div class="px-6 py-8 text-center text-stone-500">No tickets yet</div>')
parts.append('</div></section>')
return "".join(parts)
# ---------------------------------------------------------------------------
# All events / page summary entry cards
# ---------------------------------------------------------------------------
def _entry_card_html(entry, page_info: dict, pending_tickets: dict,
ticket_url: str, events_url_fn, *, is_page_scoped: bool = False,
post: dict | None = None) -> str:
"""Render a list card for one event entry."""
pi = page_info.get(getattr(entry, "calendar_container_id", 0), {})
if is_page_scoped and post:
page_slug = pi.get("slug", post.get("slug", ""))
else:
page_slug = pi.get("slug", "")
page_title = pi.get("title")
day_href = ""
if page_slug:
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 = ['<article class="rounded-xl bg-white shadow-sm border border-stone-200 p-4">']
parts.append('<div class="flex flex-col sm:flex-row sm:items-start justify-between gap-3">')
parts.append('<div class="flex-1 min-w-0">')
if entry_href:
parts.append(f'<a href="{entry_href}" class="hover:text-emerald-700">')
parts.append(f'<h2 class="text-lg font-semibold text-stone-900">{escape(entry.name)}</h2>')
if entry_href:
parts.append('</a>')
# Badges
parts.append('<div class="flex flex-wrap items-center gap-1.5 mt-1">')
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'<a href="{page_href}" class="inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 hover:bg-amber-200">'
f'{escape(page_title)}</a>'
)
cal_name = getattr(entry, "calendar_name", "")
if cal_name:
parts.append(f'<span class="inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-sky-100 text-sky-700">{escape(cal_name)}</span>')
parts.append('</div>')
# Time
parts.append('<div class="mt-1 text-sm text-stone-500">')
if day_href and not is_page_scoped:
parts.append(f'<a href="{day_href}" class="hover:text-stone-700">{entry.start_at.strftime("%a %-d %b")}</a> &middot; ')
elif not is_page_scoped:
parts.append(f'{entry.start_at.strftime("%a %-d %b")} &middot; ')
parts.append(entry.start_at.strftime("%H:%M"))
if entry.end_at:
parts.append(f' &ndash; {entry.end_at.strftime("%H:%M")}')
parts.append('</div>')
cost = getattr(entry, "cost", None)
if cost:
parts.append(f'<div class="mt-1 text-sm font-medium text-green-600">&pound;{cost:.2f}</div>')
parts.append('</div>')
# Ticket widget
tp = getattr(entry, "ticket_price", None)
if tp is not None:
qty = pending_tickets.get(entry.id, 0)
parts.append('<div class="shrink-0">')
parts.append(_ticket_widget_html(entry, qty, ticket_url, ctx={}))
parts.append('</div>')
parts.append('</div></article>')
return "".join(parts)
def _entry_card_tile_html(entry, page_info: dict, pending_tickets: dict,
ticket_url: str, events_url_fn, *, is_page_scoped: bool = False,
post: dict | None = None) -> str:
"""Render a tile card for one event entry."""
pi = page_info.get(getattr(entry, "calendar_container_id", 0), {})
if is_page_scoped and post:
page_slug = pi.get("slug", post.get("slug", ""))
else:
page_slug = pi.get("slug", "")
page_title = pi.get("title")
day_href = ""
if page_slug:
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 = ['<article class="rounded-xl bg-white shadow-sm border border-stone-200 overflow-hidden"><div class="p-3">']
if entry_href:
parts.append(f'<a href="{entry_href}" class="hover:text-emerald-700">')
parts.append(f'<h2 class="text-base font-semibold text-stone-900 line-clamp-2">{escape(entry.name)}</h2>')
if entry_href:
parts.append('</a>')
parts.append('<div class="flex flex-wrap items-center gap-1 mt-1">')
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'<a href="{page_href}" class="inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 hover:bg-amber-200">'
f'{escape(page_title)}</a>'
)
cal_name = getattr(entry, "calendar_name", "")
if cal_name:
parts.append(f'<span class="inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-sky-100 text-sky-700">{escape(cal_name)}</span>')
parts.append('</div>')
parts.append('<div class="mt-1 text-xs text-stone-500">')
if day_href:
parts.append(f'<a href="{day_href}" class="hover:text-stone-700">{entry.start_at.strftime("%a %-d %b")}</a>')
else:
parts.append(entry.start_at.strftime("%a %-d %b"))
parts.append(f' &middot; {entry.start_at.strftime("%H:%M")}')
if entry.end_at:
parts.append(f' &ndash; {entry.end_at.strftime("%H:%M")}')
parts.append('</div>')
cost = getattr(entry, "cost", None)
if cost:
parts.append(f'<div class="mt-1 text-sm font-medium text-green-600">&pound;{cost:.2f}</div>')
parts.append('</div>')
tp = getattr(entry, "ticket_price", None)
if tp is not None:
qty = pending_tickets.get(entry.id, 0)
parts.append('<div class="border-t border-stone-100 px-3 py-2">')
parts.append(_ticket_widget_html(entry, qty, ticket_url, ctx={}))
parts.append('</div>')
parts.append('</article>')
return "".join(parts)
def _ticket_widget_html(entry, qty: int, ticket_url: str, *, ctx: dict) -> str:
"""Render the inline +/- ticket widget."""
csrf_token_val = ""
if ctx:
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", "")
except Exception:
pass
eid = entry.id
tp = getattr(entry, "ticket_price", 0) or 0
cart_url_fn = None
parts = [f'<div id="page-ticket-{eid}" class="flex items-center gap-2">']
parts.append(f'<span class="text-green-600 font-medium text-sm">&pound;{tp:.2f}</span>')
if qty == 0:
parts.append(
f'<form action="{ticket_url}" method="post" hx-post="{ticket_url}" '
f'hx-target="#page-ticket-{eid}" hx-swap="outerHTML">'
f'<input type="hidden" name="csrf_token" value="{csrf_token_val}">'
f'<input type="hidden" name="entry_id" value="{eid}">'
'<input type="hidden" name="count" value="1">'
'<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"></i></button></form>'
)
else:
# Minus button
parts.append(
f'<form action="{ticket_url}" method="post" hx-post="{ticket_url}" '
f'hx-target="#page-ticket-{eid}" hx-swap="outerHTML">'
f'<input type="hidden" name="csrf_token" value="{csrf_token_val}">'
f'<input type="hidden" name="entry_id" value="{eid}">'
f'<input type="hidden" name="count" value="{qty - 1}">'
'<button type="submit" class="inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl">-</button></form>'
)
# Cart icon with count
parts.append(
'<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"></i>'
'<span class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none">'
f'<span class="flex items-center justify-center bg-black text-white rounded-full w-4 h-4 text-xs font-bold">{qty}</span>'
'</span></span></span>'
)
# Plus button
parts.append(
f'<form action="{ticket_url}" method="post" hx-post="{ticket_url}" '
f'hx-target="#page-ticket-{eid}" hx-swap="outerHTML">'
f'<input type="hidden" name="csrf_token" value="{csrf_token_val}">'
f'<input type="hidden" name="entry_id" value="{eid}">'
f'<input type="hidden" name="count" value="{qty + 1}">'
'<button type="submit" class="inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl">+</button></form>'
)
parts.append('</div>')
return "".join(parts)
def _entry_cards_html(entries, page_info, pending_tickets, ticket_url,
events_url_fn, view, page, has_more, next_url,
*, is_page_scoped=False, post=None) -> str:
"""Render entry cards (list or tile) with sentinel."""
parts = []
last_date = None
for entry in entries:
if view == "tile":
parts.append(_entry_card_tile_html(
entry, page_info, pending_tickets, ticket_url, events_url_fn,
is_page_scoped=is_page_scoped, post=post,
))
else:
entry_date = entry.start_at.strftime("%A %-d %B %Y")
if entry_date != last_date:
parts.append(
f'<div class="pt-2 pb-1"><h3 class="text-sm font-semibold text-stone-500 uppercase tracking-wide">'
f'{entry_date}</h3></div>'
)
last_date = entry_date
parts.append(_entry_card_html(
entry, page_info, pending_tickets, ticket_url, events_url_fn,
is_page_scoped=is_page_scoped, post=post,
))
if has_more:
parts.append(
f'<div id="sentinel-{page}" class="h-4 opacity-0 pointer-events-none" '
f'hx-get="{next_url}" hx-trigger="intersect once delay:250ms" hx-swap="outerHTML" '
f'role="status" aria-hidden="true">'
f'<div class="text-center text-xs text-stone-400">loading...</div></div>'
)
return "".join(parts)
# ---------------------------------------------------------------------------
# All events / page summary main panels
# ---------------------------------------------------------------------------
_LIST_SVG = '<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16" /></svg>'
_TILE_SVG = '<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" /></svg>'
def _view_toggle_html(ctx: dict, view: str) -> str:
"""Render the list/tile view toggle bar."""
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:
tile_href = tile_href.split("?")[0] + "?view=tile"
else:
tile_href = tile_href + "?view=tile"
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 (
'<div class="hidden md:flex justify-end px-3 pt-3 gap-1">'
f'<a href="{list_href}" hx-get="{list_href}" hx-target="#main-panel" hx-select="{hx_select}" '
f'hx-swap="outerHTML" hx-push-url="true" class="p-1.5 rounded {list_active}" title="List view" '
f"""_="on click js localStorage.removeItem('events_view') end">{_LIST_SVG}</a>"""
f'<a href="{tile_href}" hx-get="{tile_href}" hx-target="#main-panel" hx-select="{hx_select}" '
f'hx-swap="outerHTML" hx-push-url="true" class="p-1.5 rounded {tile_active}" title="Tile view" '
f"""_="on click js localStorage.setItem('events_view','tile') end">{_TILE_SVG}</a></div>"""
)
def _events_main_panel_html(ctx: dict, entries, has_more, pending_tickets, page_info,
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)]
if entries:
cards = _entry_cards_html(
entries, page_info, pending_tickets, ticket_url, events_url_fn,
view, page, has_more, next_url,
is_page_scoped=is_page_scoped, post=post,
)
if view == "tile":
parts.append(f'<div class="max-w-full px-3 py-3 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">{cards}</div>')
else:
parts.append(f'<div class="max-w-full px-3 py-3 space-y-3">{cards}</div>')
else:
parts.append(
'<div class="px-3 py-12 text-center text-stone-400">'
'<i class="fa fa-calendar-xmark text-4xl mb-3" aria-hidden="true"></i>'
'<p class="text-lg">No upcoming events</p></div>'
)
parts.append('<div class="pb-8"></div>')
return "".join(parts)
# ---------------------------------------------------------------------------
# Utility
# ---------------------------------------------------------------------------
def _list_container(ctx: dict) -> str:
styles = ctx.get("styles") or {}
return getattr(styles, "list_container", "") if hasattr(styles, "list_container") else styles.get("list_container", "")
# ===========================================================================
# PUBLIC API
# ===========================================================================
# ---------------------------------------------------------------------------
# All events
# ---------------------------------------------------------------------------
async def render_all_events_page(ctx: dict, entries, has_more, pending_tickets,
page_info, page, view) -> str:
"""Full page: all events listing."""
from quart import url_for
from shared.utils import route_prefix, events_url
prefix = route_prefix()
view_param = f"&view={view}" if view != "list" else ""
ticket_url = url_for("all_events.adjust_ticket")
next_url = prefix + url_for("all_events.entries_fragment", page=page + 1) + (f"?view={view}" if view != "list" else "")
content = _events_main_panel_html(
ctx, entries, has_more, pending_tickets, page_info,
page, view, ticket_url, next_url, events_url,
)
hdr = root_header_html(ctx)
return full_page(ctx, header_rows_html=hdr, content_html=content)
async def render_all_events_oob(ctx: dict, entries, has_more, pending_tickets,
page_info, page, view) -> str:
"""OOB response: all events listing (htmx nav)."""
from quart import url_for
from shared.utils import route_prefix, events_url
prefix = route_prefix()
ticket_url = url_for("all_events.adjust_ticket")
next_url = prefix + url_for("all_events.entries_fragment", page=page + 1) + (f"?view={view}" if view != "list" else "")
content = _events_main_panel_html(
ctx, entries, has_more, pending_tickets, page_info,
page, view, ticket_url, next_url, events_url,
)
return oob_page(ctx, content_html=content)
async def render_all_events_cards(entries, has_more, pending_tickets,
page_info, page, view) -> str:
"""Pagination fragment: all events cards only."""
from quart import url_for
from shared.utils import route_prefix, events_url
prefix = route_prefix()
ticket_url = url_for("all_events.adjust_ticket")
next_url = prefix + url_for("all_events.entries_fragment", page=page + 1) + (f"?view={view}" if view != "list" else "")
return _entry_cards_html(
entries, page_info, pending_tickets, ticket_url, events_url,
view, page, has_more, next_url,
)
# ---------------------------------------------------------------------------
# Page summary
# ---------------------------------------------------------------------------
async def render_page_summary_page(ctx: dict, entries, has_more, pending_tickets,
page_info, page, view) -> str:
"""Full page: page-scoped events listing."""
from quart import url_for
from shared.utils import route_prefix, events_url
prefix = route_prefix()
post = ctx.get("post") or {}
ticket_url = url_for("page_summary.adjust_ticket")
next_url = prefix + url_for("page_summary.entries_fragment", page=page + 1) + (f"?view={view}" if view != "list" else "")
content = _events_main_panel_html(
ctx, entries, has_more, pending_tickets, page_info,
page, view, ticket_url, next_url, events_url,
is_page_scoped=True, post=post,
)
hdr = root_header_html(ctx)
hdr += sexp(
'(div :id "root-header-child" :class "w-full" (raw! ph))',
ph=_post_header_html(ctx),
)
return full_page(ctx, header_rows_html=hdr, content_html=content)
async def render_page_summary_oob(ctx: dict, entries, has_more, pending_tickets,
page_info, page, view) -> str:
"""OOB response: page-scoped events (htmx nav)."""
from quart import url_for
from shared.utils import route_prefix, events_url
prefix = route_prefix()
post = ctx.get("post") or {}
ticket_url = url_for("page_summary.adjust_ticket")
next_url = prefix + url_for("page_summary.entries_fragment", page=page + 1) + (f"?view={view}" if view != "list" else "")
content = _events_main_panel_html(
ctx, entries, has_more, pending_tickets, page_info,
page, view, ticket_url, next_url, events_url,
is_page_scoped=True, post=post,
)
oobs = _post_header_html(ctx, oob=True)
return oob_page(ctx, oobs_html=oobs, content_html=content)
async def render_page_summary_cards(entries, has_more, pending_tickets,
page_info, page, view, post) -> str:
"""Pagination fragment: page-scoped events cards only."""
from quart import url_for
from shared.utils import route_prefix, events_url
prefix = route_prefix()
ticket_url = url_for("page_summary.adjust_ticket")
next_url = prefix + url_for("page_summary.entries_fragment", page=page + 1) + (f"?view={view}" if view != "list" else "")
return _entry_cards_html(
entries, page_info, pending_tickets, ticket_url, events_url,
view, page, has_more, next_url,
is_page_scoped=True, post=post,
)
# ---------------------------------------------------------------------------
# Calendars home
# ---------------------------------------------------------------------------
async def render_calendars_page(ctx: dict) -> str:
"""Full page: calendars listing."""
content = _calendars_main_panel_html(ctx)
hdr = root_header_html(ctx)
hdr += sexp(
'(div :id "root-header-child" :class "w-full" (raw! ph (raw! pah (raw! ch))))',
ph=_post_header_html(ctx),
pah=_post_admin_header_html(ctx),
ch=_calendars_header_html(ctx),
)
return full_page(ctx, header_rows_html=hdr, content_html=content)
async def render_calendars_oob(ctx: dict) -> str:
"""OOB response: calendars listing."""
content = _calendars_main_panel_html(ctx)
oobs = _post_admin_header_html(ctx, oob=True)
oobs += _oob_header_html("post-admin-header-child", "calendars-header-child",
_calendars_header_html(ctx))
return oob_page(ctx, oobs_html=oobs, content_html=content)
# ---------------------------------------------------------------------------
# Calendar month view
# ---------------------------------------------------------------------------
async def render_calendar_page(ctx: dict) -> str:
"""Full page: calendar month view."""
content = _calendar_main_panel_html(ctx)
hdr = root_header_html(ctx)
hdr += sexp(
'(div :id "root-header-child" :class "w-full" (raw! ph (raw! pah (raw! ch))))',
ph=_post_header_html(ctx),
pah=_post_admin_header_html(ctx),
ch=_calendar_header_html(ctx),
)
return full_page(ctx, header_rows_html=hdr, content_html=content)
async def render_calendar_oob(ctx: dict) -> str:
"""OOB response: calendar month view."""
content = _calendar_main_panel_html(ctx)
oobs = _post_header_html(ctx, oob=True)
oobs += _oob_header_html("post-header-child", "calendar-header-child",
_calendar_header_html(ctx))
return oob_page(ctx, oobs_html=oobs, content_html=content)
# ---------------------------------------------------------------------------
# Day detail
# ---------------------------------------------------------------------------
async def render_day_page(ctx: dict) -> str:
"""Full page: day detail."""
content = _day_main_panel_html(ctx)
hdr = root_header_html(ctx)
hdr += sexp(
'(div :id "root-header-child" :class "w-full" (raw! ph (raw! pah (raw! ch (raw! dh)))))',
ph=_post_header_html(ctx),
pah=_post_admin_header_html(ctx),
ch=_calendar_header_html(ctx),
dh=_day_header_html(ctx),
)
return full_page(ctx, header_rows_html=hdr, content_html=content)
async def render_day_oob(ctx: dict) -> str:
"""OOB response: day detail."""
content = _day_main_panel_html(ctx)
oobs = _calendar_header_html(ctx, oob=True)
oobs += _oob_header_html("calendar-header-child", "day-header-child",
_day_header_html(ctx))
return oob_page(ctx, oobs_html=oobs, content_html=content)
# ---------------------------------------------------------------------------
# Day admin
# ---------------------------------------------------------------------------
async def render_day_admin_page(ctx: dict) -> str:
"""Full page: day admin."""
content = _day_admin_main_panel_html(ctx)
hdr = root_header_html(ctx)
hdr += sexp(
'(div :id "root-header-child" :class "w-full" (raw! ph (raw! pah (raw! ch (raw! dh (raw! dah))))))',
ph=_post_header_html(ctx),
pah=_post_admin_header_html(ctx),
ch=_calendar_header_html(ctx),
dh=_day_header_html(ctx),
dah=_day_admin_header_html(ctx),
)
return full_page(ctx, header_rows_html=hdr, content_html=content)
async def render_day_admin_oob(ctx: dict) -> str:
"""OOB response: day admin."""
content = _day_admin_main_panel_html(ctx)
oobs = _calendar_header_html(ctx, oob=True)
oobs += _oob_header_html("day-header-child", "day-admin-header-child",
_day_admin_header_html(ctx))
return oob_page(ctx, oobs_html=oobs, content_html=content)
# ---------------------------------------------------------------------------
# Calendar admin
# ---------------------------------------------------------------------------
async def render_calendar_admin_page(ctx: dict) -> str:
"""Full page: calendar admin."""
content = _calendar_admin_main_panel_html(ctx)
hdr = root_header_html(ctx)
hdr += sexp(
'(div :id "root-header-child" :class "w-full" (raw! ph (raw! pah (raw! ch (raw! cah)))))',
ph=_post_header_html(ctx),
pah=_post_admin_header_html(ctx),
ch=_calendar_header_html(ctx),
cah=_calendar_admin_header_html(ctx),
)
return full_page(ctx, header_rows_html=hdr, content_html=content)
async def render_calendar_admin_oob(ctx: dict) -> str:
"""OOB response: calendar admin."""
content = _calendar_admin_main_panel_html(ctx)
oobs = _calendar_header_html(ctx, oob=True)
oobs += _oob_header_html("calendar-header-child", "calendar-admin-header-child",
_calendar_admin_header_html(ctx))
return oob_page(ctx, oobs_html=oobs, content_html=content)
# ---------------------------------------------------------------------------
# Tickets
# ---------------------------------------------------------------------------
async def render_tickets_page(ctx: dict, tickets: list) -> str:
"""Full page: my tickets."""
content = _tickets_main_panel_html(ctx, tickets)
hdr = root_header_html(ctx)
return full_page(ctx, header_rows_html=hdr, content_html=content)
async def render_tickets_oob(ctx: dict, tickets: list) -> str:
"""OOB response: my tickets."""
content = _tickets_main_panel_html(ctx, tickets)
return oob_page(ctx, content_html=content)
async def render_ticket_detail_page(ctx: dict, ticket) -> str:
"""Full page: ticket detail with QR."""
content = _ticket_detail_panel_html(ctx, ticket)
hdr = root_header_html(ctx)
return full_page(ctx, header_rows_html=hdr, content_html=content)
async def render_ticket_detail_oob(ctx: dict, ticket) -> str:
"""OOB response: ticket detail."""
content = _ticket_detail_panel_html(ctx, ticket)
return oob_page(ctx, content_html=content)
# ---------------------------------------------------------------------------
# Ticket admin
# ---------------------------------------------------------------------------
async def render_ticket_admin_page(ctx: dict, tickets: list, stats: dict) -> str:
"""Full page: ticket admin dashboard."""
content = _ticket_admin_main_panel_html(ctx, tickets, stats)
hdr = root_header_html(ctx)
return full_page(ctx, header_rows_html=hdr, content_html=content)
async def render_ticket_admin_oob(ctx: dict, tickets: list, stats: dict) -> str:
"""OOB response: ticket admin dashboard."""
content = _ticket_admin_main_panel_html(ctx, tickets, stats)
return oob_page(ctx, content_html=content)
# ---------------------------------------------------------------------------
# Markets
# ---------------------------------------------------------------------------
async def render_markets_page(ctx: dict) -> str:
"""Full page: markets listing."""
content = _markets_main_panel_html(ctx)
hdr = root_header_html(ctx)
hdr += sexp(
'(div :id "root-header-child" :class "w-full" (raw! ph (raw! pah (raw! mh))))',
ph=_post_header_html(ctx),
pah=_post_admin_header_html(ctx),
mh=_markets_header_html(ctx),
)
return full_page(ctx, header_rows_html=hdr, content_html=content)
async def render_markets_oob(ctx: dict) -> str:
"""OOB response: markets listing."""
content = _markets_main_panel_html(ctx)
oobs = _post_admin_header_html(ctx, oob=True)
oobs += _oob_header_html("post-admin-header-child", "markets-header-child",
_markets_header_html(ctx))
return oob_page(ctx, oobs_html=oobs, content_html=content)
# ---------------------------------------------------------------------------
# Payments
# ---------------------------------------------------------------------------
async def render_payments_page(ctx: dict) -> str:
"""Full page: payments admin."""
content = _payments_main_panel_html(ctx)
hdr = root_header_html(ctx)
hdr += sexp(
'(div :id "root-header-child" :class "w-full" (raw! ph (raw! pah (raw! pyh))))',
ph=_post_header_html(ctx),
pah=_post_admin_header_html(ctx),
pyh=_payments_header_html(ctx),
)
return full_page(ctx, header_rows_html=hdr, content_html=content)
async def render_payments_oob(ctx: dict) -> str:
"""OOB response: payments admin."""
content = _payments_main_panel_html(ctx)
oobs = _post_admin_header_html(ctx, oob=True)
oobs += _oob_header_html("post-admin-header-child", "payments-header-child",
_payments_header_html(ctx))
return oob_page(ctx, oobs_html=oobs, content_html=content)