Files
rose-ash/events/sexp/sexp_components.py
giles eda95ec58b
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m15s
Enable cross-subdomain htmx and purify layout to sexp
- Disable htmx selfRequestsOnly, add CORS headers for *.rose-ash.com
- Remove same-origin guards from ~menu-row and ~nav-link htmx attrs
- Convert ~app-layout from string-concatenated HTML to pure sexp tree
- Extract ~app-head component, replace ~app-shell with inline structure
- Convert hamburger SVG from Python HTML constant to ~hamburger sexp component
- Fix cross-domain fragment URLs (events_url, market_url)
- Fix starts-with? primitive to handle nil values
- Fix duplicate admin menu rows on OOB swaps
- Add calendar admin nav links (slots, description)
- Convert slots page from Jinja to sexp rendering
- Disable page caching in development mode
- Backfill migration to clean orphaned container_relations

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 12:09:00 +00:00

3322 lines
146 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 + container nav (markets, etc.)."""
from quart import url_for, g
calendars = ctx.get("calendars") or []
select_colours = ctx.get("select_colours", "")
current_cal_slug = getattr(g, "calendar_slug", None)
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)
is_sel = (cal_slug == current_cal_slug)
parts.append(sexp(
'(~nav-link :href h :icon "fa fa-calendar" :label l :select-colours sc :is-selected sel)',
h=href,
l=cal_name,
sc=select_colours,
sel=is_sel,
))
# Container nav fragments (markets, etc.)
container_nav = ctx.get("container_nav_html", "")
if container_nav:
parts.append(container_nav)
return "".join(parts)
# ---------------------------------------------------------------------------
# Post admin header
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# 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 with nav links."""
from quart import url_for
calendar = ctx.get("calendar")
cal_slug = getattr(calendar, "slug", "") if calendar else ""
select_colours = ctx.get("select_colours", "")
nav_parts = []
if cal_slug:
for endpoint, label in [
("calendars.calendar.slots.get", "slots"),
("calendars.calendar.admin.calendar_description_edit", "description"),
]:
href = url_for(endpoint, calendar_slug=cal_slug)
nav_parts.append(sexp(
'(~nav-link :href h :label l :select-colours sc)',
h=href, l=label, sc=select_colours,
))
nav_html = "".join(nav_parts)
return sexp(
'(~menu-row :id "calendar-admin-row" :level 4'
' :link-label "admin" :icon "fa fa-cog"'
' :nav-html nh :child-id "calendar-admin-header-child" :oob oob)',
nh=nav_html,
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:
if isinstance(day_cell, dict):
in_month = day_cell.get("in_month", True)
is_today = day_cell.get("is_today", False)
day_date = day_cell.get("date")
else:
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">')
if day_date:
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 and 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 and entry.start_at:
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 and entry.start_at:
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 and entry.start_at:
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 and entry.start_at:
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.start_at else ""
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
from shared.infrastructure.urls import 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
from shared.infrastructure.urls import 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
from shared.infrastructure.urls import 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
from shared.infrastructure.urls import 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
from shared.infrastructure.urls import 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
from shared.infrastructure.urls import 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)
child = _post_header_html(ctx) + _calendars_header_html(ctx)
hdr += sexp('(div :id "root-header-child" :class "w-full" (raw! h))', h=child)
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_header_html(ctx, oob=True)
oobs += _oob_header_html("post-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)
child = _post_header_html(ctx) + _calendar_header_html(ctx)
hdr += sexp('(div :id "root-header-child" :class "w-full" (raw! h))', h=child)
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)
child = (_post_header_html(ctx)
+ _calendar_header_html(ctx) + _day_header_html(ctx))
hdr += sexp('(div :id "root-header-child" :class "w-full" (raw! h))', h=child)
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)
child = (_post_header_html(ctx)
+ _calendar_header_html(ctx) + _day_header_html(ctx)
+ _day_admin_header_html(ctx))
hdr += sexp('(div :id "root-header-child" :class "w-full" (raw! h))', h=child)
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)
child = (_post_header_html(ctx)
+ _calendar_header_html(ctx) + _calendar_admin_header_html(ctx))
hdr += sexp('(div :id "root-header-child" :class "w-full" (raw! h))', h=child)
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)
# ---------------------------------------------------------------------------
# Slots
# ---------------------------------------------------------------------------
async def render_slots_page(ctx: dict) -> str:
"""Full page: slots listing."""
from quart import g
slots = ctx.get("slots") or []
calendar = ctx.get("calendar")
content = render_slots_table(slots, calendar)
hdr = root_header_html(ctx)
child = (_post_header_html(ctx)
+ _calendar_header_html(ctx) + _calendar_admin_header_html(ctx))
hdr += sexp('(div :id "root-header-child" :class "w-full" (raw! h))', h=child)
return full_page(ctx, header_rows_html=hdr, content_html=content)
async def render_slots_oob(ctx: dict) -> str:
"""OOB response: slots listing."""
slots = ctx.get("slots") or []
calendar = ctx.get("calendar")
content = render_slots_table(slots, calendar)
oobs = _calendar_admin_header_html(ctx, oob=True)
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)
child = _post_header_html(ctx) + _markets_header_html(ctx)
hdr += sexp('(div :id "root-header-child" :class "w-full" (raw! h))', h=child)
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_header_html(ctx, oob=True)
oobs += _oob_header_html("post-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)
child = _post_header_html(ctx) + _payments_header_html(ctx)
hdr += sexp('(div :id "root-header-child" :class "w-full" (raw! h))', h=child)
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_header_html(ctx, oob=True)
oobs += _oob_header_html("post-header-child", "payments-header-child",
_payments_header_html(ctx))
return oob_page(ctx, oobs_html=oobs, content_html=content)
# ===========================================================================
# POST / PUT / DELETE response components
# ===========================================================================
# ---------------------------------------------------------------------------
# Ticket widget (public wrapper for _ticket_widget_html)
# ---------------------------------------------------------------------------
def render_ticket_widget(entry, qty: int, ticket_url: str) -> str:
"""Render the +/- ticket widget for page_summary / all_events adjust_ticket."""
return _ticket_widget_html(entry, qty, ticket_url, ctx={})
# ---------------------------------------------------------------------------
# Ticket admin: checkin result
# ---------------------------------------------------------------------------
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 (
'<div class="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-800">'
f'<i class="fa fa-exclamation-circle mr-2" aria-hidden="true"></i>{err_msg}'
'</div>'
)
if not ticket:
return ""
code = ticket.code
entry = getattr(ticket, "entry", None)
tt = getattr(ticket, "ticket_type", None)
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'<div class="text-xs text-stone-500">{entry.start_at.strftime("%d %b %Y, %H:%M")}</div>'
tt_name = escape(tt.name) if tt else ""
return (
f'<tr class="bg-blue-50" id="ticket-row-{code}">'
f'<td class="px-4 py-3"><span class="font-mono text-xs">{code[:12]}...</span></td>'
f'<td class="px-4 py-3"><div class="font-medium">{entry_name}</div>{date_html}</td>'
f'<td class="px-4 py-3 text-sm">{tt_name}</td>'
f'<td class="px-4 py-3">{_ticket_state_badge_html("checked_in")}</td>'
f'<td class="px-4 py-3"><span class="text-xs text-blue-600">'
f'<i class="fa fa-check-circle" aria-hidden="true"></i> {time_str}</span></td>'
'</tr>'
)
# ---------------------------------------------------------------------------
# Ticket admin: lookup result
# ---------------------------------------------------------------------------
def render_lookup_result(ticket, error: str | None) -> str:
"""Render ticket lookup result: error div or ticket info card."""
from quart import url_for
from shared.browser.app.csrf import generate_csrf_token
if error:
return (
'<div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">'
f'<i class="fa fa-exclamation-circle mr-2" aria-hidden="true"></i>{escape(error)}'
'</div>'
)
if not ticket:
return ""
entry = getattr(ticket, "entry", None)
tt = getattr(ticket, "ticket_type", None)
state = getattr(ticket, "state", "")
code = ticket.code
checked_in_at = getattr(ticket, "checked_in_at", None)
csrf = generate_csrf_token()
entry_name = escape(entry.name) if entry else "Unknown event"
parts = ['<div class="rounded-lg border border-stone-200 bg-stone-50 p-4">']
parts.append('<div class="flex items-start justify-between gap-4"><div class="flex-1">')
parts.append(f'<div class="font-semibold text-lg">{entry_name}</div>')
if tt:
parts.append(f'<div class="text-sm text-stone-600">{escape(tt.name)}</div>')
if entry and entry.start_at:
parts.append(f'<div class="text-sm text-stone-500 mt-1">{entry.start_at.strftime("%A, %B %d, %Y at %H:%M")}</div>')
cal = getattr(entry, "calendar", None) if entry else None
if cal:
parts.append(f'<div class="text-xs text-stone-400 mt-0.5">{escape(cal.name)}</div>')
parts.append('<div class="mt-2">')
parts.append(_ticket_state_badge_html(state))
parts.append(f'<span class="text-xs text-stone-400 ml-2 font-mono">{code}</span>')
parts.append('</div>')
if checked_in_at:
parts.append(f'<div class="text-xs text-blue-600 mt-1">Checked in: {checked_in_at.strftime("%B %d, %Y at %H:%M")}</div>')
parts.append('</div>')
# Action area
parts.append(f'<div id="checkin-action-{code}">')
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="#checkin-action-{code}" hx-swap="innerHTML">'
f'<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"></i>Check In</button></form>'
)
elif state == "checked_in":
parts.append(
'<div class="text-blue-600 text-center">'
'<i class="fa fa-check-circle text-3xl" aria-hidden="true"></i>'
'<div class="text-sm font-medium mt-1">Checked In</div></div>'
)
elif state == "cancelled":
parts.append(
'<div class="text-red-600 text-center">'
'<i class="fa fa-times-circle text-3xl" aria-hidden="true"></i>'
'<div class="text-sm font-medium mt-1">Cancelled</div></div>'
)
parts.append('</div></div></div>')
return "".join(parts)
# ---------------------------------------------------------------------------
# Ticket admin: entry tickets table
# ---------------------------------------------------------------------------
def render_entry_tickets_admin(entry, tickets: list) -> str:
"""Render admin ticket table for a specific entry."""
from quart import url_for
from shared.browser.app.csrf import generate_csrf_token
csrf = generate_csrf_token()
count = len(tickets)
suffix = "s" if count != 1 else ""
parts = ['<div class="space-y-4">']
parts.append(
'<div class="flex items-center justify-between">'
f'<h3 class="text-lg font-semibold">Tickets for: {escape(entry.name)}</h3>'
f'<span class="text-sm text-stone-500">{count} ticket{suffix}</span>'
'</div>'
)
if tickets:
parts.append('<div class="overflow-x-auto rounded-xl border border-stone-200">')
parts.append(
'<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>'
'<th class="px-4 py-2 text-left font-medium text-stone-600">Type</th>'
'<th class="px-4 py-2 text-left font-medium text-stone-600">State</th>'
'<th class="px-4 py-2 text-left font-medium text-stone-600">Actions</th>'
'</tr></thead><tbody class="divide-y divide-stone-100">'
)
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'<tr class="hover:bg-stone-50" id="entry-ticket-row-{code}">')
parts.append(f'<td class="px-4 py-2 font-mono text-xs">{code[:12]}...</td>')
parts.append(f'<td class="px-4 py-2">{escape(tt.name) if tt else ""}</td>')
parts.append(f'<td class="px-4 py-2">{_ticket_state_badge_html(state)}</td>')
parts.append('<td class="px-4 py-2">')
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="#entry-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">'
'Check in</button></form>'
)
elif state == "checked_in":
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="text-center py-6 text-stone-500 text-sm">No tickets for this entry</div>')
parts.append('</div>')
return "".join(parts)
# ---------------------------------------------------------------------------
# Day main panel — public API
# ---------------------------------------------------------------------------
def render_day_main_panel(ctx: dict) -> str:
"""Public wrapper for day main panel rendering."""
return _day_main_panel_html(ctx)
# ---------------------------------------------------------------------------
# Entry main panel
# ---------------------------------------------------------------------------
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:
return ""
calendar = ctx.get("calendar")
cal_slug = getattr(calendar, "slug", "") if calendar else ""
day = ctx.get("day")
month = ctx.get("month")
year = ctx.get("year")
styles = ctx.get("styles") or {}
list_container = getattr(styles, "list_container", "") if hasattr(styles, "list_container") else styles.get("list_container", "")
pre_action = getattr(styles, "pre_action_button", "") if hasattr(styles, "pre_action_button") else styles.get("pre_action_button", "")
eid = entry.id
state = getattr(entry, "state", "pending") or "pending"
parts = [f'<section id="entry-{eid}" class="{list_container}">']
# Name
parts.append(
'<div class="flex flex-col mb-4">'
'<div class="text-xs font-semibold uppercase tracking-wide text-stone-500">Name</div>'
f'<div class="mt-1 text-lg font-medium">{escape(entry.name)}</div>'
'</div>'
)
# Slot
slot = getattr(entry, "slot", None)
parts.append(
'<div class="flex flex-col mb-4">'
'<div class="text-xs font-semibold uppercase tracking-wide text-stone-500">Slot</div>'
'<div class="mt-1">'
)
if slot:
flex_label = "(flexible)" if getattr(slot, "flexible", False) else "(fixed)"
parts.append(
f'<span class="px-2 py-1 rounded text-sm bg-blue-100 text-blue-700">{escape(slot.name)}</span>'
f'<span class="ml-2 text-xs text-stone-500">{flex_label}</span>'
)
else:
parts.append('<span class="text-sm text-stone-400">No slot assigned</span>')
parts.append('</div></div>')
# Time Period
start_str = entry.start_at.strftime("%H:%M") if entry.start_at else ""
end_str = f" &ndash; {entry.end_at.strftime('%H:%M')}" if entry.end_at else " &ndash; open-ended"
parts.append(
'<div class="flex flex-col mb-4">'
'<div class="text-xs font-semibold uppercase tracking-wide text-stone-500">Time Period</div>'
f'<div class="mt-1">{start_str}{end_str}</div>'
'</div>'
)
# State
state_badge = _entry_state_badge_html(state)
parts.append(
'<div class="flex flex-col mb-4">'
'<div class="text-xs font-semibold uppercase tracking-wide text-stone-500">State</div>'
f'<div class="mt-1"><div id="entry-state-{eid}">{state_badge}</div></div>'
'</div>'
)
# Cost
cost = getattr(entry, "cost", None)
cost_str = f"{cost:.2f}" if cost is not None else "0.00"
parts.append(
'<div class="flex flex-col mb-4">'
'<div class="text-xs font-semibold uppercase tracking-wide text-stone-500">Cost</div>'
f'<div class="mt-1"><span class="font-medium text-green-600">&pound;{cost_str}</span></div>'
'</div>'
)
# Ticket Configuration (admin)
parts.append(
'<div class="flex flex-col mb-4">'
'<div class="text-xs font-semibold uppercase tracking-wide text-stone-500">Tickets</div>'
f'<div class="mt-1" id="entry-tickets-{eid}">'
)
parts.append(render_entry_tickets_config(entry, calendar, day, month, year))
parts.append('</div></div>')
# 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(
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(
'<div class="flex flex-col mb-4">'
'<div class="text-xs font-semibold uppercase tracking-wide text-stone-500">Date</div>'
f'<div class="mt-1">{date_str}</div>'
'</div>'
)
# Associated Posts
entry_posts = ctx.get("entry_posts") or []
parts.append(
'<div class="flex flex-col mb-4">'
'<div class="text-xs font-semibold uppercase tracking-wide text-stone-500">Associated Posts</div>'
f'<div class="mt-1" id="entry-posts-{eid}">'
)
parts.append(render_entry_posts_panel(entry_posts, entry, calendar, day, month, year))
parts.append('</div></div>')
# Options and Edit Button
parts.append('<div class="flex gap-2 mt-6">')
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'<button type="button" class="{pre_action}" '
f'hx-get="{edit_url}" hx-target="#entry-{eid}" hx-swap="outerHTML">'
'Edit</button>'
)
parts.append('</div></section>')
return "".join(parts)
# ---------------------------------------------------------------------------
# Entry header row
# ---------------------------------------------------------------------------
def _entry_header_html(ctx: dict, *, oob: bool = False) -> str:
"""Build entry detail header row."""
from quart import url_for
calendar = ctx.get("calendar")
if not calendar:
return ""
cal_slug = getattr(calendar, "slug", "")
entry = ctx.get("entry")
if not entry:
return ""
day = ctx.get("day")
month = ctx.get("month")
year = ctx.get("year")
link_href = url_for(
"calendars.calendar.day.calendar_entries.calendar_entry.get",
calendar_slug=cal_slug,
year=year, month=month, day=day,
entry_id=entry.id,
)
label_html = (
f'<div id="entry-title-{entry.id}" class="flex gap-1 items-center">'
+ _entry_title_html(entry)
+ _entry_times_html(entry)
+ '</div>'
)
nav_html = _entry_nav_html(ctx)
return sexp(
'(~menu-row :id "entry-row" :level 5'
' :link-href lh :link-label-html llh'
' :nav-html nh :child-id "entry-header-child" :oob oob)',
lh=link_href,
llh=label_html,
nh=nav_html,
oob=oob,
)
def _entry_times_html(entry) -> str:
"""Render entry times label."""
start = entry.start_at
end = entry.end_at
if not start:
return ""
start_str = start.strftime("%H:%M")
end_str = f" &rarr; {end.strftime('%H:%M')}" if end else ""
return f'<div class="text-sm text-gray-600">{start_str}{end_str}</div>'
# ---------------------------------------------------------------------------
# Entry nav (desktop + admin link)
# ---------------------------------------------------------------------------
def _entry_nav_html(ctx: dict) -> str:
"""Entry desktop nav: associated posts scrolling menu + admin link."""
from quart import url_for
calendar = ctx.get("calendar")
if not calendar:
return ""
cal_slug = getattr(calendar, "slug", "")
entry = ctx.get("entry")
if not entry:
return ""
day = ctx.get("day")
month = ctx.get("month")
year = ctx.get("year")
entry_posts = ctx.get("entry_posts") or []
rights = ctx.get("rights") or {}
is_admin = getattr(rights, "admin", False) if hasattr(rights, "admin") else rights.get("admin", False)
blog_url_fn = ctx.get("blog_url")
parts = []
# Associated Posts scrolling menu
if entry_posts:
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="entry-posts-nav-wrapper">'
'<div class="flex overflow-x-auto gap-1 scrollbar-thin">'
)
for ep in entry_posts:
slug = getattr(ep, "slug", "")
title = escape(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'<img src="{feat}" alt="{title}" class="w-8 h-8 rounded-full object-cover flex-shrink-0" />'
else:
img = '<div class="w-8 h-8 rounded-full bg-stone-200 flex-shrink-0"></div>'
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'{img}<div class="flex-1 min-w-0"><div class="font-medium truncate">{title}</div></div></a>'
)
parts.append('</div></div>')
# Admin link
if is_admin:
admin_url = url_for(
"calendars.calendar.day.calendar_entries.calendar_entry.admin.admin",
calendar_slug=cal_slug,
day=day, month=month, year=year,
entry_id=entry.id,
)
parts.append(
f'<a href="{admin_url}" '
'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"></i> Admin</a>'
)
return "".join(parts)
# ---------------------------------------------------------------------------
# Entry page / OOB rendering
# ---------------------------------------------------------------------------
async def render_entry_page(ctx: dict) -> str:
"""Full page: entry detail."""
content = _entry_main_panel_html(ctx)
hdr = root_header_html(ctx)
child = (_post_header_html(ctx)
+ _calendar_header_html(ctx) + _day_header_html(ctx)
+ _entry_header_html(ctx))
hdr += sexp('(div :id "root-header-child" :class "w-full" (raw! h))', h=child)
nav_html = _entry_nav_html(ctx)
return full_page(ctx, header_rows_html=hdr, content_html=content, menu_html=nav_html)
async def render_entry_oob(ctx: dict) -> str:
"""OOB response: entry detail."""
content = _entry_main_panel_html(ctx)
oobs = _day_header_html(ctx, oob=True)
oobs += _oob_header_html("day-header-child", "entry-header-child",
_entry_header_html(ctx))
nav_html = _entry_nav_html(ctx)
return oob_page(ctx, oobs_html=oobs, content_html=content, menu_html=nav_html)
# ---------------------------------------------------------------------------
# Entry optioned (confirm/decline/provisional response)
# ---------------------------------------------------------------------------
def render_entry_optioned(entry, calendar, day, month, year) -> str:
"""Render entry options buttons + OOB title & state swaps."""
options = _entry_options_html(entry, calendar, day, month, year)
title = _entry_title_html(entry)
state = _entry_state_badge_html(getattr(entry, "state", "pending") or "pending")
return (
options
+ f'<div id="entry-title-{entry.id}" hx-swap-oob="innerHTML">{title}</div>'
+ f'<div id="entry-state-{entry.id}" hx-swap-oob="innerHTML">{state}</div>'
)
def _entry_title_html(entry) -> str:
"""Render entry title (icon + name + state badge)."""
state = getattr(entry, "state", "pending") or "pending"
return (
f'<i class="fa fa-clock"></i> {escape(entry.name)} '
+ _entry_state_badge_html(state)
)
def _entry_options_html(entry, calendar, day, month, year) -> str:
"""Render confirm/decline/provisional buttons based on entry state."""
from quart import url_for, g
from shared.browser.app.csrf import generate_csrf_token
csrf = generate_csrf_token()
styles = getattr(g, "styles", None) or {}
action_btn = getattr(styles, "action_button", "") if hasattr(styles, "action_button") else styles.get("action_button", "")
cal_slug = getattr(calendar, "slug", "")
eid = entry.id
state = getattr(entry, "state", "pending") or "pending"
target = f"#calendar_entry_options_{eid}"
parts = [f'<div id="calendar_entry_options_{eid}" class="flex flex-col md:flex-row gap-1">']
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'<form hx-post="{url}" hx-select="{target}" hx-target="{target}" hx-swap="outerHTML"{trigger_attr}>'
f'<input type="hidden" name="csrf_token" value="{csrf}">'
f'<button type="{btn_type}" class="{action_btn}" '
f'data-confirm="true" data-confirm-title="{confirm_title}" '
f'data-confirm-text="{confirm_text}" data-confirm-icon="question" '
f'data-confirm-confirm-text="Yes, {label} it" data-confirm-cancel-text="Cancel"'
+ (f' data-confirm-event="confirmed"' if trigger_type == "button" else "")
+ f'><i class="fa-solid fa-rotate mr-2" aria-hidden="true"></i>{label}</button></form>'
)
if state == "provisional":
parts.append(_make_button(
"confirm_entry", "confirm",
"Confirm entry?", "Are you sure you want to confirm this entry?",
))
parts.append(_make_button(
"decline_entry", "decline",
"Decline entry?", "Are you sure you want to decline this entry?",
))
elif state == "confirmed":
parts.append(_make_button(
"provisional_entry", "provisional",
"Provisional entry?", "Are you sure you want to provisional this entry?",
trigger_type="button",
))
parts.append("</div>")
return "".join(parts)
# ---------------------------------------------------------------------------
# Entry tickets config (display + form)
# ---------------------------------------------------------------------------
def render_entry_tickets_config(entry, calendar, day, month, year) -> str:
"""Render ticket config display + edit form for admin entry view."""
from quart import url_for
from shared.browser.app.csrf import generate_csrf_token
csrf = generate_csrf_token()
cal_slug = getattr(calendar, "slug", "")
eid = entry.id
tp = getattr(entry, "ticket_price", None)
tc = getattr(entry, "ticket_count", None)
parts = []
if tp is not None:
parts.append('<div class="space-y-2">')
parts.append(f'<div class="flex items-center gap-2"><span class="text-sm font-medium text-stone-700">Price:</span>')
parts.append(f'<span class="font-medium text-green-600">&pound;{tp:.2f}</span></div>')
parts.append(f'<div class="flex items-center gap-2"><span class="text-sm font-medium text-stone-700">Available:</span>')
tc_str = f"{tc} tickets" if tc is not None else "Unlimited"
parts.append(f'<span class="font-medium text-blue-600">{tc_str}</span></div>')
parts.append(
f'<button type="button" class="text-xs text-blue-600 hover:text-blue-800 underline" '
f"""onclick="document.getElementById('ticket-form-{eid}').classList.remove('hidden'); this.classList.add('hidden');">"""
'Edit ticket config</button></div>'
)
else:
parts.append('<div class="space-y-2">')
parts.append('<span class="text-sm text-stone-400">No tickets configured</span>')
parts.append(
f'<button type="button" class="block text-xs text-blue-600 hover:text-blue-800 underline" '
f"""onclick="document.getElementById('ticket-form-{eid}').classList.remove('hidden'); this.classList.add('hidden');">"""
'Configure tickets</button></div>'
)
# 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,
)
hidden_cls = "" if tp is None else "hidden"
tp_val = f"{tp:.2f}" if tp is not None else ""
tc_val = str(tc) if tc is not None else ""
parts.append(
f'<form id="ticket-form-{eid}" class="{hidden_cls} space-y-3 mt-2 p-3 border rounded bg-stone-50" '
f'hx-post="{update_url}" hx-target="#entry-tickets-{eid}" hx-swap="innerHTML">'
f'<input type="hidden" name="csrf_token" value="{csrf}" />'
f'<div><label for="ticket-price-{eid}" class="block text-sm font-medium text-stone-700 mb-1">Ticket Price (&pound;)</label>'
f'<input type="number" id="ticket-price-{eid}" name="ticket_price" step="0.01" min="0" value="{tp_val}" '
'class="w-full px-3 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="e.g., 5.00" /></div>'
f'<div><label for="ticket-count-{eid}" class="block text-sm font-medium text-stone-700 mb-1">Total Tickets</label>'
f'<input type="number" id="ticket-count-{eid}" name="ticket_count" min="0" value="{tc_val}" '
'class="w-full px-3 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="Leave empty for unlimited" /></div>'
'<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>'
f'<button type="button" class="px-4 py-2 bg-stone-200 text-stone-700 rounded hover:bg-stone-300 text-sm" '
f"""onclick="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'));">"""
'Cancel</button></div></form>'
)
return "".join(parts)
# ---------------------------------------------------------------------------
# Entry posts panel
# ---------------------------------------------------------------------------
def render_entry_posts_panel(entry_posts, entry, calendar, day, month, year) -> str:
"""Render associated posts list with remove buttons and search input."""
from quart import url_for
from shared.browser.app.csrf import generate_csrf_token
csrf = generate_csrf_token()
cal_slug = getattr(calendar, "slug", "")
eid = entry.id
parts = ['<div class="space-y-2">']
if entry_posts:
parts.append('<div class="space-y-2">')
for ep in entry_posts:
ep_title = escape(getattr(ep, "title", ""))
ep_id = getattr(ep, "id", 0)
feat = getattr(ep, "feature_image", None)
if feat:
img = f'<img src="{feat}" alt="{ep_title}" class="w-8 h-8 rounded-full object-cover flex-shrink-0" />'
else:
img = '<div class="w-8 h-8 rounded-full bg-stone-200 flex-shrink-0"></div>'
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'<div class="flex items-center justify-between gap-3 p-2 bg-stone-50 rounded border">'
f'{img}<span class="text-sm flex-1">{ep_title}</span>'
f'<button type="button" class="text-xs text-red-600 hover:text-red-800 flex-shrink-0" '
f'data-confirm data-confirm-title="Remove post?" '
f'data-confirm-text="This will remove {ep_title} from this entry" '
f'data-confirm-icon="warning" data-confirm-confirm-text="Yes, remove it" '
f'data-confirm-cancel-text="Cancel" data-confirm-event="confirmed" '
f'hx-delete="{del_url}" hx-trigger="confirmed" '
f'hx-target="#entry-posts-{eid}" hx-swap="innerHTML" '
f"""hx-headers='{{"X-CSRFToken": "{csrf}"}}'>"""
'<i class="fa fa-times"></i> Remove</button></div>'
)
parts.append('</div>')
else:
parts.append('<p class="text-sm text-stone-400">No posts associated</p>')
# 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(
'<div class="mt-3 pt-3 border-t">'
'<label class="block text-xs font-medium text-stone-700 mb-1">Add Post</label>'
f'<input type="text" placeholder="Search posts..." class="w-full px-3 py-2 border rounded text-sm" '
f'hx-get="{search_url}" hx-trigger="keyup changed delay:300ms, load" '
f'hx-target="#post-search-results-{eid}" hx-swap="innerHTML" name="q" />'
f'<div id="post-search-results-{eid}" class="mt-2 max-h-96 overflow-y-auto border rounded"></div></div>'
)
parts.append('</div>')
return "".join(parts)
# ---------------------------------------------------------------------------
# Entry posts nav OOB
# ---------------------------------------------------------------------------
def render_entry_posts_nav_oob(entry_posts) -> str:
"""Render OOB nav for entry posts (scrolling menu)."""
from quart import g
styles = getattr(g, "styles", None) or {}
nav_btn = getattr(styles, "nav_button", "") if hasattr(styles, "nav_button") else styles.get("nav_button", "")
blog_url_fn = getattr(g, "blog_url", None)
if not entry_posts:
return '<div id="entry-posts-nav-wrapper" hx-swap-oob="true"></div>'
parts = [
'<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">'
]
for ep in entry_posts:
slug = getattr(ep, "slug", "")
title = escape(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'<img src="{feat}" alt="{title}" class="w-8 h-8 rounded-full object-cover flex-shrink-0" />'
else:
img = '<div class="w-8 h-8 rounded-full bg-stone-200 flex-shrink-0"></div>'
parts.append(
f'<a href="{href}" class="{nav_btn}">'
f'{img}<div class="flex-1 min-w-0"><div class="font-medium truncate">{title}</div></div></a>'
)
parts.append('</div></div>')
return "".join(parts)
# ---------------------------------------------------------------------------
# Day entries nav OOB
# ---------------------------------------------------------------------------
def render_day_entries_nav_oob(confirmed_entries, calendar, day_date) -> str:
"""Render OOB nav for confirmed entries in a day."""
from quart import url_for, g
styles = getattr(g, "styles", None) or {}
nav_btn = getattr(styles, "nav_button", "") if hasattr(styles, "nav_button") else styles.get("nav_button", "")
cal_slug = getattr(calendar, "slug", "")
if not confirmed_entries:
return '<div id="day-entries-nav-wrapper" hx-swap-oob="true"></div>'
parts = [
'<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">'
]
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="{nav_btn}">'
'<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>'
'</div></a>'
)
parts.append('</div></div>')
return "".join(parts)
# ---------------------------------------------------------------------------
# Post nav entries OOB
# ---------------------------------------------------------------------------
def render_post_nav_entries_oob(associated_entries, calendars, post) -> str:
"""Render OOB nav for associated entries and calendars of a post."""
from quart import g
from shared.infrastructure.urls import events_url
styles = getattr(g, "styles", None) or {}
nav_btn = getattr(styles, "nav_button_less_pad", "") if hasattr(styles, "nav_button_less_pad") else styles.get("nav_button_less_pad", "")
has_entries = associated_entries and getattr(associated_entries, "entries", None)
has_items = has_entries or calendars
if not has_items:
return '<div id="entries-calendars-nav-wrapper" hx-swap-oob="true"></div>'
slug = post.get("slug", "") if isinstance(post, dict) else getattr(post, "slug", "")
parts = [
'<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"></i></button>'
'<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;"'
' _="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">'
'<div class="flex flex-col sm:flex-row gap-1">'
]
if has_entries:
for entry in associated_entries.entries:
entry_path = (
f"/{slug}/calendars/{entry.calendar_slug}/"
f"{entry.start_at.year}/{entry.start_at.month}/{entry.start_at.day}/"
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'<a href="{href}" class="{nav_btn}">'
'<div class="w-8 h-8 rounded bg-stone-200 flex-shrink-0"></div>'
'<div class="flex-1 min-w-0">'
f'<div class="font-medium truncate">{name}</div>'
f'<div class="text-xs text-stone-600 truncate">{time_str}{end_str}</div>'
'</div></a>'
)
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'<a href="{local_href}" class="{nav_btn}">'
f'<i class="fa fa-calendar" aria-hidden="true"></i>'
f'<div>{cal_name}</div></a>'
)
parts.append('</div></div>')
parts.append(
'<style>'
'.scrollbar-hide::-webkit-scrollbar { display: none; }'
'.scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }'
'</style>'
)
parts.append(
'<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"></i></button>'
)
parts.append('</div>')
return "".join(parts)
# ---------------------------------------------------------------------------
# Calendar description display + edit form
# ---------------------------------------------------------------------------
def render_calendar_description(calendar, *, oob: bool = False) -> str:
"""Render calendar description display with edit button, optionally with OOB title."""
from quart import url_for
cal_slug = getattr(calendar, "slug", "")
edit_url = url_for("calendars.calendar.admin.calendar_description_edit", calendar_slug=cal_slug)
html = _calendar_description_display_html(calendar, edit_url)
if oob:
desc = getattr(calendar, "description", "") or ""
html += (
'<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">'
f'{escape(desc)}</div>'
)
return html
def render_calendar_description_edit(calendar) -> str:
"""Render calendar description edit form."""
from quart import url_for
from shared.browser.app.csrf import generate_csrf_token
csrf = generate_csrf_token()
cal_slug = getattr(calendar, "slug", "")
desc = getattr(calendar, "description", "") or ""
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 (
'<div id="calendar-description">'
f'<form hx-post="{save_url}" hx-target="#calendar-description" hx-swap="outerHTML">'
f'<input type="hidden" name="csrf_token" value="{csrf}">'
f'<textarea name="description" autocomplete="off" rows="4" class="w-full p-2 border rounded">{escape(desc)}</textarea>'
'<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>'
f'<button type="button" class="px-3 py-1 rounded border" '
f'hx-get="{cancel_url}" hx-target="#calendar-description" hx-swap="outerHTML">Cancel</button>'
'</div></form></div>'
)
# ---------------------------------------------------------------------------
# Payments panel (public wrapper)
# ---------------------------------------------------------------------------
def render_payments_panel(ctx: dict) -> str:
"""Render the payments config panel for PUT response."""
return _payments_main_panel_html(ctx)
# ---------------------------------------------------------------------------
# Calendars list panel (for POST create / DELETE)
# ---------------------------------------------------------------------------
def render_calendars_list_panel(ctx: dict) -> str:
"""Render the calendars main panel HTML for POST/DELETE response."""
return _calendars_main_panel_html(ctx)
# ---------------------------------------------------------------------------
# Markets list panel (for POST create / DELETE)
# ---------------------------------------------------------------------------
def render_markets_list_panel(ctx: dict) -> str:
"""Render the markets main panel HTML for POST/DELETE response."""
return _markets_main_panel_html(ctx)
# ---------------------------------------------------------------------------
# Slot main panel
# ---------------------------------------------------------------------------
def render_slot_main_panel(slot, calendar, *, oob: bool = False) -> str:
"""Render slot detail view."""
from quart import url_for, g
styles = getattr(g, "styles", None) or {}
list_container = getattr(styles, "list_container", "") if hasattr(styles, "list_container") else styles.get("list_container", "")
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 = days_display.split(", ")
flexible = getattr(slot, "flexible", False)
time_start = slot.time_start.strftime("%H:%M") if slot.time_start else ""
time_end = slot.time_end.strftime("%H:%M") if slot.time_end else ""
cost = getattr(slot, "cost", None)
cost_str = f"{cost:.2f}" if cost is not None else ""
desc = getattr(slot, "description", "") or ""
edit_url = url_for("calendars.calendar.slots.slot.get_edit", slot_id=slot.id, calendar_slug=cal_slug)
parts = [f'<section id="slot-{slot.id}" class="{list_container}">']
# Days
parts.append(
'<div class="flex flex-col">'
'<div class="text-xs font-semibold uppercase tracking-wide text-stone-500">Days</div>'
'<div class="mt-1">'
)
if days and days[0] != "":
parts.append('<div class="flex flex-wrap gap-1">')
for d in days:
parts.append(f'<span class="px-2 py-0.5 rounded-full text-xs bg-slate-200">{escape(d)}</span>')
parts.append('</div>')
else:
parts.append('<span class="text-xs text-slate-400">No days</span>')
parts.append('</div></div>')
# Flexible
parts.append(
'<div class="flex flex-col">'
'<div class="text-xs font-semibold uppercase tracking-wide text-stone-500">Flexible</div>'
f'<div class="mt-1">{"yes" if flexible else "no"}</div></div>'
)
# Time & Cost
parts.append(
'<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>'
f'<div class="mt-1">{time_start}{time_end}</div></div>'
'<div class="flex flex-col">'
'<div class="text-xs font-semibold uppercase tracking-wide text-stone-500">Cost</div>'
f'<div class="mt-1">{cost_str}</div></div></div>'
)
# Edit button
parts.append(
f'<button type="button" class="{pre_action}" '
f'hx-get="{edit_url}" hx-target="#slot-{slot.id}" hx-swap="outerHTML">Edit</button>'
)
parts.append('</section>')
if oob:
parts.append(
f'<div id="slot-description-title" hx-swap-oob="outerHTML"'
f' class="text-base font-normal break-words whitespace-normal min-w-0 break-all w-full text-center block">'
f'{escape(desc)}</div>'
)
return "".join(parts)
# ---------------------------------------------------------------------------
# Slots table
# ---------------------------------------------------------------------------
def render_slots_table(slots, calendar) -> str:
"""Render slots table with rows and add button."""
from quart import url_for, g
from shared.browser.app.csrf import generate_csrf_token
csrf = generate_csrf_token()
styles = getattr(g, "styles", None) or {}
list_container = getattr(styles, "list_container", "") if hasattr(styles, "list_container") else styles.get("list_container", "")
tr_cls = getattr(styles, "tr", "") if hasattr(styles, "tr") else styles.get("tr", "")
pill_cls = getattr(styles, "pill", "") if hasattr(styles, "pill") else styles.get("pill", "")
action_btn = getattr(styles, "action_button", "") if hasattr(styles, "action_button") else styles.get("action_button", "")
pre_action = getattr(styles, "pre_action_button", "") if hasattr(styles, "pre_action_button") else styles.get("pre_action_button", "")
hx_select = getattr(g, "hx_select_search", "#main-panel")
cal_slug = getattr(calendar, "slug", "")
parts = [f'<section id="slots-table" 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-1/6">Name</th>'
'<th class="p-2 text-left w-1/6">Flexible</th>'
'<th class="text-left p-2 w-1/6">Days</th>'
'<th class="text-left p-1/6">Time</th>'
'<th class="text-left p-2 w-1/6">Cost</th>'
'<th class="text-left p-2 w-1/6">Actions</th>'
'</tr></thead><tbody>'
)
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'<p class="text-stone-500 whitespace-pre-line break-all w-full">{escape(desc)}</p>' if desc else '<p class="text-stone-500 whitespace-pre-line break-all w-full"></p>'
days_display = getattr(s, "days_display", "")
day_list = days_display.split(", ")
if day_list and day_list[0] != "":
days_html = '<div class="flex flex-wrap gap-1">' + "".join(
f'<span class="px-2 py-0.5 rounded-full text-xs bg-slate-200">{escape(d)}</span>' for d in day_list
) + '</div>'
else:
days_html = '<span class="text-xs text-slate-400">No days</span>'
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'<tr class="{tr_cls}">'
f'<td class="p-2 align-top w-1/6"><div class="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(s.name)}</a></div>{desc_html}</td>'
f'<td class="p-2 align-top w-1/6">{"yes" if s.flexible else "no"}</td>'
f'<td class="p-2 align-top w-1/6">{days_html}</td>'
f'<td class="p-2 align-top w-1/6">{time_start} - {time_end}</td>'
f'<td class="p-2 align-top w-1/6">{cost_str}</td>'
f'<td class="p-2 align-top w-1/6">'
f'<button class="{action_btn}" '
f'data-confirm="true" data-confirm-title="Delete slot?" '
f'data-confirm-text="This action cannot be undone." '
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-target="#slots-table" hx-select="#slots-table" '
f"""hx-swap="outerHTML" hx-headers='{{"X-CSRFToken": "{csrf}"}}' hx-trigger="confirmed" """
f'type="button"><i class="fa-solid fa-trash"></i></button></td></tr>'
)
else:
parts.append('<tr><td colspan="5" class="p-3 text-stone-500">No slots yet.</td></tr>')
parts.append('</tbody></table>')
# Add button
add_url = url_for("calendars.calendar.slots.add_form", calendar_slug=cal_slug)
parts.append(
f'<div id="slot-add-container" class="mt-4">'
f'<button type="button" class="{pre_action}" '
f'hx-get="{add_url}" hx-target="#slot-add-container" hx-swap="innerHTML">'
'+ Add slot</button></div>'
)
parts.append('</section>')
return "".join(parts)
# ---------------------------------------------------------------------------
# Ticket type main panel
# ---------------------------------------------------------------------------
def render_ticket_type_main_panel(ticket_type, entry, calendar, day, month, year, *, oob: bool = False) -> str:
"""Render ticket type detail view."""
from quart import url_for, g
styles = getattr(g, "styles", None) or {}
list_container = getattr(styles, "list_container", "") if hasattr(styles, "list_container") else styles.get("list_container", "")
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"&pound;{cost:.2f}" if cost is not None else "&pound;0.00"
count = getattr(ticket_type, "count", 0)
edit_url = url_for(
"calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.get_edit",
ticket_type_id=ticket_type.id, calendar_slug=cal_slug,
year=year, month=month, day=day, entry_id=entry.id,
)
return (
f'<section id="ticket-{ticket_type.id}" class="{list_container}">'
'<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 text-sm">'
'<div class="flex flex-col">'
'<div class="text-xs font-semibold uppercase tracking-wide text-stone-500">Name</div>'
f'<div class="mt-1">{name}</div></div>'
'<div class="flex flex-col">'
'<div class="text-xs font-semibold uppercase tracking-wide text-stone-500">Cost</div>'
f'<div class="mt-1">{cost_str}</div></div>'
'<div class="flex flex-col">'
'<div class="text-xs font-semibold uppercase tracking-wide text-stone-500">Count</div>'
f'<div class="mt-1">{count}</div></div></div>'
f'<button type="button" class="{pre_action}" '
f'hx-get="{edit_url}" hx-target="#ticket-{ticket_type.id}" hx-swap="outerHTML">Edit</button>'
'</section>'
)
# ---------------------------------------------------------------------------
# Ticket types table
# ---------------------------------------------------------------------------
def render_ticket_types_table(ticket_types, entry, calendar, day, month, year) -> str:
"""Render ticket types table with rows and add button."""
from quart import url_for, g
from shared.browser.app.csrf import generate_csrf_token
csrf = generate_csrf_token()
styles = getattr(g, "styles", None) or {}
list_container = getattr(styles, "list_container", "") if hasattr(styles, "list_container") else styles.get("list_container", "")
tr_cls = getattr(styles, "tr", "") if hasattr(styles, "tr") else styles.get("tr", "")
pill_cls = getattr(styles, "pill", "") if hasattr(styles, "pill") else styles.get("pill", "")
action_btn = getattr(styles, "action_button", "") if hasattr(styles, "action_button") else styles.get("action_button", "")
hx_select = getattr(g, "hx_select_search", "#main-panel")
cal_slug = getattr(calendar, "slug", "")
eid = entry.id
parts = [f'<section id="tickets-table" 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-1/3">Name</th>'
'<th class="text-left p-2 w-1/4">Cost</th>'
'<th class="text-left p-2 w-1/4">Count</th>'
'<th class="text-left p-2 w-1/6">Actions</th>'
'</tr></thead><tbody>'
)
if ticket_types:
for tt in ticket_types:
tt_href = url_for(
"calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.get",
calendar_slug=cal_slug, year=year, month=month, day=day,
entry_id=eid, ticket_type_id=tt.id,
)
del_url = url_for(
"calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.delete",
calendar_slug=cal_slug, year=year, month=month, day=day,
entry_id=eid, ticket_type_id=tt.id,
)
cost = getattr(tt, "cost", None)
cost_str = f"&pound;{cost:.2f}" if cost is not None else "&pound;0.00"
parts.append(
f'<tr class="{tr_cls}">'
f'<td class="p-2 align-top w-1/3"><div class="font-medium">'
f'<a href="{tt_href}" class="{pill_cls}" '
f'hx-get="{tt_href}" hx-target="#main-panel" hx-select="{hx_select}" hx-swap="outerHTML" hx-push-url="true">'
f'{escape(tt.name)}</a></div></td>'
f'<td class="p-2 align-top w-1/4">{cost_str}</td>'
f'<td class="p-2 align-top w-1/4">{tt.count}</td>'
f'<td class="p-2 align-top w-1/6">'
f'<button class="{action_btn}" '
f'data-confirm="true" data-confirm-title="Delete ticket type?" '
f'data-confirm-text="This action cannot be undone." '
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-target="#tickets-table" hx-select="#tickets-table" '
f"""hx-swap="outerHTML" hx-headers='{{"X-CSRFToken": "{csrf}"}}' hx-trigger="confirmed" """
f'type="button"><i class="fa-solid fa-trash"></i></button></td></tr>'
)
else:
parts.append('<tr><td colspan="4" class="p-3 text-stone-500">No ticket types yet.</td></tr>')
parts.append('</tbody></table>')
# 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'<div id="ticket-add-container" class="mt-4">'
f'<button class="{action_btn}" '
f'hx-get="{add_url}" hx-target="#ticket-add-container" hx-swap="innerHTML">'
'<i class="fa fa-plus"></i> Add ticket type</button></div>'
)
parts.append('</section>')
return "".join(parts)
# ---------------------------------------------------------------------------
# Buy result (ticket purchase confirmation)
# ---------------------------------------------------------------------------
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)
count = len(created_tickets)
suffix = "s" if count != 1 else ""
parts = [f'<div id="ticket-buy-{entry.id}" class="rounded-xl border border-emerald-200 bg-emerald-50 p-4">']
parts.append(
'<div class="flex items-center gap-2 mb-3">'
'<i class="fa fa-check-circle text-emerald-600" aria-hidden="true"></i>'
f'<span class="font-semibold text-emerald-800">{count} ticket{suffix} reserved</span></div>'
)
parts.append('<div class="space-y-2 mb-4">')
for ticket in created_tickets:
href = url_for("tickets.ticket_detail", code=ticket.code)
parts.append(
f'<a href="{href}" class="flex items-center justify-between p-2 rounded-lg bg-white border border-emerald-100 hover:border-emerald-300 transition text-sm">'
'<div class="flex items-center gap-2">'
'<i class="fa fa-ticket text-emerald-500" aria-hidden="true"></i>'
f'<span class="font-mono text-xs text-stone-500">{ticket.code[:12]}...</span></div>'
'<span class="text-xs text-emerald-600 font-medium">View ticket</span></a>'
)
parts.append('</div>')
if remaining is not None:
r_suffix = "s" if remaining != 1 else ""
parts.append(f'<p class="text-xs text-stone-500">{remaining} ticket{r_suffix} remaining</p>')
my_href = url_for("tickets.my_tickets")
parts.append(
'<div class="mt-3 flex gap-2">'
f'<a href="{my_href}" class="text-sm text-emerald-700 hover:text-emerald-900 underline">'
'View all my tickets</a></div>'
)
parts.append('</div>')
return html + "".join(parts)
# ---------------------------------------------------------------------------
# Buy form (ticket +/- controls)
# ---------------------------------------------------------------------------
def render_buy_form(entry, ticket_remaining, ticket_sold_count,
user_ticket_count, user_ticket_counts_by_type) -> str:
"""Render the ticket buy/adjust form with +/- controls."""
from quart import url_for
from shared.browser.app.csrf import generate_csrf_token
csrf = generate_csrf_token()
eid = entry.id
tp = getattr(entry, "ticket_price", None)
state = getattr(entry, "state", "")
ticket_types = getattr(entry, "ticket_types", None) or []
if tp is None:
return ""
if tp is not None and state != "confirmed":
return (
f'<div id="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"></i>'
'Tickets available once this event is confirmed.</div>'
)
adjust_url = url_for("tickets.adjust_quantity")
target = f"#ticket-buy-{eid}"
parts = [f'<div id="ticket-buy-{eid}" class="rounded-xl border border-stone-200 bg-white p-4">']
parts.append(
'<h3 class="text-sm font-semibold text-stone-700 mb-3">'
'<i class="fa fa-ticket mr-1" aria-hidden="true"></i>Tickets</h3>'
)
# Info line
info_parts = []
if ticket_sold_count:
info_parts.append(f'<span>{ticket_sold_count} sold</span>')
if ticket_remaining is not None:
info_parts.append(f'<span>{ticket_remaining} remaining</span>')
if user_ticket_count:
info_parts.append(
'<span class="text-emerald-600 font-medium">'
f'<i class="fa fa-shopping-cart text-[0.6rem]" aria-hidden="true"></i> {user_ticket_count} in basket</span>'
)
if info_parts:
parts.append(f'<div class="flex items-center gap-3 mb-3 text-xs text-stone-500">{"".join(info_parts)}</div>')
active_types = [tt for tt in ticket_types if getattr(tt, "deleted_at", None) is None]
if active_types:
# Multiple ticket types
parts.append('<div class="space-y-2">')
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"&pound;{tt.cost:.2f}" if tt.cost is not None else "&pound;0.00"
parts.append(
'<div class="flex items-center justify-between p-3 rounded-lg bg-stone-50 border border-stone-100">'
f'<div><div class="font-medium text-sm">{escape(tt.name)}</div>'
f'<div class="text-xs text-stone-500">{cost_str}</div></div>'
)
parts.append(_ticket_adjust_controls(csrf, adjust_url, target, eid, type_count, ticket_type_id=tt.id))
parts.append('</div>')
parts.append('</div>')
else:
# Simple ticket
parts.append(
'<div class="flex items-center justify-between mb-4"><div>'
f'<span class="font-medium text-green-600">&pound;{tp:.2f}</span>'
'<span class="text-sm text-stone-500 ml-2">per ticket</span></div></div>'
)
qty = user_ticket_count or 0
parts.append(_ticket_adjust_controls(csrf, adjust_url, target, eid, qty))
parts.append('</div>')
return "".join(parts)
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'<input type="hidden" name="ticket_type_id" value="{ticket_type_id}" />' if ticket_type_id else ""
my_tickets_href = url_for("tickets.my_tickets")
if count == 0:
return (
f'<form hx-post="{adjust_url}" hx-target="{target}" hx-swap="outerHTML" class="flex items-center">'
f'<input type="hidden" name="csrf_token" value="{csrf}" />'
f'<input type="hidden" name="entry_id" value="{entry_id}" />'
f'{tt_hidden}'
'<input type="hidden" name="count" value="1" />'
'<button type="submit" class="relative inline-flex items-center justify-center text-sm font-medium text-stone-500 hover:bg-emerald-50 rounded p-1">'
'<i class="fa fa-cart-plus text-2xl" aria-hidden="true"></i></button></form>'
)
return (
'<div class="flex items-center gap-2">'
f'<form hx-post="{adjust_url}" hx-target="{target}" hx-swap="outerHTML">'
f'<input type="hidden" name="csrf_token" value="{csrf}" />'
f'<input type="hidden" name="entry_id" value="{entry_id}" />'
f'{tt_hidden}'
f'<input type="hidden" name="count" value="{count - 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>'
f'<a class="relative inline-flex items-center justify-center text-emerald-700" href="{my_tickets_href}">'
'<span class="relative inline-flex items-center justify-center">'
'<i class="fa-solid fa-shopping-cart text-2xl" 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">{count}</span>'
'</span></span></a>'
f'<form hx-post="{adjust_url}" hx-target="{target}" hx-swap="outerHTML">'
f'<input type="hidden" name="csrf_token" value="{csrf}" />'
f'<input type="hidden" name="entry_id" value="{entry_id}" />'
f'{tt_hidden}'
f'<input type="hidden" name="count" value="{count + 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>'
'</div>'
)
# ---------------------------------------------------------------------------
# Adjust response (OOB cart icon + buy form)
# ---------------------------------------------------------------------------
def render_adjust_response(entry, ticket_remaining, ticket_sold_count,
user_ticket_count, user_ticket_counts_by_type,
cart_count) -> str:
"""Render ticket adjust response: OOB cart icon + buy form."""
cart_html = _cart_icon_oob(cart_count)
form_html = render_buy_form(
entry, ticket_remaining, ticket_sold_count,
user_ticket_count, user_ticket_counts_by_type,
)
return cart_html + form_html
def _cart_icon_oob(count: int) -> str:
"""Render the OOB cart icon/badge swap."""
from quart import g
blog_url_fn = getattr(g, "blog_url", None)
cart_url_fn = getattr(g, "cart_url", None)
site_fn = getattr(g, "site", None)
logo = ""
if site_fn:
site_obj = site_fn() if callable(site_fn) else site_fn
logo = getattr(site_obj, "logo", "") if site_obj else ""
if count == 0:
blog_href = blog_url_fn("/") if blog_url_fn else "/"
return (
'<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">'
f'<a href="{blog_href}" class="h-full w-full font-bold text-5xl flex-shrink-0 flex flex-row items-center gap-1">'
f'<img src="{logo}" class="h-full w-full rounded-full object-cover border border-stone-300 flex-shrink-0"></a>'
'</div></div>'
)
cart_href = cart_url_fn("/") if cart_url_fn else "/"
return (
'<div id="cart-mini" hx-swap-oob="true">'
f'<a href="{cart_href}" class="relative inline-flex items-center justify-center text-stone-700 hover:text-emerald-700">'
'<i class="fa fa-shopping-cart text-5xl" aria-hidden="true"></i>'
'<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">'
f'{count}</span></a></div>'
)