Files
rose-ash/events/sexp/sexp_components.py
giles 0d1ce92e52 Fix sexp parse errors: avoid literal parentheses in sexp string args
The sexp parser doesn't handle "(" and ")" as string literals
inside expressions. Use raw! with pre-formatted strings instead.

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

3413 lines
148 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 sexp(
'(div :id pid :hx-swap-oob "outerHTML" :class "w-full"'
' (div :class "w-full"'
' (raw! rh)'
' (div :id cid)))',
pid=parent_id, cid=child_id, rh=row_html,
)
# ---------------------------------------------------------------------------
# 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_html = sexp(
'(<> (when fi (img :src fi :class "h-8 w-8 rounded-full object-cover border border-stone-300 flex-shrink-0"))'
' (span t))',
fi=feature_image, t=title,
)
nav_parts = []
page_cart_count = ctx.get("page_cart_count", 0)
if page_cart_count and page_cart_count > 0:
cart_href = call_url(ctx, "cart_url", f"/{slug}/")
nav_parts.append(sexp(
'(a :href ch :class "relative inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-full'
' border border-emerald-300 bg-emerald-50 text-emerald-800 hover:bg-emerald-100 transition"'
' (i :class "fa fa-shopping-cart" :aria-hidden "true")'
' (span cnt))',
ch=cart_href, cnt=str(page_cart_count),
))
# Post nav: calendar links + admin
nav_parts.append(_post_nav_html(ctx))
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=sexp('(<> (i :class "fa fa-calendar" :aria-hidden "true") (div "Calendars"))'),
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 = sexp(
'(div :class "flex flex-col md:flex-row md:gap-2 items-center min-w-0"'
' (div :class "flex flex-row items-center gap-2"'
' (i :class "fa fa-calendar")'
' (div :class "shrink-0" n))'
' (div :id "calendar-description-title"'
' :class "text-base font-normal break-words whitespace-normal min-w-0 break-all w-full text-center block"'
' d))',
n=cal_name, d=cal_desc,
)
# Desktop nav: slots + admin
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(sexp(
'(~nav-link :href h :icon "fa fa-cog" :select-colours sc)',
h=admin_href, sc=select_colours,
))
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 = sexp(
'(div :class "flex gap-1 items-center"'
' (i :class "fa fa-calendar-day")'
' (span d))',
d=day_date.strftime("%A %d %B %Y"),
)
nav_html = _day_nav_html(ctx)
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:
entry_links = []
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,
)
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 ""
entry_links.append(sexp(
'(a :href h :class "flex items-center gap-2 px-3 py-2 hover:bg-stone-100 rounded transition text-sm border sm:whitespace-nowrap sm:flex-shrink-0"'
' (div :class "flex-1 min-w-0"'
' (div :class "font-medium truncate" n)'
' (div :class "text-xs text-stone-600 truncate" t)))',
h=href, n=entry.name, t=f"{start}{end}",
))
inner = "".join(entry_links)
parts.append(sexp(
'(div :class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"'
' :id "day-entries-nav-wrapper"'
' (div :class "flex overflow-x-auto gap-1 scrollbar-thin"'
' (raw! inner)))',
inner=inner,
))
if is_admin and day_date:
admin_href = url_for(
"calendars.calendar.day.admin.admin",
calendar_slug=cal_slug,
year=day_date.year,
month=day_date.month,
day=day_date.day,
)
parts.append(sexp(
'(~nav-link :href h :icon "fa fa-cog")',
h=admin_href,
))
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=sexp('(<> (i :class "fa fa-shopping-bag" :aria-hidden "true") (div "Markets"))'),
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=sexp('(<> (i :class "fa fa-credit-card" :aria-hidden "true") (div "Payments"))'),
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 []
form_html = ""
if can_create:
create_url = url_for("calendars.create_calendar")
form_html = sexp(
'(<>'
' (div :id "cal-create-errors" :class "mt-2 text-sm text-red-600")'
' (form :class "mt-4 flex gap-2 items-end" :hx-post cu'
' :hx-target "#calendars-list" :hx-select "#calendars-list" :hx-swap "outerHTML"'
""" :hx-on::before-request "document.querySelector('#cal-create-errors').textContent='';" """
""" :hx-on::response-error "document.querySelector('#cal-create-errors').innerHTML = event.detail.xhr.responseText;" """
' (input :type "hidden" :name "csrf_token" :value csrf)'
' (div :class "flex-1"'
' (label :class "block text-sm text-gray-600" "Name")'
' (input :name "name" :type "text" :required true :class "w-full border rounded px-3 py-2"'
' :placeholder "e.g. Events, Gigs, Meetings"))'
' (button :type "submit" :class "border rounded px-3 py-2" "Add calendar")))',
cu=create_url, csrf=csrf,
)
list_html = _calendars_list_html(ctx, calendars)
return sexp(
'(section :class "p-4"'
' (raw! fh)'
' (div :id "calendars-list" :class "mt-6" (raw! lh)))',
fh=form_html, lh=list_html,
)
def _calendars_list_html(ctx: dict, calendars: list) -> str:
"""Render the calendars list items."""
from quart import url_for
from shared.utils import route_prefix
csrf_token = ctx.get("csrf_token")
csrf = csrf_token() if callable(csrf_token) else (csrf_token or "")
prefix = route_prefix()
if not calendars:
return sexp('(p :class "text-gray-500 mt-4" "No calendars yet. Create one above.")')
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)
csrf_hdr = f'{{"X-CSRFToken":"{csrf}"}}'
parts.append(sexp(
'(div :class "mt-6 border rounded-lg p-4"'
' (div :class "flex items-center justify-between gap-3"'
' (a :class "flex items-baseline gap-3" :href h'
' :hx-get h :hx-target "#main-panel" :hx-select "#main-panel" :hx-swap "outerHTML" :hx-push-url "true"'
' (h3 :class "font-semibold" cn)'
' (h4 :class "text-gray-500" (str "/" cs "/")))'
' (button :class "text-sm border rounded px-3 py-1 hover:bg-red-50 hover:border-red-400"'
' :data-confirm true :data-confirm-title "Delete calendar?"'
' :data-confirm-text "Entries will be hidden (soft delete)"'
' :data-confirm-icon "warning" :data-confirm-confirm-text "Yes, delete it"'
' :data-confirm-cancel-text "Cancel" :data-confirm-event "confirmed"'
' :hx-delete du :hx-trigger "confirmed"'
' :hx-target "#calendars-list" :hx-select "#calendars-list" :hx-swap "outerHTML"'
' :hx-headers ch'
' (i :class "fa-solid fa-trash"))))',
h=href, cn=cal_name, cs=cal_slug, du=del_url, ch=csrf_hdr,
))
return "".join(parts)
# ---------------------------------------------------------------------------
# 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):
return url_for("calendars.calendar.get", calendar_slug=cal_slug, year=y, month=m)
# Month navigation arrows
nav_arrows = []
for label, yr, mn in [
("\u00ab", prev_year, month),
("\u2039", prev_month_year, prev_month),
]:
href = nav_link(yr, mn)
nav_arrows.append(sexp(
'(a :class (str pc " text-xl") :href h'
' :hx-get h :hx-target "#main-panel" :hx-select "#main-panel" :hx-swap "outerHTML" :hx-push-url "true" l)',
pc=pill_cls, h=href, l=label,
))
nav_arrows.append(sexp('(div :class "px-3 font-medium" (str mn " " yr))', mn=month_name, yr=str(year)))
for label, yr, mn in [
("\u203a", next_month_year, next_month),
("\u00bb", next_year, month),
]:
href = nav_link(yr, mn)
nav_arrows.append(sexp(
'(a :class (str pc " text-xl") :href h'
' :hx-get h :hx-target "#main-panel" :hx-select "#main-panel" :hx-swap "outerHTML" :hx-push-url "true" l)',
pc=pill_cls, h=href, l=label,
))
# Weekday headers
wd_html = "".join(sexp('(div :class "py-1" w)', w=wd) for wd in weekday_names)
# Day cells
cells = []
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"
# Day number link
day_num_html = ""
day_short_html = ""
if day_date:
day_href = url_for(
"calendars.calendar.day.show_day",
calendar_slug=cal_slug,
year=day_date.year, month=day_date.month, day=day_date.day,
)
day_short_html = sexp(
'(span :class "sm:hidden text-[16px] text-stone-500" d)',
d=day_date.strftime("%a"),
)
day_num_html = sexp(
'(a :class pc :href h :hx-get h :hx-target "#main-panel" :hx-select "#main-panel"'
' :hx-swap "outerHTML" :hx-push-url "true" n)',
pc=pill_cls, h=day_href, n=str(day_date.day),
)
# Entry badges for this day
entry_badges = []
if day_date:
for e in month_entries:
if e.start_at and e.start_at.date() == day_date:
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("_", " ")
entry_badges.append(sexp(
'(div :class (str "flex items-center justify-between gap-1 text-[11px] rounded px-1 py-0.5 " bc)'
' (span :class "truncate" n)'
' (span :class "shrink-0 text-[10px] font-semibold uppercase tracking-tight" sl))',
bc=bg_cls, n=e.name, sl=state_label,
))
badges_html = "".join(entry_badges)
cells.append(sexp(
'(div :class cc'
' (div :class "flex justify-between items-center"'
' (div :class "flex flex-col" (raw! dsh) (raw! dnh)))'
' (div :class "mt-1 space-y-0.5" (raw! bh)))',
cc=cell_cls, dsh=day_short_html, dnh=day_num_html, bh=badges_html,
))
cells_html = "".join(cells)
arrows_html = "".join(nav_arrows)
return sexp(
'(section :class "bg-orange-100"'
' (header :class "flex items-center justify-center mt-2"'
' (nav :class "flex items-center gap-2 text-2xl" (raw! ah)))'
' (div :class "rounded-2xl border border-stone-200 bg-white/80 p-4"'
' (div :class "hidden sm:grid grid-cols-7 text-center text-md font-semibold text-stone-700 mb-2" (raw! wh))'
' (div :class "grid grid-cols-1 sm:grid-cols-7 gap-px bg-stone-200 rounded-xl overflow-hidden" (raw! ch))))',
ah=arrows_html, wh=wd_html, ch=cells_html,
)
# ---------------------------------------------------------------------------
# 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", "")
rows_html = ""
if day_entries:
rows_html = "".join(_day_row_html(ctx, entry) for entry in day_entries)
else:
rows_html = sexp('(tr (td :colspan "6" :class "p-3 text-stone-500" "No entries yet."))')
add_url = url_for(
"calendars.calendar.day.calendar_entries.add_form",
calendar_slug=cal_slug,
day=day, month=month, year=year,
)
return sexp(
'(section :id "day-entries" :class lc'
' (table :class "w-full text-sm border table-fixed"'
' (thead :class "bg-stone-100"'
' (tr'
' (th :class "p-2 text-left w-2/6" "Name")'
' (th :class "text-left p-2 w-1/6" "Slot/Time")'
' (th :class "text-left p-2 w-1/6" "State")'
' (th :class "text-left p-2 w-1/6" "Cost")'
' (th :class "text-left p-2 w-1/6" "Tickets")'
' (th :class "text-left p-2 w-1/6" "Actions")))'
' (tbody (raw! rh)))'
' (div :id "entry-add-container" :class "mt-4"'
' (button :type "button" :class pa'
' :hx-get au :hx-target "#entry-add-container" :hx-swap "innerHTML"'
' "+ Add entry")))',
lc=list_container, rh=rows_html, pa=pre_action, au=add_url,
)
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 = sexp(
'(td :class "p-2 align-top w-2/6" (div :class "font-medium"'
' (a :href h :class pc :hx-get h :hx-target "#main-panel" :hx-select "#main-panel"'
' :hx-swap "outerHTML" :hx-push-url "true" n)))',
h=entry_href, pc=pill_cls, n=entry.name,
)
# Slot/Time
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" \u2192 {slot.time_end.strftime('%H:%M')}" if slot.time_end else ""
slot_html = sexp(
'(td :class "p-2 align-top w-1/6" (div :class "text-xs font-medium"'
' (a :href h :class pc :hx-get h :hx-target "#main-panel" :hx-select "#main-panel"'
' :hx-swap "outerHTML" :hx-push-url "true" sn)'
' (span :class "text-stone-600 font-normal" (raw! time-str))))',
h=slot_href, pc=pill_cls, sn=slot.name,
**{"time-str": f"({time_start}{time_end})"},
)
else:
start = entry.start_at.strftime("%H:%M") if entry.start_at else ""
end = f" \u2192 {entry.end_at.strftime('%H:%M')}" if entry.end_at else ""
slot_html = sexp(
'(td :class "p-2 align-top w-1/6" (div :class "text-xs text-stone-600" (str s e)))',
s=start, e=end,
)
# State
state = getattr(entry, "state", "pending") or "pending"
state_badge = _entry_state_badge_html(state)
state_td = sexp(
'(td :class "p-2 align-top w-1/6" (div :id sid (raw! sb)))',
sid=f"entry-state-{entry.id}", sb=state_badge,
)
# Cost
cost = getattr(entry, "cost", None)
cost_str = f"\u00a3{cost:.2f}" if cost is not None else "\u00a30.00"
cost_td = sexp('(td :class "p-2 align-top w-1/6" (span :class "font-medium text-green-600" c))', c=cost_str)
# Tickets
tp = getattr(entry, "ticket_price", None)
if tp is not None:
tc = getattr(entry, "ticket_count", None)
tc_str = f"{tc} tickets" if tc is not None else "Unlimited"
tickets_td = sexp(
'(td :class "p-2 align-top w-1/6" (div :class "text-xs space-y-1"'
' (div :class "font-medium text-green-600" tp)'
' (div :class "text-stone-600" tc)))',
tp=f"\u00a3{tp:.2f}", tc=tc_str,
)
else:
tickets_td = sexp('(td :class "p-2 align-top w-1/6" (span :class "text-xs text-stone-400" "No tickets"))')
actions_td = sexp('(td :class "p-2 align-top w-1/6")')
return sexp(
'(tr :class tc (raw! nh) (raw! sh) (raw! std) (raw! ctd) (raw! ttd) (raw! atd))',
tc=tr_cls, nh=name_html, sh=slot_html, std=state_td, ctd=cost_td, ttd=tickets_td, atd=actions_td,
)
def _entry_state_badge_html(state: str) -> str:
"""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 sexp(
'(span :class (str "inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium " c) l)',
c=cls, l=label,
)
# ---------------------------------------------------------------------------
# Day admin main panel
# ---------------------------------------------------------------------------
def _day_admin_main_panel_html(ctx: dict) -> str:
"""Render day admin panel (placeholder nav)."""
return sexp('(div :class "p-4 text-sm text-stone-500" "Admin options")')
# ---------------------------------------------------------------------------
# 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)
return sexp(
'(section :class "max-w-3xl mx-auto p-4 space-y-10"'
' (div'
' (h2 :class "text-xl font-semibold" "Calendar configuration")'
' (div :id "cal-put-errors" :class "mt-2 text-sm text-red-600")'
' (div (label :class "block text-sm font-medium text-stone-700" "Description")'
' (raw! dh))'
' (form :id "calendar-form" :method "post" :hx-target "#main-panel" :hx-select "#main-panel"'
""" :hx-on::before-request "document.querySelector('#cal-put-errors').textContent='';" """
""" :hx-on::response-error "document.querySelector('#cal-put-errors').innerHTML = event.detail.xhr.responseText;" """
""" :hx-on::after-request "if (event.detail.successful) this.reset()" """
' :class "hidden space-y-4 mt-4" :autocomplete "off"'
' (input :type "hidden" :name "csrf_token" :value csrf)'
' (div (label :class "block text-sm font-medium text-stone-700" "Description")'
' (div d)'
' (textarea :name "description" :autocomplete "off" :rows "4" :class "w-full p-2 border rounded" d))'
' (div (button :class "px-3 py-2 rounded bg-stone-800 text-white" "Save"))))'
' (hr :class "border-stone-200"))',
dh=description_html, csrf=csrf, d=desc,
)
def _calendar_description_display_html(calendar, edit_url: str) -> str:
"""Render calendar description display with edit button."""
desc = getattr(calendar, "description", "") or ""
return sexp(
'(div :id "calendar-description"'
' (if d'
' (p :class "text-stone-700 whitespace-pre-line break-all" d)'
' (p :class "text-stone-400 italic" "No description yet."))'
' (button :type "button" :class "mt-2 text-xs underline"'
' :hx-get eu :hx-target "#calendar-description" :hx-swap "outerHTML"'
' (i :class "fas fa-edit")))',
d=desc, eu=edit_url,
)
# ---------------------------------------------------------------------------
# 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 []
form_html = ""
if can_create:
create_url = url_for("markets.create_market")
form_html = sexp(
'(<>'
' (div :id "market-create-errors" :class "mt-2 text-sm text-red-600")'
' (form :class "mt-4 flex gap-2 items-end" :hx-post cu'
' :hx-target "#markets-list" :hx-select "#markets-list" :hx-swap "outerHTML"'
""" :hx-on::before-request "document.querySelector('#market-create-errors').textContent='';" """
""" :hx-on::response-error "document.querySelector('#market-create-errors').innerHTML = event.detail.xhr.responseText;" """
' (input :type "hidden" :name "csrf_token" :value csrf)'
' (div :class "flex-1"'
' (label :class "block text-sm text-gray-600" "Name")'
' (input :name "name" :type "text" :required true :class "w-full border rounded px-3 py-2"'
' :placeholder "e.g. Farm Shop, Bakery"))'
' (button :type "submit" :class "border rounded px-3 py-2" "Add market")))',
cu=create_url, csrf=csrf,
)
list_html = _markets_list_html(ctx, markets)
return sexp(
'(section :class "p-4"'
' (raw! fh)'
' (div :id "markets-list" :class "mt-6" (raw! lh)))',
fh=form_html, lh=list_html,
)
def _markets_list_html(ctx: dict, markets: list) -> str:
"""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 sexp('(p :class "text-gray-500 mt-4" "No markets yet. Create one above.")')
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)
csrf_hdr = f'{{"X-CSRFToken":"{csrf}"}}'
parts.append(sexp(
'(div :class "mt-6 border rounded-lg p-4"'
' (div :class "flex items-center justify-between gap-3"'
' (a :class "flex items-baseline gap-3" :href h'
' (h3 :class "font-semibold" mn)'
' (h4 :class "text-gray-500" (str "/" ms "/")))'
' (button :class "text-sm border rounded px-3 py-1 hover:bg-red-50 hover:border-red-400"'
' :data-confirm true :data-confirm-title "Delete market?"'
' :data-confirm-text "Products will be hidden (soft delete)"'
' :data-confirm-icon "warning" :data-confirm-confirm-text "Yes, delete it"'
' :data-confirm-cancel-text "Cancel" :data-confirm-event "confirmed"'
' :hx-delete du :hx-trigger "confirmed"'
' :hx-target "#markets-list" :hx-select "#markets-list" :hx-swap "outerHTML"'
' :hx-headers ch'
' (i :class "fa-solid fa-trash"))))',
h=market_href, mn=m_name, ms=m_slug, du=del_url, ch=csrf_hdr,
))
return "".join(parts)
# ---------------------------------------------------------------------------
# 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_..."
input_cls = "w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500"
return sexp(
'(section :class "p-4 max-w-lg mx-auto"'
' (div :id "payments-panel" :class "space-y-4 p-4 bg-white rounded-lg border border-stone-200"'
' (h3 :class "text-lg font-semibold text-stone-800"'
' (i :class "fa fa-credit-card text-purple-600 mr-1") " SumUp Payment")'
' (p :class "text-xs text-stone-400" "Configure per-page SumUp credentials. Leave blank to use the global merchant account.")'
' (form :hx-put uu :hx-target "#payments-panel" :hx-swap "outerHTML" :hx-select "#payments-panel" :class "space-y-3"'
' (input :type "hidden" :name "csrf_token" :value csrf)'
' (div (label :class "block text-xs font-medium text-stone-600 mb-1" "Merchant Code")'
' (input :type "text" :name "merchant_code" :value mc :placeholder "e.g. ME4J6100" :class ic))'
' (div (label :class "block text-xs font-medium text-stone-600 mb-1" "API Key")'
' (input :type "password" :name "api_key" :value "" :placeholder ph :class ic)'
' (when sc (p :class "text-xs text-stone-400 mt-0.5" "Key is set. Leave blank to keep current key.")))'
' (div (label :class "block text-xs font-medium text-stone-600 mb-1" "Checkout Reference Prefix")'
' (input :type "text" :name "checkout_prefix" :value cp :placeholder "e.g. ROSE-" :class ic))'
' (button :type "submit" :class "px-4 py-1.5 text-sm font-medium text-white bg-purple-600 rounded hover:bg-purple-700 focus:ring-2 focus:ring-purple-500"'
' "Save SumUp Settings")'
' (when sc (span :class "ml-2 text-xs text-green-600"'
' (i :class "fa fa-check-circle") " Connected")))))',
uu=update_url, csrf=csrf, mc=merchant_code, ph=placeholder,
ic=input_cls, sc=sumup_configured, cp=checkout_prefix,
)
# ---------------------------------------------------------------------------
# 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 sexp(
'(span :class (str "inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium " c) l)',
c=cls, l=label,
)
# ---------------------------------------------------------------------------
# Tickets main panel (my tickets)
# ---------------------------------------------------------------------------
def _tickets_main_panel_html(ctx: dict, tickets: list) -> str:
"""Render my tickets list."""
from quart import url_for
ticket_cards = []
if tickets:
for ticket in tickets:
href = url_for("tickets.ticket_detail", code=ticket.code)
entry = getattr(ticket, "entry", None)
entry_name = entry.name if entry else "Unknown event"
tt = getattr(ticket, "ticket_type", None)
state = getattr(ticket, "state", "")
cal = getattr(entry, "calendar", None) if entry else None
time_str = ""
if entry and entry.start_at:
time_str = entry.start_at.strftime("%A, %B %d, %Y at %H:%M")
if entry.end_at:
time_str += f" \u2013 {entry.end_at.strftime('%H:%M')}"
ticket_cards.append(sexp(
'(a :href h :class "block rounded-xl border border-stone-200 bg-white p-4 hover:shadow-md transition"'
' (div :class "flex items-start justify-between gap-4"'
' (div :class "flex-1 min-w-0"'
' (div :class "font-semibold text-lg truncate" en)'
' (when tn (div :class "text-sm text-stone-600 mt-0.5" tn))'
' (when ts (div :class "text-sm text-stone-500 mt-1" ts))'
' (when cn (div :class "text-xs text-stone-400 mt-0.5" cn)))'
' (div :class "flex flex-col items-end gap-1 flex-shrink-0"'
' (raw! sb)'
' (span :class "text-xs text-stone-400 font-mono" (str cc "...")))))',
h=href, en=entry_name,
tn=tt.name if tt else None,
ts=time_str or None,
cn=cal.name if cal else None,
sb=_ticket_state_badge_html(state),
cc=ticket.code[:8],
))
cards_html = "".join(ticket_cards)
return sexp(
'(section :id "tickets-list" :class lc'
' (h1 :class "text-2xl font-bold mb-6" "My Tickets")'
' (if has'
' (div :class "space-y-4" (raw! ch))'
' (div :class "text-center py-12 text-stone-500"'
' (i :class "fa fa-ticket text-4xl mb-4 block" :aria-hidden "true")'
' (p :class "text-lg" "No tickets yet")'
' (p :class "text-sm mt-1" "Tickets will appear here after you purchase them."))))',
lc=_list_container(ctx), has=bool(tickets), ch=cards_html,
)
# ---------------------------------------------------------------------------
# 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
cal = getattr(entry, "calendar", None) if entry else None
checked_in_at = getattr(ticket, "checked_in_at", None)
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")
# Badge with larger sizing
badge = _ticket_state_badge_html(state).replace('px-2 py-0.5 text-xs', 'px-3 py-1 text-sm')
# Time info
time_date = entry.start_at.strftime("%A, %B %d, %Y") if entry and entry.start_at else None
time_range = entry.start_at.strftime("%H:%M") if entry and entry.start_at else None
if time_range and entry.end_at:
time_range += f" \u2013 {entry.end_at.strftime('%H:%M')}"
tt_desc = f"{tt.name} \u2014 \u00a3{tt.cost:.2f}" if tt and getattr(tt, "cost", None) else None
checkin_str = checked_in_at.strftime("Checked in: %B %d, %Y at %H:%M") if checked_in_at else None
qr_script = (
f"(function(){{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)}});"
"}})()"
)
return sexp(
'(section :id "ticket-detail" :class (str lc " max-w-lg mx-auto")'
' (a :href bh :class "inline-flex items-center gap-1 text-sm text-stone-500 hover:text-stone-700 mb-4"'
' (i :class "fa fa-arrow-left" :aria-hidden "true") " Back to my tickets")'
' (div :class "rounded-2xl border border-stone-200 bg-white overflow-hidden"'
' (div :class (str "px-6 py-4 border-b border-stone-100 " hbg)'
' (div :class "flex items-center justify-between"'
' (h1 :class "text-xl font-bold" en)'
' (raw! bdg))'
' (when tn (div :class "text-sm text-stone-600 mt-1" tn)))'
' (div :class "px-6 py-8 flex flex-col items-center border-b border-stone-100"'
' (div :id (str "ticket-qr-" cd) :class "bg-white p-4 rounded-lg border border-stone-200")'
' (p :class "text-xs text-stone-400 mt-3 font-mono select-all" cd))'
' (div :class "px-6 py-4 space-y-3"'
' (when td (div :class "flex items-start gap-3"'
' (i :class "fa fa-calendar text-stone-400 mt-0.5" :aria-hidden "true")'
' (div (div :class "text-sm font-medium" td)'
' (div :class "text-sm text-stone-500" tr))))'
' (when cn (div :class "flex items-start gap-3"'
' (i :class "fa fa-map-pin text-stone-400 mt-0.5" :aria-hidden "true")'
' (div :class "text-sm" cn)))'
' (when ttd (div :class "flex items-start gap-3"'
' (i :class "fa fa-tag text-stone-400 mt-0.5" :aria-hidden "true")'
' (div :class "text-sm" ttd)))'
' (when cs (div :class "flex items-start gap-3"'
' (i :class "fa fa-check-circle text-blue-500 mt-0.5" :aria-hidden "true")'
' (div :class "text-sm text-blue-700" cs)))))'
' (script :src "https://cdn.jsdelivr.net/npm/qrcode@1.5.3/build/qrcode.min.js")'
' (script qs))',
lc=_list_container(ctx), bh=back_href, hbg=header_bg,
en=entry_name, bdg=badge,
tn=tt.name if tt else None,
cd=code, td=time_date, tr=time_range,
cn=cal.name if cal else None,
ttd=tt_desc, cs=checkin_str, qs=qr_script,
)
# ---------------------------------------------------------------------------
# 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")
# Stats cards
stats_html = ""
for label, key, border, bg, text_cls in [
("Total", "total", "border-stone-200", "", "text-stone-900"),
("Confirmed", "confirmed", "border-emerald-200", "bg-emerald-50", "text-emerald-700"),
("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"
stats_html += sexp(
'(div :class (str "rounded-xl border " b " " bg " p-4 text-center")'
' (div :class (str "text-2xl font-bold " tc) v)'
' (div :class (str "text-xs " lc " uppercase tracking-wide") l))',
b=border, bg=bg, tc=text_cls, v=str(val), lc=lbl_cls, l=label,
)
# Ticket rows
rows_html = ""
for ticket in tickets:
entry = getattr(ticket, "entry", None)
tt = getattr(ticket, "ticket_type", None)
state = getattr(ticket, "state", "")
code = ticket.code
date_html = ""
if entry and entry.start_at:
date_html = sexp(
'(div :class "text-xs text-stone-500" d)',
d=entry.start_at.strftime("%d %b %Y, %H:%M"),
)
action_html = ""
if state in ("confirmed", "reserved"):
checkin_url = url_for("ticket_admin.do_checkin", code=code)
action_html = sexp(
'(form :hx-post cu :hx-target (str "#ticket-row-" c) :hx-swap "outerHTML"'
' (input :type "hidden" :name "csrf_token" :value csrf)'
' (button :type "submit" :class "px-3 py-1 bg-blue-600 text-white text-xs rounded hover:bg-blue-700 transition"'
' (i :class "fa fa-check mr-1" :aria-hidden "true") "Check in"))',
cu=checkin_url, c=code, csrf=csrf,
)
elif state == "checked_in":
checked_in_at = getattr(ticket, "checked_in_at", None)
t_str = checked_in_at.strftime("%H:%M") if checked_in_at else ""
action_html = sexp(
'(span :class "text-xs text-blue-600"'
' (i :class "fa fa-check-circle" :aria-hidden "true") (str " " ts))',
ts=t_str,
)
rows_html += sexp(
'(tr :class "hover:bg-stone-50 transition" :id (str "ticket-row-" c)'
' (td :class "px-4 py-3" (span :class "font-mono text-xs" cs))'
' (td :class "px-4 py-3" (div :class "font-medium" en) (raw! dh))'
' (td :class "px-4 py-3 text-sm" tn)'
' (td :class "px-4 py-3" (raw! sb))'
' (td :class "px-4 py-3" (raw! ah)))',
c=code, cs=code[:12] + "...",
en=entry.name if entry else "",
dh=date_html, tn=tt.name if tt else "",
sb=_ticket_state_badge_html(state), ah=action_html,
)
return sexp(
'(section :id "ticket-admin" :class lc'
' (h1 :class "text-2xl font-bold mb-6" "Ticket Admin")'
' (div :class "grid grid-cols-2 sm:grid-cols-4 gap-3 mb-8" (raw! sh))'
' (div :class "rounded-xl border border-stone-200 bg-white p-6 mb-8"'
' (h2 :class "text-lg font-semibold mb-4"'
' (i :class "fa fa-qrcode mr-2" :aria-hidden "true") "Scan / Look Up Ticket")'
' (div :class "flex gap-3 mb-4"'
' (input :type "text" :id "ticket-code-input" :name "code"'
' :placeholder "Enter or scan ticket code..."'
' :class "flex-1 px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"'
' :hx-get lu :hx-trigger "keyup changed delay:300ms"'
' :hx-target "#lookup-result" :hx-include "this" :autofocus "true")'
' (button :type "button"'
' :class "px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition"'
""" :onclick "document.getElementById('ticket-code-input').dispatchEvent(new Event('keyup'))" """
' (i :class "fa fa-search" :aria-hidden "true")))'
' (div :id "lookup-result"'
' (div :class "text-sm text-stone-400 text-center py-4" "Enter a ticket code to look it up")))'
' (div :class "rounded-xl border border-stone-200 bg-white overflow-hidden"'
' (h2 :class "text-lg font-semibold px-6 py-4 border-b border-stone-100" "Recent Tickets")'
' (if has-tickets'
' (div :class "overflow-x-auto"'
' (table :class "w-full text-sm"'
' (thead :class "bg-stone-50"'
' (tr (th :class "px-4 py-3 text-left font-medium text-stone-600" "Code")'
' (th :class "px-4 py-3 text-left font-medium text-stone-600" "Event")'
' (th :class "px-4 py-3 text-left font-medium text-stone-600" "Type")'
' (th :class "px-4 py-3 text-left font-medium text-stone-600" "State")'
' (th :class "px-4 py-3 text-left font-medium text-stone-600" "Actions")))'
' (tbody :class "divide-y divide-stone-100" (raw! rh))))'
' (div :class "px-6 py-8 text-center text-stone-500" "No tickets yet"))))',
lc=_list_container(ctx), sh=stats_html, lu=lookup_url,
**{"has-tickets": bool(tickets)}, rh=rows_html,
)
# ---------------------------------------------------------------------------
# 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}/{entry.calendar_slug}/day/{entry.start_at.strftime('%Y/%-m/%-d')}/")
entry_href = f"{day_href}entries/{entry.id}/" if day_href else ""
# Title (linked or plain)
title_html = sexp(
'(if eh (a :href eh :class "hover:text-emerald-700"'
' (h2 :class "text-lg font-semibold text-stone-900" n))'
' (h2 :class "text-lg font-semibold text-stone-900" n))',
eh=entry_href or False, n=entry.name,
)
# Badges
badges_html = ""
if page_title and (not is_page_scoped or page_title != (post or {}).get("title")):
page_href = events_url_fn(f"/{page_slug}/")
badges_html += sexp(
'(a :href ph :class "inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 hover:bg-amber-200" pt)',
ph=page_href, pt=page_title,
)
cal_name = getattr(entry, "calendar_name", "")
if cal_name:
badges_html += sexp(
'(span :class "inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-sky-100 text-sky-700" cn)',
cn=cal_name,
)
# Time line
time_parts = ""
if day_href and not is_page_scoped:
time_parts += sexp(
'(<> (a :href dh :class "hover:text-stone-700" ds) (raw! " &middot; "))',
dh=day_href, ds=entry.start_at.strftime("%a %-d %b"),
)
elif not is_page_scoped:
time_parts += sexp(
'(<> (span ds) (raw! " &middot; "))',
ds=entry.start_at.strftime("%a %-d %b"),
)
time_parts += entry.start_at.strftime("%H:%M")
if entry.end_at:
time_parts += f' \u2013 {entry.end_at.strftime("%H:%M")}'
cost = getattr(entry, "cost", None)
cost_html = sexp('(div :class "mt-1 text-sm font-medium text-green-600" (raw! c))',
c=f"&pound;{cost:.2f}") if cost else ""
# Ticket widget
tp = getattr(entry, "ticket_price", None)
widget_html = ""
if tp is not None:
qty = pending_tickets.get(entry.id, 0)
widget_html = sexp(
'(div :class "shrink-0" (raw! w))',
w=_ticket_widget_html(entry, qty, ticket_url, ctx={}),
)
return sexp(
'(article :class "rounded-xl bg-white shadow-sm border border-stone-200 p-4"'
' (div :class "flex flex-col sm:flex-row sm:items-start justify-between gap-3"'
' (div :class "flex-1 min-w-0"'
' (raw! th)'
' (div :class "flex flex-wrap items-center gap-1.5 mt-1" (raw! bh))'
' (div :class "mt-1 text-sm text-stone-500" (raw! tp))'
' (raw! ch))'
' (raw! wh)))',
th=title_html, bh=badges_html, tp=time_parts, ch=cost_html, wh=widget_html,
)
def _entry_card_tile_html(entry, page_info: dict, pending_tickets: dict,
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}/{entry.calendar_slug}/day/{entry.start_at.strftime('%Y/%-m/%-d')}/")
entry_href = f"{day_href}entries/{entry.id}/" if day_href else ""
# Title
title_html = sexp(
'(if eh (a :href eh :class "hover:text-emerald-700"'
' (h2 :class "text-base font-semibold text-stone-900 line-clamp-2" n))'
' (h2 :class "text-base font-semibold text-stone-900 line-clamp-2" n))',
eh=entry_href or False, n=entry.name,
)
# Badges
badges_html = ""
if page_title and (not is_page_scoped or page_title != (post or {}).get("title")):
page_href = events_url_fn(f"/{page_slug}/")
badges_html += sexp(
'(a :href ph :class "inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 hover:bg-amber-200" pt)',
ph=page_href, pt=page_title,
)
cal_name = getattr(entry, "calendar_name", "")
if cal_name:
badges_html += sexp(
'(span :class "inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-sky-100 text-sky-700" cn)',
cn=cal_name,
)
# Time
time_html = ""
if day_href:
time_html += sexp('(a :href dh :class "hover:text-stone-700" ds)', dh=day_href, ds=entry.start_at.strftime("%a %-d %b"))
else:
time_html += entry.start_at.strftime("%a %-d %b")
time_html += f' \u00b7 {entry.start_at.strftime("%H:%M")}'
if entry.end_at:
time_html += f' \u2013 {entry.end_at.strftime("%H:%M")}'
cost = getattr(entry, "cost", None)
cost_html = sexp('(div :class "mt-1 text-sm font-medium text-green-600" (raw! c))',
c=f"&pound;{cost:.2f}") if cost else ""
# Ticket widget
tp = getattr(entry, "ticket_price", None)
widget_html = ""
if tp is not None:
qty = pending_tickets.get(entry.id, 0)
widget_html = sexp(
'(div :class "border-t border-stone-100 px-3 py-2" (raw! w))',
w=_ticket_widget_html(entry, qty, ticket_url, ctx={}),
)
return sexp(
'(article :class "rounded-xl bg-white shadow-sm border border-stone-200 overflow-hidden"'
' (div :class "p-3"'
' (raw! th)'
' (div :class "flex flex-wrap items-center gap-1 mt-1" (raw! bh))'
' (div :class "mt-1 text-xs text-stone-500" (raw! tm))'
' (raw! ch))'
' (raw! wh))',
th=title_html, bh=badges_html, tm=time_html, ch=cost_html, wh=widget_html,
)
def _ticket_widget_html(entry, qty: int, ticket_url: str, *, ctx: dict) -> str:
"""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:
try:
from flask_wtf.csrf import generate_csrf
csrf_token_val = generate_csrf()
except Exception:
pass
eid = entry.id
tp = getattr(entry, "ticket_price", 0) or 0
tgt = f"#page-ticket-{eid}"
def _tw_form(count_val, btn_html):
return sexp(
'(form :action tu :method "post" :hx-post tu :hx-target tgt :hx-swap "outerHTML"'
' (input :type "hidden" :name "csrf_token" :value csrf)'
' (input :type "hidden" :name "entry_id" :value eid)'
' (input :type "hidden" :name "count" :value cv)'
' (raw! bh))',
tu=ticket_url, tgt=tgt, csrf=csrf_token_val,
eid=str(eid), cv=str(count_val), bh=btn_html,
)
if qty == 0:
inner = _tw_form(1, sexp(
'(button :type "submit" :class "relative inline-flex items-center justify-center text-stone-500 hover:bg-emerald-50 rounded p-1"'
' (i :class "fa fa-cart-plus text-2xl" :aria-hidden "true"))',
))
else:
minus = _tw_form(qty - 1, sexp(
'(button :type "submit" :class "inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl" "-")',
))
cart_icon = sexp(
'(span :class "relative inline-flex items-center justify-center text-emerald-700"'
' (span :class "relative inline-flex items-center justify-center"'
' (i :class "fa-solid fa-shopping-cart text-xl" :aria-hidden "true")'
' (span :class "absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none"'
' (span :class "flex items-center justify-center bg-black text-white rounded-full w-4 h-4 text-xs font-bold" q))))',
q=str(qty),
)
plus = _tw_form(qty + 1, sexp(
'(button :type "submit" :class "inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl" "+")',
))
inner = minus + cart_icon + plus
return sexp(
'(div :id (str "page-ticket-" eid) :class "flex items-center gap-2"'
' (span :class "text-green-600 font-medium text-sm" (raw! pr))'
' (raw! inner))',
eid=str(eid), pr=f"&pound;{tp:.2f}", inner=inner,
)
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(sexp(
'(div :class "pt-2 pb-1"'
' (h3 :class "text-sm font-semibold text-stone-500 uppercase tracking-wide" d))',
d=entry_date,
))
last_date = entry_date
parts.append(_entry_card_html(
entry, page_info, pending_tickets, ticket_url, events_url_fn,
is_page_scoped=is_page_scoped, post=post,
))
if has_more:
parts.append(sexp(
'(div :id (str "sentinel-" p) :class "h-4 opacity-0 pointer-events-none"'
' :hx-get nu :hx-trigger "intersect once delay:250ms" :hx-swap "outerHTML"'
' :role "status" :aria-hidden "true"'
' (div :class "text-center text-xs text-stone-400" "loading..."))',
p=str(page), nu=next_url,
))
return "".join(parts)
# ---------------------------------------------------------------------------
# All events / page summary main panels
# ---------------------------------------------------------------------------
_LIST_SVG = sexp(
'(svg :xmlns "http://www.w3.org/2000/svg" :class "h-5 w-5" :fill "none"'
' :viewBox "0 0 24 24" :stroke "currentColor" :stroke-width "2"'
' (path :stroke-linecap "round" :stroke-linejoin "round" :d "M4 6h16M4 12h16M4 18h16"))',
)
_TILE_SVG = sexp(
'(svg :xmlns "http://www.w3.org/2000/svg" :class "h-5 w-5" :fill "none"'
' :viewBox "0 0 24 24" :stroke "currentColor" :stroke-width "2"'
' (path :stroke-linecap "round" :stroke-linejoin "round"'
' :d "M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z"))',
)
def _view_toggle_html(ctx: dict, view: str) -> str:
"""Render the list/tile view toggle bar."""
from shared.utils import route_prefix
prefix = route_prefix()
clh = ctx.get("current_local_href", "/")
hx_select = ctx.get("hx_select_search", "#main-panel")
list_href = prefix + str(clh)
tile_href = prefix + str(clh)
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 sexp(
'(div :class "hidden md:flex justify-end px-3 pt-3 gap-1"'
' (a :href lh :hx-get lh :hx-target "#main-panel" :hx-select hs'
' :hx-swap "outerHTML" :hx-push-url "true"'
' :class (str "p-1.5 rounded " la) :title "List view"'
""" :_ "on click js localStorage.removeItem('events_view') end" """
' (raw! ls))'
' (a :href th :hx-get th :hx-target "#main-panel" :hx-select hs'
' :hx-swap "outerHTML" :hx-push-url "true"'
' :class (str "p-1.5 rounded " ta) :title "Tile view"'
""" :_ "on click js localStorage.setItem('events_view','tile') end" """
' (raw! ts)))',
lh=list_href, th=tile_href, hs=hx_select,
la=list_active, ta=tile_active,
ls=_LIST_SVG, ts=_TILE_SVG,
)
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."""
toggle = _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,
)
grid_cls = ("max-w-full px-3 py-3 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4"
if view == "tile" else "max-w-full px-3 py-3 space-y-3")
body = sexp('(div :class gc (raw! c))', gc=grid_cls, c=cards)
else:
body = sexp(
'(div :class "px-3 py-12 text-center text-stone-400"'
' (i :class "fa fa-calendar-xmark text-4xl mb-3" :aria-hidden "true")'
' (p :class "text-lg" "No upcoming events"))',
)
return sexp(
'(<> (raw! tg) (raw! bd) (div :class "pb-8"))',
tg=toggle, bd=body,
)
# ---------------------------------------------------------------------------
# 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:
return sexp(
'(div :class "rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-800"'
' (i :class "fa fa-exclamation-circle mr-2" :aria-hidden "true") em)',
em=error or "Check-in failed",
)
if not ticket:
return ""
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"
date_html = ""
if entry and entry.start_at:
date_html = sexp('(div :class "text-xs text-stone-500" d)',
d=entry.start_at.strftime("%d %b %Y, %H:%M"))
return sexp(
'(tr :class "bg-blue-50" :id (str "ticket-row-" c)'
' (td :class "px-4 py-3" (span :class "font-mono text-xs" cs))'
' (td :class "px-4 py-3" (div :class "font-medium" en) (raw! dh))'
' (td :class "px-4 py-3 text-sm" tn)'
' (td :class "px-4 py-3" (raw! sb))'
' (td :class "px-4 py-3"'
' (span :class "text-xs text-blue-600"'
' (i :class "fa fa-check-circle" :aria-hidden "true") (str " " ts))))',
c=code, cs=code[:12] + "...",
en=entry.name if entry else "\u2014",
dh=date_html, tn=tt.name if tt else "\u2014",
sb=_ticket_state_badge_html("checked_in"), ts=time_str,
)
# ---------------------------------------------------------------------------
# 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 sexp(
'(div :class "rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800"'
' (i :class "fa fa-exclamation-circle mr-2" :aria-hidden "true") em)',
em=error,
)
if not ticket:
return ""
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()
# Info section
info_html = sexp('(div :class "font-semibold text-lg" en)',
en=entry.name if entry else "Unknown event")
if tt:
info_html += sexp('(div :class "text-sm text-stone-600" tn)', tn=tt.name)
if entry and entry.start_at:
info_html += sexp('(div :class "text-sm text-stone-500 mt-1" d)',
d=entry.start_at.strftime("%A, %B %d, %Y at %H:%M"))
cal = getattr(entry, "calendar", None) if entry else None
if cal:
info_html += sexp('(div :class "text-xs text-stone-400 mt-0.5" cn)', cn=cal.name)
info_html += sexp(
'(div :class "mt-2" (raw! sb) (span :class "text-xs text-stone-400 ml-2 font-mono" c))',
sb=_ticket_state_badge_html(state), c=code,
)
if checked_in_at:
info_html += sexp('(div :class "text-xs text-blue-600 mt-1" (str "Checked in: " d))',
d=checked_in_at.strftime("%B %d, %Y at %H:%M"))
# Action area
action_html = ""
if state in ("confirmed", "reserved"):
checkin_url = url_for("ticket_admin.do_checkin", code=code)
action_html = sexp(
'(form :hx-post cu :hx-target (str "#checkin-action-" c) :hx-swap "innerHTML"'
' (input :type "hidden" :name "csrf_token" :value csrf)'
' (button :type "submit"'
' :class "px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition font-semibold text-lg"'
' (i :class "fa fa-check mr-2" :aria-hidden "true") "Check In"))',
cu=checkin_url, c=code, csrf=csrf,
)
elif state == "checked_in":
action_html = sexp(
'(div :class "text-blue-600 text-center"'
' (i :class "fa fa-check-circle text-3xl" :aria-hidden "true")'
' (div :class "text-sm font-medium mt-1" "Checked In"))',
)
elif state == "cancelled":
action_html = sexp(
'(div :class "text-red-600 text-center"'
' (i :class "fa fa-times-circle text-3xl" :aria-hidden "true")'
' (div :class "text-sm font-medium mt-1" "Cancelled"))',
)
return sexp(
'(div :class "rounded-lg border border-stone-200 bg-stone-50 p-4"'
' (div :class "flex items-start justify-between gap-4"'
' (div :class "flex-1" (raw! ih))'
' (div :id (str "checkin-action-" c) (raw! ah))))',
ih=info_html, c=code, ah=action_html,
)
# ---------------------------------------------------------------------------
# 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 ""
rows_html = ""
for ticket in tickets:
tt = getattr(ticket, "ticket_type", None)
state = getattr(ticket, "state", "")
code = ticket.code
checked_in_at = getattr(ticket, "checked_in_at", None)
action_html = ""
if state in ("confirmed", "reserved"):
checkin_url = url_for("ticket_admin.do_checkin", code=code)
action_html = sexp(
'(form :hx-post cu :hx-target (str "#entry-ticket-row-" c) :hx-swap "outerHTML"'
' (input :type "hidden" :name "csrf_token" :value csrf)'
' (button :type "submit" :class "px-3 py-1 bg-blue-600 text-white text-xs rounded hover:bg-blue-700"'
' "Check in"))',
cu=checkin_url, c=code, csrf=csrf,
)
elif state == "checked_in":
t_str = checked_in_at.strftime("%H:%M") if checked_in_at else ""
action_html = sexp(
'(span :class "text-xs text-blue-600"'
' (i :class "fa fa-check-circle" :aria-hidden "true") (str " " ts))',
ts=t_str,
)
rows_html += sexp(
'(tr :class "hover:bg-stone-50" :id (str "entry-ticket-row-" c)'
' (td :class "px-4 py-2 font-mono text-xs" cs)'
' (td :class "px-4 py-2" tn)'
' (td :class "px-4 py-2" (raw! sb))'
' (td :class "px-4 py-2" (raw! ah)))',
c=code, cs=code[:12] + "...",
tn=tt.name if tt else "\u2014",
sb=_ticket_state_badge_html(state), ah=action_html,
)
if tickets:
body_html = sexp(
'(div :class "overflow-x-auto rounded-xl border border-stone-200"'
' (table :class "w-full text-sm"'
' (thead :class "bg-stone-50"'
' (tr (th :class "px-4 py-2 text-left font-medium text-stone-600" "Code")'
' (th :class "px-4 py-2 text-left font-medium text-stone-600" "Type")'
' (th :class "px-4 py-2 text-left font-medium text-stone-600" "State")'
' (th :class "px-4 py-2 text-left font-medium text-stone-600" "Actions")))'
' (tbody :class "divide-y divide-stone-100" (raw! rh))))',
rh=rows_html,
)
else:
body_html = sexp('(div :class "text-center py-6 text-stone-500 text-sm" "No tickets for this entry")')
return sexp(
'(div :class "space-y-4"'
' (div :class "flex items-center justify-between"'
' (h3 :class "text-lg font-semibold" (str "Tickets for: " en))'
' (span :class "text-sm text-stone-500" cl))'
' (raw! bh))',
en=entry.name, cl=f"{count} ticket{suffix}", bh=body_html,
)
# ---------------------------------------------------------------------------
# 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
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"
def _field(label, content_html):
return sexp(
'(div :class "flex flex-col mb-4"'
' (div :class "text-xs font-semibold uppercase tracking-wide text-stone-500" l)'
' (raw! ch))',
l=label, ch=content_html,
)
# Name
name_html = _field("Name", sexp('(div :class "mt-1 text-lg font-medium" n)', n=entry.name))
# Slot
slot = getattr(entry, "slot", None)
if slot:
flex_label = "(flexible)" if getattr(slot, "flexible", False) else "(fixed)"
slot_inner = sexp(
'(div :class "mt-1"'
' (span :class "px-2 py-1 rounded text-sm bg-blue-100 text-blue-700" sn)'
' (span :class "ml-2 text-xs text-stone-500" fl))',
sn=slot.name, fl=flex_label,
)
else:
slot_inner = sexp('(div :class "mt-1" (span :class "text-sm text-stone-400" "No slot assigned"))')
slot_html = _field("Slot", slot_inner)
# Time Period
start_str = entry.start_at.strftime("%H:%M") if entry.start_at else ""
end_str = f" \u2013 {entry.end_at.strftime('%H:%M')}" if entry.end_at else " \u2013 open-ended"
time_html = _field("Time Period", sexp('(div :class "mt-1" t)', t=start_str + end_str))
# State
state_html = _field("State", sexp(
'(div :class "mt-1" (div :id (str "entry-state-" eid) (raw! sb)))',
eid=str(eid), sb=_entry_state_badge_html(state),
))
# Cost
cost = getattr(entry, "cost", None)
cost_str = f"{cost:.2f}" if cost is not None else "0.00"
cost_html = _field("Cost", sexp(
'(div :class "mt-1" (span :class "font-medium text-green-600" (raw! cs)))',
cs=f"&pound;{cost_str}",
))
# Ticket Configuration (admin)
tickets_html = _field("Tickets", sexp(
'(div :class "mt-1" :id (str "entry-tickets-" eid) (raw! tc))',
eid=str(eid), tc=render_entry_tickets_config(entry, calendar, day, month, year),
))
# Buy Tickets (public-facing)
ticket_remaining = ctx.get("ticket_remaining")
ticket_sold_count = ctx.get("ticket_sold_count", 0)
user_ticket_count = ctx.get("user_ticket_count", 0)
user_ticket_counts_by_type = ctx.get("user_ticket_counts_by_type") or {}
buy_html = render_buy_form(
entry, ticket_remaining, ticket_sold_count,
user_ticket_count, user_ticket_counts_by_type,
)
# Date
date_str = entry.start_at.strftime("%A, %B %d, %Y") if entry.start_at else ""
date_html = _field("Date", sexp('(div :class "mt-1" d)', d=date_str))
# Associated Posts
entry_posts = ctx.get("entry_posts") or []
posts_html = _field("Associated Posts", sexp(
'(div :class "mt-1" :id (str "entry-posts-" eid) (raw! ph))',
eid=str(eid), ph=render_entry_posts_panel(entry_posts, entry, calendar, day, month, year),
))
# Options and Edit Button
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,
)
return sexp(
'(section :id (str "entry-" eid) :class lc'
' (raw! nh) (raw! slh) (raw! tmh) (raw! sth) (raw! cth)'
' (raw! tkh) (raw! buyh) (raw! dth) (raw! psh)'
' (div :class "flex gap-2 mt-6"'
' (raw! opts)'
' (button :type "button" :class pa'
' :hx-get eu :hx-target (str "#entry-" eid) :hx-swap "outerHTML"'
' "Edit")))',
eid=str(eid), lc=list_container,
nh=name_html, slh=slot_html, tmh=time_html, sth=state_html,
cth=cost_html, tkh=tickets_html, buyh=buy_html,
dth=date_html, psh=posts_html,
opts=_entry_options_html(entry, calendar, day, month, year),
pa=pre_action, eu=edit_url,
)
# ---------------------------------------------------------------------------
# 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 = sexp(
'(div :id (str "entry-title-" eid) :class "flex gap-1 items-center"'
' (raw! th) (raw! tmh))',
eid=str(entry.id), th=_entry_title_html(entry), tmh=_entry_times_html(entry),
)
nav_html = _entry_nav_html(ctx)
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" \u2192 {end.strftime('%H:%M')}" if end else ""
return sexp('(div :class "text-sm text-gray-600" t)', t=start_str + end_str)
# ---------------------------------------------------------------------------
# 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:
post_links = ""
for ep in entry_posts:
slug = getattr(ep, "slug", "")
title = getattr(ep, "title", "")
feat = getattr(ep, "feature_image", None)
href = blog_url_fn(f"/{slug}/") if blog_url_fn else f"/{slug}/"
if feat:
img_html = sexp(
'(img :src f :alt t :class "w-8 h-8 rounded-full object-cover flex-shrink-0")',
f=feat, t=title,
)
else:
img_html = sexp('(div :class "w-8 h-8 rounded-full bg-stone-200 flex-shrink-0")')
post_links += sexp(
'(a :href h :class "flex items-center gap-2 px-3 py-2 hover:bg-stone-100 rounded transition text-sm border sm:whitespace-nowrap sm:flex-shrink-0"'
' (raw! ih) (div :class "flex-1 min-w-0" (div :class "font-medium truncate" t)))',
h=href, ih=img_html, t=title,
)
parts.append(sexp(
'(div :class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"'
' :id "entry-posts-nav-wrapper"'
' (div :class "flex overflow-x-auto gap-1 scrollbar-thin" (raw! pl)))',
pl=post_links,
))
# Admin link
if is_admin:
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(sexp(
'(a :href au :class "inline-flex items-center gap-1 px-2 py-1 text-xs text-stone-500 hover:text-stone-700 hover:bg-stone-100 rounded"'
' (i :class "fa fa-cog" :aria-hidden "true") " Admin")',
au=admin_url,
))
return "".join(parts)
# ---------------------------------------------------------------------------
# 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 + sexp(
'(<> (div :id (str "entry-title-" eid) :hx-swap-oob "innerHTML" (raw! th))'
' (div :id (str "entry-state-" eid) :hx-swap-oob "innerHTML" (raw! sh)))',
eid=str(entry.id), th=title, sh=state,
)
def _entry_title_html(entry) -> str:
"""Render entry title (icon + name + state badge)."""
state = getattr(entry, "state", "pending") or "pending"
return sexp(
'(<> (i :class "fa fa-clock") " " n " " (raw! sb))',
n=entry.name, sb=_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}"
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"
return sexp(
'(form :hx-post u :hx-select tgt :hx-target tgt :hx-swap "outerHTML"'
' :hx-trigger (if is-btn "confirmed" nil)'
' (input :type "hidden" :name "csrf_token" :value csrf)'
' (button :type bt :class ab'
' :data-confirm "true" :data-confirm-title ct'
' :data-confirm-text cx :data-confirm-icon "question"'
' :data-confirm-confirm-text (str "Yes, " l " it")'
' :data-confirm-cancel-text "Cancel"'
' :data-confirm-event (if is-btn "confirmed" nil)'
' (i :class "fa-solid fa-rotate mr-2" :aria-hidden "true") l))',
u=url, tgt=target, csrf=csrf, bt=btn_type,
ab=action_btn, ct=confirm_title, cx=confirm_text,
l=label, **{"is-btn": trigger_type == "button"},
)
buttons_html = ""
if state == "provisional":
buttons_html += _make_button(
"confirm_entry", "confirm",
"Confirm entry?", "Are you sure you want to confirm this entry?",
)
buttons_html += _make_button(
"decline_entry", "decline",
"Decline entry?", "Are you sure you want to decline this entry?",
)
elif state == "confirmed":
buttons_html += _make_button(
"provisional_entry", "provisional",
"Provisional entry?", "Are you sure you want to provisional this entry?",
trigger_type="button",
)
return sexp(
'(div :id (str "calendar_entry_options_" eid) :class "flex flex-col md:flex-row gap-1"'
' (raw! bh))',
eid=str(eid), bh=buttons_html,
)
# ---------------------------------------------------------------------------
# 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)
eid_s = str(eid)
show_js = f"document.getElementById('ticket-form-{eid}').classList.remove('hidden'); this.classList.add('hidden');"
hide_js = (f"document.getElementById('ticket-form-{eid}').classList.add('hidden'); "
f"document.getElementById('entry-tickets-{eid}').querySelectorAll('button:not([type=submit])').forEach(btn => btn.classList.remove('hidden'));")
if tp is not None:
tc_str = f"{tc} tickets" if tc is not None else "Unlimited"
display_html = sexp(
'(div :class "space-y-2"'
' (div :class "flex items-center gap-2"'
' (span :class "text-sm font-medium text-stone-700" "Price:")'
' (span :class "font-medium text-green-600" (raw! ps)))'
' (div :class "flex items-center gap-2"'
' (span :class "text-sm font-medium text-stone-700" "Available:")'
' (span :class "font-medium text-blue-600" ts))'
' (button :type "button" :class "text-xs text-blue-600 hover:text-blue-800 underline"'
' :onclick sj "Edit ticket config"))',
ps=f"&pound;{tp:.2f}", ts=tc_str, sj=show_js,
)
else:
display_html = sexp(
'(div :class "space-y-2"'
' (span :class "text-sm text-stone-400" "No tickets configured")'
' (button :type "button" :class "block text-xs text-blue-600 hover:text-blue-800 underline"'
' :onclick sj "Configure tickets"))',
sj=show_js,
)
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 ""
form_html = sexp(
'(form :id (str "ticket-form-" eid) :class (str hc " space-y-3 mt-2 p-3 border rounded bg-stone-50")'
' :hx-post uu :hx-target (str "#entry-tickets-" eid) :hx-swap "innerHTML"'
' (input :type "hidden" :name "csrf_token" :value csrf)'
' (div (label :for (str "ticket-price-" eid) :class "block text-sm font-medium text-stone-700 mb-1"'
' (raw! "Ticket Price (&pound;)"))'
' (input :type "number" :id (str "ticket-price-" eid) :name "ticket_price"'
' :step "0.01" :min "0" :value tpv'
' :class "w-full px-3 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"'
' :placeholder "e.g., 5.00"))'
' (div (label :for (str "ticket-count-" eid) :class "block text-sm font-medium text-stone-700 mb-1"'
' "Total Tickets")'
' (input :type "number" :id (str "ticket-count-" eid) :name "ticket_count"'
' :min "0" :value tcv'
' :class "w-full px-3 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"'
' :placeholder "Leave empty for unlimited"))'
' (div :class "flex gap-2"'
' (button :type "submit" :class "px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 text-sm" "Save")'
' (button :type "button" :class "px-4 py-2 bg-stone-200 text-stone-700 rounded hover:bg-stone-300 text-sm"'
' :onclick hj "Cancel")))',
eid=eid_s, hc=hidden_cls, uu=update_url, csrf=csrf,
tpv=tp_val, tcv=tc_val, hj=hide_js,
)
return display_html + form_html
# ---------------------------------------------------------------------------
# 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
eid_s = str(eid)
posts_html = ""
if entry_posts:
items = ""
for ep in entry_posts:
ep_title = getattr(ep, "title", "")
ep_id = getattr(ep, "id", 0)
feat = getattr(ep, "feature_image", None)
img_html = (sexp('(img :src f :alt t :class "w-8 h-8 rounded-full object-cover flex-shrink-0")', f=feat, t=ep_title)
if feat else sexp('(div :class "w-8 h-8 rounded-full bg-stone-200 flex-shrink-0")'))
del_url = url_for(
"calendars.calendar.day.calendar_entries.calendar_entry.remove_post",
calendar_slug=cal_slug, day=day, month=month, year=year,
entry_id=eid, post_id=ep_id,
)
items += sexp(
'(div :class "flex items-center justify-between gap-3 p-2 bg-stone-50 rounded border"'
' (raw! ih) (span :class "text-sm flex-1" t)'
' (button :type "button" :class "text-xs text-red-600 hover:text-red-800 flex-shrink-0"'
' :data-confirm "true" :data-confirm-title "Remove post?"'
' :data-confirm-text (str "This will remove " t " from this entry")'
' :data-confirm-icon "warning" :data-confirm-confirm-text "Yes, remove it"'
' :data-confirm-cancel-text "Cancel" :data-confirm-event "confirmed"'
' :hx-delete du :hx-trigger "confirmed"'
' :hx-target (str "#entry-posts-" eid) :hx-swap "innerHTML"'
' :hx-headers hd'
' (i :class "fa fa-times") " Remove"))',
ih=img_html, t=ep_title, du=del_url,
eid=eid_s, hd=f'{{"X-CSRFToken": "{csrf}"}}',
)
posts_html = sexp('(div :class "space-y-2" (raw! it))', it=items)
else:
posts_html = sexp('(p :class "text-sm text-stone-400" "No posts associated")')
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,
)
return sexp(
'(div :class "space-y-2"'
' (raw! ph)'
' (div :class "mt-3 pt-3 border-t"'
' (label :class "block text-xs font-medium text-stone-700 mb-1" "Add Post")'
' (input :type "text" :placeholder "Search posts..."'
' :class "w-full px-3 py-2 border rounded text-sm"'
' :hx-get su :hx-trigger "keyup changed delay:300ms, load"'
' :hx-target (str "#post-search-results-" eid) :hx-swap "innerHTML" :name "q")'
' (div :id (str "post-search-results-" eid) :class "mt-2 max-h-96 overflow-y-auto border rounded")))',
ph=posts_html, su=search_url, eid=eid_s,
)
# ---------------------------------------------------------------------------
# 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 sexp('(div :id "entry-posts-nav-wrapper" :hx-swap-oob "true")')
items = ""
for ep in entry_posts:
slug = getattr(ep, "slug", "")
title = getattr(ep, "title", "")
feat = getattr(ep, "feature_image", None)
href = blog_url_fn(f"/{slug}/") if blog_url_fn else f"/{slug}/"
img_html = (sexp('(img :src f :alt t :class "w-8 h-8 rounded-full object-cover flex-shrink-0")', f=feat, t=title)
if feat else sexp('(div :class "w-8 h-8 rounded-full bg-stone-200 flex-shrink-0")'))
items += sexp(
'(a :href h :class nb (raw! ih) (div :class "flex-1 min-w-0" (div :class "font-medium truncate" t)))',
h=href, nb=nav_btn, ih=img_html, t=title,
)
return sexp(
'(div :class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"'
' :id "entry-posts-nav-wrapper" :hx-swap-oob "true"'
' (div :class "flex overflow-x-auto gap-1 scrollbar-thin" (raw! it)))',
it=items,
)
# ---------------------------------------------------------------------------
# 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 sexp('(div :id "day-entries-nav-wrapper" :hx-swap-oob "true")')
items = ""
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,
)
start = entry.start_at.strftime("%H:%M") if entry.start_at else ""
end = f" \u2013 {entry.end_at.strftime('%H:%M')}" if entry.end_at else ""
items += sexp(
'(a :href h :class nb'
' (div :class "flex-1 min-w-0"'
' (div :class "font-medium truncate" n)'
' (div :class "text-xs text-stone-600 truncate" t)))',
h=href, nb=nav_btn, n=entry.name, t=start + end,
)
return sexp(
'(div :class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"'
' :id "day-entries-nav-wrapper" :hx-swap-oob "true"'
' (div :class "flex overflow-x-auto gap-1 scrollbar-thin" (raw! it)))',
it=items,
)
# ---------------------------------------------------------------------------
# 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 sexp('(div :id "entries-calendars-nav-wrapper" :hx-swap-oob "true")')
slug = post.get("slug", "") if isinstance(post, dict) else getattr(post, "slug", "")
items = ""
if has_entries:
for entry in associated_entries.entries:
entry_path = (
f"/{slug}/{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)
time_str = entry.start_at.strftime("%b %d, %Y at %H:%M")
end_str = f" \u2013 {entry.end_at.strftime('%H:%M')}" if entry.end_at else ""
items += sexp(
'(a :href h :class nb'
' (div :class "w-8 h-8 rounded bg-stone-200 flex-shrink-0")'
' (div :class "flex-1 min-w-0"'
' (div :class "font-medium truncate" n)'
' (div :class "text-xs text-stone-600 truncate" t)))',
h=href, nb=nav_btn, n=entry.name, t=time_str + end_str,
)
if calendars:
for cal in calendars:
cs = getattr(cal, "slug", "")
local_href = events_url(f"/{slug}/{cs}/")
items += sexp(
'(a :href lh :class nb'
' (i :class "fa fa-calendar" :aria-hidden "true")'
' (div cn))',
lh=local_href, nb=nav_btn, cn=cal.name,
)
hs = ("on load or scroll "
"if window.innerWidth >= 640 and my.scrollWidth > my.clientWidth "
"remove .hidden from .entries-nav-arrow add .flex to .entries-nav-arrow "
"else add .hidden to .entries-nav-arrow remove .flex from .entries-nav-arrow end")
return sexp(
'(div :class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"'
' :id "entries-calendars-nav-wrapper" :hx-swap-oob "true"'
' (button :class "entries-nav-arrow hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"'
' :aria-label "Scroll left"'
' :_ "on click set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft - 200"'
' (i :class "fa fa-chevron-left"))'
' (div :id "associated-items-container"'
' :class "overflow-y-auto sm:overflow-x-auto sm:overflow-y-visible scrollbar-hide max-h-[50vh] sm:max-h-none"'
' :style "scroll-behavior: smooth;" :_ hs'
' (div :class "flex flex-col sm:flex-row gap-1" (raw! it)))'
' (style ".scrollbar-hide::-webkit-scrollbar { display: none; }'
' .scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }")'
' (button :class "entries-nav-arrow hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"'
' :aria-label "Scroll right"'
' :_ "on click set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft + 200"'
' (i :class "fa fa-chevron-right")))',
it=items, hs=hs,
)
# ---------------------------------------------------------------------------
# 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 += sexp(
'(div :id "calendar-description-title" :hx-swap-oob "outerHTML"'
' :class "text-base font-normal break-words whitespace-normal min-w-0 break-all w-full text-center block"'
' d)',
d=desc,
)
return html
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 sexp(
'(div :id "calendar-description"'
' (form :hx-post su :hx-target "#calendar-description" :hx-swap "outerHTML"'
' (input :type "hidden" :name "csrf_token" :value csrf)'
' (textarea :name "description" :autocomplete "off" :rows "4"'
' :class "w-full p-2 border rounded" d)'
' (div :class "mt-2 flex gap-2 text-xs"'
' (button :type "submit" :class "px-3 py-1 rounded bg-stone-800 text-white" "Save")'
' (button :type "button" :class "px-3 py-1 rounded border"'
' :hx-get cu :hx-target "#calendar-description" :hx-swap "outerHTML"'
' "Cancel"))))',
su=save_url, csrf=csrf, d=desc, cu=cancel_url,
)
# ---------------------------------------------------------------------------
# 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", "\u2014")
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)
# Days pills
if days and days[0] != "\u2014":
days_inner = "".join(
sexp('(span :class "px-2 py-0.5 rounded-full text-xs bg-slate-200" d)', d=d) for d in days
)
days_html = sexp('(div :class "flex flex-wrap gap-1" (raw! di))', di=days_inner)
else:
days_html = sexp('(span :class "text-xs text-slate-400" "No days")')
sid = str(slot.id)
result = sexp(
'(section :id (str "slot-" sid) :class lc'
' (div :class "flex flex-col"'
' (div :class "text-xs font-semibold uppercase tracking-wide text-stone-500" "Days")'
' (div :class "mt-1" (raw! dh)))'
' (div :class "flex flex-col"'
' (div :class "text-xs font-semibold uppercase tracking-wide text-stone-500" "Flexible")'
' (div :class "mt-1" fl))'
' (div :class "grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm"'
' (div :class "flex flex-col"'
' (div :class "text-xs font-semibold uppercase tracking-wide text-stone-500" "Time")'
' (div :class "mt-1" tm))'
' (div :class "flex flex-col"'
' (div :class "text-xs font-semibold uppercase tracking-wide text-stone-500" "Cost")'
' (div :class "mt-1" cs)))'
' (button :type "button" :class pa :hx-get eu'
' :hx-target (str "#slot-" sid) :hx-swap "outerHTML" "Edit"))',
sid=sid, lc=list_container, dh=days_html,
fl="yes" if flexible else "no",
tm=f"{time_start} \u2014 {time_end}", cs=cost_str,
pa=pre_action, eu=edit_url,
)
if oob:
result += sexp(
'(div :id "slot-description-title" :hx-swap-oob "outerHTML"'
' :class "text-base font-normal break-words whitespace-normal min-w-0 break-all w-full text-center block"'
' d)',
d=desc,
)
return result
# ---------------------------------------------------------------------------
# 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", "")
rows_html = ""
if slots:
for s in slots:
slot_href = url_for("calendars.calendar.slots.slot.get", calendar_slug=cal_slug, slot_id=s.id)
del_url = url_for("calendars.calendar.slots.slot.slot_delete", calendar_slug=cal_slug, slot_id=s.id)
desc = getattr(s, "description", "") or ""
days_display = getattr(s, "days_display", "\u2014")
day_list = days_display.split(", ")
if day_list and day_list[0] != "\u2014":
days_inner = "".join(
sexp('(span :class "px-2 py-0.5 rounded-full text-xs bg-slate-200" d)', d=d) for d in day_list
)
days_html = sexp('(div :class "flex flex-wrap gap-1" (raw! di))', di=days_inner)
else:
days_html = sexp('(span :class "text-xs text-slate-400" "No days")')
time_start = s.time_start.strftime("%H:%M") if s.time_start else ""
time_end = s.time_end.strftime("%H:%M") if s.time_end else ""
cost = getattr(s, "cost", None)
cost_str = f"{cost:.2f}" if cost is not None else ""
rows_html += sexp(
'(tr :class tc'
' (td :class "p-2 align-top w-1/6"'
' (div :class "font-medium"'
' (a :href sh :class pc :hx-get sh :hx-target "#main-panel"'
' :hx-select hs :hx-swap "outerHTML" :hx-push-url "true" sn))'
' (p :class "text-stone-500 whitespace-pre-line break-all w-full" ds))'
' (td :class "p-2 align-top w-1/6" fl)'
' (td :class "p-2 align-top w-1/6" (raw! dh))'
' (td :class "p-2 align-top w-1/6" tm)'
' (td :class "p-2 align-top w-1/6" cs)'
' (td :class "p-2 align-top w-1/6"'
' (button :class ab :type "button"'
' :data-confirm "true" :data-confirm-title "Delete slot?"'
' :data-confirm-text "This action cannot be undone."'
' :data-confirm-icon "warning" :data-confirm-confirm-text "Yes, delete it"'
' :data-confirm-cancel-text "Cancel" :data-confirm-event "confirmed"'
' :hx-delete du :hx-target "#slots-table" :hx-select "#slots-table"'
' :hx-swap "outerHTML" :hx-headers hd :hx-trigger "confirmed"'
' (i :class "fa-solid fa-trash"))))',
tc=tr_cls, sh=slot_href, pc=pill_cls, hs=hx_select,
sn=s.name, ds=desc, fl="yes" if s.flexible else "no",
dh=days_html, tm=f"{time_start} - {time_end}", cs=cost_str,
ab=action_btn, du=del_url, hd=f'{{"X-CSRFToken": "{csrf}"}}',
)
else:
rows_html = sexp('(tr (td :colspan "5" :class "p-3 text-stone-500" "No slots yet."))')
add_url = url_for("calendars.calendar.slots.add_form", calendar_slug=cal_slug)
return sexp(
'(section :id "slots-table" :class lc'
' (table :class "w-full text-sm border table-fixed"'
' (thead :class "bg-stone-100"'
' (tr (th :class "p-2 text-left w-1/6" "Name")'
' (th :class "p-2 text-left w-1/6" "Flexible")'
' (th :class "text-left p-2 w-1/6" "Days")'
' (th :class "text-left p-2 w-1/6" "Time")'
' (th :class "text-left p-2 w-1/6" "Cost")'
' (th :class "text-left p-2 w-1/6" "Actions")))'
' (tbody (raw! rh)))'
' (div :id "slot-add-container" :class "mt-4"'
' (button :type "button" :class pa'
' :hx-get au :hx-target "#slot-add-container" :hx-swap "innerHTML"'
' "+ Add slot")))',
lc=list_container, rh=rows_html, pa=pre_action, au=add_url,
)
# ---------------------------------------------------------------------------
# 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", "")
cost = getattr(ticket_type, "cost", None)
cost_str = f"\u00a3{cost:.2f}" if cost is not None else "\u00a30.00"
count = getattr(ticket_type, "count", 0)
tid = str(ticket_type.id)
edit_url = url_for(
"calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.get_edit",
ticket_type_id=ticket_type.id, calendar_slug=cal_slug,
year=year, month=month, day=day, entry_id=entry.id,
)
def _col(label, val):
return sexp(
'(div :class "flex flex-col"'
' (div :class "text-xs font-semibold uppercase tracking-wide text-stone-500" l)'
' (div :class "mt-1" v))',
l=label, v=val,
)
return sexp(
'(section :id (str "ticket-" tid) :class lc'
' (div :class "grid grid-cols-1 sm:grid-cols-3 gap-4 text-sm"'
' (raw! c1) (raw! c2) (raw! c3))'
' (button :type "button" :class pa :hx-get eu'
' :hx-target (str "#ticket-" tid) :hx-swap "outerHTML" "Edit"))',
tid=tid, lc=list_container,
c1=_col("Name", ticket_type.name),
c2=_col("Cost", cost_str),
c3=_col("Count", str(count)),
pa=pre_action, eu=edit_url,
)
# ---------------------------------------------------------------------------
# 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
rows_html = ""
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"\u00a3{cost:.2f}" if cost is not None else "\u00a30.00"
rows_html += sexp(
'(tr :class tc'
' (td :class "p-2 align-top w-1/3"'
' (div :class "font-medium"'
' (a :href th :class pc :hx-get th :hx-target "#main-panel"'
' :hx-select hs :hx-swap "outerHTML" :hx-push-url "true" tn)))'
' (td :class "p-2 align-top w-1/4" cs)'
' (td :class "p-2 align-top w-1/4" cnt)'
' (td :class "p-2 align-top w-1/6"'
' (button :class ab :type "button"'
' :data-confirm "true" :data-confirm-title "Delete ticket type?"'
' :data-confirm-text "This action cannot be undone."'
' :data-confirm-icon "warning" :data-confirm-confirm-text "Yes, delete it"'
' :data-confirm-cancel-text "Cancel" :data-confirm-event "confirmed"'
' :hx-delete du :hx-target "#tickets-table" :hx-select "#tickets-table"'
' :hx-swap "outerHTML" :hx-headers hd :hx-trigger "confirmed"'
' (i :class "fa-solid fa-trash"))))',
tc=tr_cls, th=tt_href, pc=pill_cls, hs=hx_select,
tn=tt.name, cs=cost_str, cnt=str(tt.count),
ab=action_btn, du=del_url, hd=f'{{"X-CSRFToken": "{csrf}"}}',
)
else:
rows_html = sexp('(tr (td :colspan "4" :class "p-3 text-stone-500" "No ticket types yet."))')
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,
)
return sexp(
'(section :id "tickets-table" :class lc'
' (table :class "w-full text-sm border table-fixed"'
' (thead :class "bg-stone-100"'
' (tr (th :class "p-2 text-left w-1/3" "Name")'
' (th :class "text-left p-2 w-1/4" "Cost")'
' (th :class "text-left p-2 w-1/4" "Count")'
' (th :class "text-left p-2 w-1/6" "Actions")))'
' (tbody (raw! rh)))'
' (div :id "ticket-add-container" :class "mt-4"'
' (button :class ab :hx-get au :hx-target "#ticket-add-container" :hx-swap "innerHTML"'
' (i :class "fa fa-plus") " Add ticket type")))',
lc=list_container, rh=rows_html, ab=action_btn, au=add_url,
)
# ---------------------------------------------------------------------------
# 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
cart_html = _cart_icon_oob(cart_count)
count = len(created_tickets)
suffix = "s" if count != 1 else ""
tickets_html = ""
for ticket in created_tickets:
href = url_for("tickets.ticket_detail", code=ticket.code)
tickets_html += sexp(
'(a :href h :class "flex items-center justify-between p-2 rounded-lg bg-white border border-emerald-100 hover:border-emerald-300 transition text-sm"'
' (div :class "flex items-center gap-2"'
' (i :class "fa fa-ticket text-emerald-500" :aria-hidden "true")'
' (span :class "font-mono text-xs text-stone-500" cs))'
' (span :class "text-xs text-emerald-600 font-medium" "View ticket"))',
h=href, cs=ticket.code[:12] + "...",
)
remaining_html = ""
if remaining is not None:
r_suffix = "s" if remaining != 1 else ""
remaining_html = sexp('(p :class "text-xs text-stone-500" r)',
r=f"{remaining} ticket{r_suffix} remaining")
my_href = url_for("tickets.my_tickets")
return cart_html + sexp(
'(div :id (str "ticket-buy-" eid) :class "rounded-xl border border-emerald-200 bg-emerald-50 p-4"'
' (div :class "flex items-center gap-2 mb-3"'
' (i :class "fa fa-check-circle text-emerald-600" :aria-hidden "true")'
' (span :class "font-semibold text-emerald-800" cl))'
' (div :class "space-y-2 mb-4" (raw! th))'
' (raw! rh)'
' (div :class "mt-3 flex gap-2"'
' (a :href mh :class "text-sm text-emerald-700 hover:text-emerald-900 underline"'
' "View all my tickets")))',
eid=str(entry.id), cl=f"{count} ticket{suffix} reserved",
th=tickets_html, rh=remaining_html, mh=my_href,
)
# ---------------------------------------------------------------------------
# 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
eid_s = str(eid)
tp = getattr(entry, "ticket_price", None)
state = getattr(entry, "state", "")
ticket_types = getattr(entry, "ticket_types", None) or []
if tp is None:
return ""
if state != "confirmed":
return sexp(
'(div :id (str "ticket-buy-" eid) :class "rounded-xl border border-stone-200 bg-stone-50 p-4 text-sm text-stone-500"'
' (i :class "fa fa-ticket mr-1" :aria-hidden "true")'
' "Tickets available once this event is confirmed.")',
eid=eid_s,
)
adjust_url = url_for("tickets.adjust_quantity")
target = f"#ticket-buy-{eid}"
# Info line
info_html = ""
info_items = ""
if ticket_sold_count:
info_items += sexp('(span (str sc " sold"))', sc=str(ticket_sold_count))
if ticket_remaining is not None:
info_items += sexp('(span (str tr " remaining"))', tr=str(ticket_remaining))
if user_ticket_count:
info_items += sexp(
'(span :class "text-emerald-600 font-medium"'
' (i :class "fa fa-shopping-cart text-[0.6rem]" :aria-hidden "true")'
' (str " " uc " in basket"))',
uc=str(user_ticket_count),
)
if info_items:
info_html = sexp('(div :class "flex items-center gap-3 mb-3 text-xs text-stone-500" (raw! ii))', ii=info_items)
active_types = [tt for tt in ticket_types if getattr(tt, "deleted_at", None) is None]
body_html = ""
if active_types:
type_items = ""
for tt in active_types:
type_count = user_ticket_counts_by_type.get(tt.id, 0) if user_ticket_counts_by_type else 0
cost_str = f"\u00a3{tt.cost:.2f}" if tt.cost is not None else "\u00a30.00"
type_items += sexp(
'(div :class "flex items-center justify-between p-3 rounded-lg bg-stone-50 border border-stone-100"'
' (div (div :class "font-medium text-sm" tn)'
' (div :class "text-xs text-stone-500" cs))'
' (raw! ac))',
tn=tt.name, cs=cost_str,
ac=_ticket_adjust_controls(csrf, adjust_url, target, eid, type_count, ticket_type_id=tt.id),
)
body_html = sexp('(div :class "space-y-2" (raw! ti))', ti=type_items)
else:
qty = user_ticket_count or 0
body_html = sexp(
'(<> (div :class "flex items-center justify-between mb-4"'
' (div (span :class "font-medium text-green-600" ps)'
' (span :class "text-sm text-stone-500 ml-2" "per ticket")))'
' (raw! ac))',
ps=f"\u00a3{tp:.2f}",
ac=_ticket_adjust_controls(csrf, adjust_url, target, eid, qty),
)
return sexp(
'(div :id (str "ticket-buy-" eid) :class "rounded-xl border border-stone-200 bg-white p-4"'
' (h3 :class "text-sm font-semibold text-stone-700 mb-3"'
' (i :class "fa fa-ticket mr-1" :aria-hidden "true") "Tickets")'
' (raw! ih) (raw! bh))',
eid=eid_s, ih=info_html, bh=body_html,
)
def _ticket_adjust_controls(csrf, adjust_url, target, entry_id, count, *, ticket_type_id=None):
"""Render +/- ticket controls for buy form."""
from quart import url_for
tt_html = sexp('(input :type "hidden" :name "ticket_type_id" :value tti)',
tti=str(ticket_type_id)) if ticket_type_id else ""
eid_s = str(entry_id)
def _adj_form(count_val, btn_html, *, extra_cls=""):
return sexp(
'(form :hx-post au :hx-target tgt :hx-swap "outerHTML" :class fc'
' (input :type "hidden" :name "csrf_token" :value csrf)'
' (input :type "hidden" :name "entry_id" :value eid)'
' (raw! tth)'
' (input :type "hidden" :name "count" :value cv)'
' (raw! bh))',
au=adjust_url, tgt=target, fc=extra_cls, csrf=csrf,
eid=eid_s, tth=tt_html, cv=str(count_val), bh=btn_html,
)
if count == 0:
return _adj_form(1, sexp(
'(button :type "submit"'
' :class "relative inline-flex items-center justify-center text-sm font-medium text-stone-500 hover:bg-emerald-50 rounded p-1"'
' (i :class "fa fa-cart-plus text-2xl" :aria-hidden "true"))',
), extra_cls="flex items-center")
my_tickets_href = url_for("tickets.my_tickets")
minus = _adj_form(count - 1, sexp(
'(button :type "submit"'
' :class "inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl"'
' "-")',
))
cart_icon = sexp(
'(a :class "relative inline-flex items-center justify-center text-emerald-700" :href mth'
' (span :class "relative inline-flex items-center justify-center"'
' (i :class "fa-solid fa-shopping-cart text-2xl" :aria-hidden "true")'
' (span :class "absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none"'
' (span :class "flex items-center justify-center bg-black text-white rounded-full w-4 h-4 text-xs font-bold" c))))',
mth=my_tickets_href, c=str(count),
)
plus = _adj_form(count + 1, sexp(
'(button :type "submit"'
' :class "inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl"'
' "+")',
))
return sexp(
'(div :class "flex items-center gap-2" (raw! m) (raw! ci) (raw! p))',
m=minus, ci=cart_icon, p=plus,
)
# ---------------------------------------------------------------------------
# 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 sexp(
'(div :id "cart-mini" :hx-swap-oob "true"'
' (div :class "h-12 w-12 rounded-full overflow-hidden border border-stone-300 flex-shrink-0"'
' (a :href bh :class "h-full w-full font-bold text-5xl flex-shrink-0 flex flex-row items-center gap-1"'
' (img :src lg :class "h-full w-full rounded-full object-cover border border-stone-300 flex-shrink-0"))))',
bh=blog_href, lg=logo,
)
cart_href = cart_url_fn("/") if cart_url_fn else "/"
return sexp(
'(div :id "cart-mini" :hx-swap-oob "true"'
' (a :href ch :class "relative inline-flex items-center justify-center text-stone-700 hover:text-emerald-700"'
' (i :class "fa fa-shopping-cart text-5xl" :aria-hidden "true")'
' (span :class "absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 inline-flex items-center justify-center rounded-full bg-emerald-600 text-white text-sm w-5 h-5"'
' c)))',
ch=cart_href, c=str(count),
)