All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m15s
- Disable htmx selfRequestsOnly, add CORS headers for *.rose-ash.com - Remove same-origin guards from ~menu-row and ~nav-link htmx attrs - Convert ~app-layout from string-concatenated HTML to pure sexp tree - Extract ~app-head component, replace ~app-shell with inline structure - Convert hamburger SVG from Python HTML constant to ~hamburger sexp component - Fix cross-domain fragment URLs (events_url, market_url) - Fix starts-with? primitive to handle nil values - Fix duplicate admin menu rows on OOB swaps - Add calendar admin nav links (slots, description) - Convert slots page from Jinja to sexp rendering - Disable page caching in development mode - Backfill migration to clean orphaned container_relations Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
3322 lines
146 KiB
Python
3322 lines
146 KiB
Python
"""
|
||
Events service s-expression page components.
|
||
|
||
Renders all events, page summary, calendars, calendar month, day, day admin,
|
||
calendar admin, tickets, ticket admin, markets, and payments pages.
|
||
Called from route handlers in place of ``render_template()``.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
from typing import Any
|
||
from markupsafe import escape
|
||
|
||
from shared.sexp.jinja_bridge import sexp
|
||
from shared.sexp.helpers import (
|
||
call_url, get_asset_url, root_header_html,
|
||
search_mobile_html, search_desktop_html,
|
||
full_page, oob_page,
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# OOB header helper (same pattern as market)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _oob_header_html(parent_id: str, child_id: str, row_html: str) -> str:
|
||
"""Wrap a header row in OOB div with child placeholder."""
|
||
return (
|
||
f'<div id="{parent_id}" hx-swap-oob="outerHTML" class="w-full">'
|
||
f'<div class="w-full">{row_html}'
|
||
f'<div id="{child_id}"></div></div></div>'
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Post header helpers (mirrors events/templates/_types/post/header/_header.html)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _post_header_html(ctx: dict, *, oob: bool = False) -> str:
|
||
"""Build the post-level header row."""
|
||
post = ctx.get("post") or {}
|
||
slug = post.get("slug", "")
|
||
title = (post.get("title") or "")[:160]
|
||
feature_image = post.get("feature_image")
|
||
|
||
label_parts = []
|
||
if feature_image:
|
||
label_parts.append(
|
||
f'<img src="{feature_image}" class="h-8 w-8 rounded-full object-cover border border-stone-300 flex-shrink-0">'
|
||
)
|
||
label_parts.append(f"<span>{escape(title)}</span>")
|
||
label_html = "".join(label_parts)
|
||
|
||
nav_parts = []
|
||
page_cart_count = ctx.get("page_cart_count", 0)
|
||
if page_cart_count and page_cart_count > 0:
|
||
cart_href = call_url(ctx, "cart_url", f"/{slug}/")
|
||
nav_parts.append(
|
||
f'<a href="{cart_href}" class="relative inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-full'
|
||
f' border border-emerald-300 bg-emerald-50 text-emerald-800 hover:bg-emerald-100 transition">'
|
||
f'<i class="fa fa-shopping-cart" aria-hidden="true"></i>'
|
||
f'<span>{page_cart_count}</span></a>'
|
||
)
|
||
|
||
# Post nav: calendar links + admin
|
||
nav_parts.append(_post_nav_html(ctx))
|
||
|
||
nav_html = "".join(nav_parts)
|
||
link_href = call_url(ctx, "blog_url", f"/{slug}/")
|
||
|
||
return sexp(
|
||
'(~menu-row :id "post-row" :level 1'
|
||
' :link-href lh :link-label-html llh'
|
||
' :nav-html nh :child-id "post-header-child" :oob oob)',
|
||
lh=link_href,
|
||
llh=label_html,
|
||
nh=nav_html,
|
||
oob=oob,
|
||
)
|
||
|
||
|
||
def _post_nav_html(ctx: dict) -> str:
|
||
"""Post desktop nav: calendar links + container nav (markets, etc.)."""
|
||
from quart import url_for, g
|
||
|
||
calendars = ctx.get("calendars") or []
|
||
select_colours = ctx.get("select_colours", "")
|
||
current_cal_slug = getattr(g, "calendar_slug", None)
|
||
|
||
parts = []
|
||
for cal in calendars:
|
||
cal_slug = getattr(cal, "slug", "") if hasattr(cal, "slug") else cal.get("slug", "")
|
||
cal_name = getattr(cal, "name", "") if hasattr(cal, "name") else cal.get("name", "")
|
||
href = url_for("calendars.calendar.get", calendar_slug=cal_slug)
|
||
is_sel = (cal_slug == current_cal_slug)
|
||
parts.append(sexp(
|
||
'(~nav-link :href h :icon "fa fa-calendar" :label l :select-colours sc :is-selected sel)',
|
||
h=href,
|
||
l=cal_name,
|
||
sc=select_colours,
|
||
sel=is_sel,
|
||
))
|
||
# Container nav fragments (markets, etc.)
|
||
container_nav = ctx.get("container_nav_html", "")
|
||
if container_nav:
|
||
parts.append(container_nav)
|
||
|
||
return "".join(parts)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Post admin header
|
||
# ---------------------------------------------------------------------------
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Calendars header
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _calendars_header_html(ctx: dict, *, oob: bool = False) -> str:
|
||
"""Build the calendars section header row."""
|
||
from quart import url_for
|
||
link_href = url_for("calendars.home")
|
||
return sexp(
|
||
'(~menu-row :id "calendars-row" :level 3'
|
||
' :link-href lh :link-label-html llh'
|
||
' :child-id "calendars-header-child" :oob oob)',
|
||
lh=link_href,
|
||
llh='<i class="fa fa-calendar" aria-hidden="true"></i><div>Calendars</div>',
|
||
oob=oob,
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Calendar header
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _calendar_header_html(ctx: dict, *, oob: bool = False) -> str:
|
||
"""Build a single calendar's header row."""
|
||
from quart import url_for
|
||
calendar = ctx.get("calendar")
|
||
if not calendar:
|
||
return ""
|
||
cal_slug = getattr(calendar, "slug", "")
|
||
cal_name = getattr(calendar, "name", "")
|
||
cal_desc = getattr(calendar, "description", "") or ""
|
||
|
||
link_href = url_for("calendars.calendar.get", calendar_slug=cal_slug)
|
||
label_html = (
|
||
'<div class="flex flex-col md:flex-row md:gap-2 items-center min-w-0">'
|
||
'<div class="flex flex-row items-center gap-2">'
|
||
f'<i class="fa fa-calendar"></i>'
|
||
f'<div class="shrink-0">{escape(cal_name)}</div>'
|
||
'</div>'
|
||
f'<div id="calendar-description-title" class="text-base font-normal break-words whitespace-normal min-w-0 break-all w-full text-center block">{escape(cal_desc)}</div>'
|
||
'</div>'
|
||
)
|
||
|
||
# Desktop nav: slots + admin
|
||
nav_html = _calendar_nav_html(ctx)
|
||
|
||
return sexp(
|
||
'(~menu-row :id "calendar-row" :level 3'
|
||
' :link-href lh :link-label-html llh'
|
||
' :nav-html nh :child-id "calendar-header-child" :oob oob)',
|
||
lh=link_href,
|
||
llh=label_html,
|
||
nh=nav_html,
|
||
oob=oob,
|
||
)
|
||
|
||
|
||
def _calendar_nav_html(ctx: dict) -> str:
|
||
"""Calendar desktop nav: Slots + admin link."""
|
||
from quart import url_for
|
||
calendar = ctx.get("calendar")
|
||
if not calendar:
|
||
return ""
|
||
cal_slug = getattr(calendar, "slug", "")
|
||
rights = ctx.get("rights") or {}
|
||
is_admin = getattr(rights, "admin", False) if hasattr(rights, "admin") else rights.get("admin", False)
|
||
select_colours = ctx.get("select_colours", "")
|
||
|
||
parts = []
|
||
slots_href = url_for("calendars.calendar.slots.get", calendar_slug=cal_slug)
|
||
parts.append(sexp(
|
||
'(~nav-link :href h :icon "fa fa-clock" :label "Slots" :select-colours sc)',
|
||
h=slots_href,
|
||
sc=select_colours,
|
||
))
|
||
if is_admin:
|
||
admin_href = url_for("calendars.calendar.admin.admin", calendar_slug=cal_slug)
|
||
parts.append(
|
||
f'<a href="{admin_href}" class="flex items-center gap-2 px-3 py-2 rounded text-sm">'
|
||
f'<i class="fa fa-cog" aria-hidden="true"></i></a>'
|
||
)
|
||
return "".join(parts)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Day header
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _day_header_html(ctx: dict, *, oob: bool = False) -> str:
|
||
"""Build day detail header row."""
|
||
from quart import url_for
|
||
calendar = ctx.get("calendar")
|
||
if not calendar:
|
||
return ""
|
||
cal_slug = getattr(calendar, "slug", "")
|
||
day_date = ctx.get("day_date")
|
||
if not day_date:
|
||
return ""
|
||
|
||
link_href = url_for(
|
||
"calendars.calendar.day.show_day",
|
||
calendar_slug=cal_slug,
|
||
year=day_date.year,
|
||
month=day_date.month,
|
||
day=day_date.day,
|
||
)
|
||
label_html = (
|
||
'<div class="flex gap-1 items-center">'
|
||
f'<i class="fa fa-calendar-day"></i>'
|
||
f' {escape(day_date.strftime("%A %d %B %Y"))}'
|
||
'</div>'
|
||
)
|
||
|
||
nav_html = _day_nav_html(ctx)
|
||
|
||
return sexp(
|
||
'(~menu-row :id "day-row" :level 4'
|
||
' :link-href lh :link-label-html llh'
|
||
' :nav-html nh :child-id "day-header-child" :oob oob)',
|
||
lh=link_href,
|
||
llh=label_html,
|
||
nh=nav_html,
|
||
oob=oob,
|
||
)
|
||
|
||
|
||
def _day_nav_html(ctx: dict) -> str:
|
||
"""Day desktop nav: confirmed entries scrolling menu + admin link."""
|
||
from quart import url_for
|
||
calendar = ctx.get("calendar")
|
||
if not calendar:
|
||
return ""
|
||
cal_slug = getattr(calendar, "slug", "")
|
||
day_date = ctx.get("day_date")
|
||
confirmed_entries = ctx.get("confirmed_entries") or []
|
||
rights = ctx.get("rights") or {}
|
||
is_admin = getattr(rights, "admin", False) if hasattr(rights, "admin") else rights.get("admin", False)
|
||
|
||
parts = []
|
||
# Confirmed entries nav (scrolling menu)
|
||
if confirmed_entries:
|
||
parts.append(
|
||
'<div class="flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"'
|
||
' id="day-entries-nav-wrapper">'
|
||
'<div class="flex overflow-x-auto gap-1 scrollbar-thin">'
|
||
)
|
||
for entry in confirmed_entries:
|
||
href = url_for(
|
||
"calendars.calendar.day.calendar_entries.calendar_entry.get",
|
||
calendar_slug=cal_slug,
|
||
year=day_date.year,
|
||
month=day_date.month,
|
||
day=day_date.day,
|
||
entry_id=entry.id,
|
||
)
|
||
name = escape(entry.name)
|
||
start = entry.start_at.strftime("%H:%M") if entry.start_at else ""
|
||
end = f" – {entry.end_at.strftime('%H:%M')}" if entry.end_at else ""
|
||
parts.append(
|
||
f'<a href="{href}" class="flex items-center gap-2 px-3 py-2 hover:bg-stone-100 rounded transition text-sm border sm:whitespace-nowrap sm:flex-shrink-0">'
|
||
f'<div class="flex-1 min-w-0">'
|
||
f'<div class="font-medium truncate">{name}</div>'
|
||
f'<div class="text-xs text-stone-600 truncate">{start}{end}</div>'
|
||
f'</div></a>'
|
||
)
|
||
parts.append('</div></div>')
|
||
|
||
if is_admin and day_date:
|
||
admin_href = url_for(
|
||
"calendars.calendar.day.admin.admin",
|
||
calendar_slug=cal_slug,
|
||
year=day_date.year,
|
||
month=day_date.month,
|
||
day=day_date.day,
|
||
)
|
||
parts.append(
|
||
f'<a href="{admin_href}" class="flex items-center gap-2 px-3 py-2 rounded text-sm">'
|
||
f'<i class="fa fa-cog" aria-hidden="true"></i></a>'
|
||
)
|
||
return "".join(parts)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Day admin header
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _day_admin_header_html(ctx: dict, *, oob: bool = False) -> str:
|
||
"""Build day admin header row."""
|
||
from quart import url_for
|
||
calendar = ctx.get("calendar")
|
||
if not calendar:
|
||
return ""
|
||
cal_slug = getattr(calendar, "slug", "")
|
||
day_date = ctx.get("day_date")
|
||
if not day_date:
|
||
return ""
|
||
|
||
link_href = url_for(
|
||
"calendars.calendar.day.admin.admin",
|
||
calendar_slug=cal_slug,
|
||
year=day_date.year,
|
||
month=day_date.month,
|
||
day=day_date.day,
|
||
)
|
||
return sexp(
|
||
'(~menu-row :id "day-admin-row" :level 5'
|
||
' :link-href lh :link-label "admin" :icon "fa fa-cog"'
|
||
' :child-id "day-admin-header-child" :oob oob)',
|
||
lh=link_href,
|
||
oob=oob,
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Calendar admin header
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _calendar_admin_header_html(ctx: dict, *, oob: bool = False) -> str:
|
||
"""Build calendar admin header row with nav links."""
|
||
from quart import url_for
|
||
calendar = ctx.get("calendar")
|
||
cal_slug = getattr(calendar, "slug", "") if calendar else ""
|
||
select_colours = ctx.get("select_colours", "")
|
||
|
||
nav_parts = []
|
||
if cal_slug:
|
||
for endpoint, label in [
|
||
("calendars.calendar.slots.get", "slots"),
|
||
("calendars.calendar.admin.calendar_description_edit", "description"),
|
||
]:
|
||
href = url_for(endpoint, calendar_slug=cal_slug)
|
||
nav_parts.append(sexp(
|
||
'(~nav-link :href h :label l :select-colours sc)',
|
||
h=href, l=label, sc=select_colours,
|
||
))
|
||
|
||
nav_html = "".join(nav_parts)
|
||
return sexp(
|
||
'(~menu-row :id "calendar-admin-row" :level 4'
|
||
' :link-label "admin" :icon "fa fa-cog"'
|
||
' :nav-html nh :child-id "calendar-admin-header-child" :oob oob)',
|
||
nh=nav_html,
|
||
oob=oob,
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Markets header
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _markets_header_html(ctx: dict, *, oob: bool = False) -> str:
|
||
"""Build the markets section header row."""
|
||
from quart import url_for
|
||
link_href = url_for("markets.home")
|
||
return sexp(
|
||
'(~menu-row :id "markets-row" :level 3'
|
||
' :link-href lh :link-label-html llh'
|
||
' :child-id "markets-header-child" :oob oob)',
|
||
lh=link_href,
|
||
llh='<i class="fa fa-shopping-bag" aria-hidden="true"></i><div>Markets</div>',
|
||
oob=oob,
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Payments header
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _payments_header_html(ctx: dict, *, oob: bool = False) -> str:
|
||
"""Build the payments section header row."""
|
||
from quart import url_for
|
||
link_href = url_for("payments.home")
|
||
return sexp(
|
||
'(~menu-row :id "payments-row" :level 3'
|
||
' :link-href lh :link-label-html llh'
|
||
' :child-id "payments-header-child" :oob oob)',
|
||
lh=link_href,
|
||
llh='<i class="fa fa-credit-card" aria-hidden="true"></i><div>Payments</div>',
|
||
oob=oob,
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Calendars main panel
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _calendars_main_panel_html(ctx: dict) -> str:
|
||
"""Render the calendars list + create form panel."""
|
||
from quart import url_for
|
||
rights = ctx.get("rights") or {}
|
||
is_admin = getattr(rights, "admin", False) if hasattr(rights, "admin") else rights.get("admin", False)
|
||
has_access = ctx.get("has_access")
|
||
can_create = has_access("calendars.create_calendar") if callable(has_access) else is_admin
|
||
csrf_token = ctx.get("csrf_token")
|
||
csrf = csrf_token() if callable(csrf_token) else (csrf_token or "")
|
||
|
||
calendars = ctx.get("calendars") or []
|
||
hx_select = ctx.get("hx_select_search", "#main-panel")
|
||
|
||
parts = ['<section class="p-4">']
|
||
if can_create:
|
||
create_url = url_for("calendars.create_calendar")
|
||
parts.append(
|
||
'<div id="cal-create-errors" class="mt-2 text-sm text-red-600"></div>'
|
||
f'<form class="mt-4 flex gap-2 items-end" hx-post="{create_url}" '
|
||
'hx-target="#calendars-list" hx-select="#calendars-list" hx-swap="outerHTML" '
|
||
"""hx-on::before-request="document.querySelector('#cal-create-errors').textContent='';" """
|
||
"""hx-on::response-error="document.querySelector('#cal-create-errors').innerHTML = event.detail.xhr.responseText;">"""
|
||
f'<input type="hidden" name="csrf_token" value="{csrf}">'
|
||
'<div class="flex-1"><label class="block text-sm text-gray-600">Name</label>'
|
||
'<input name="name" type="text" required class="w-full border rounded px-3 py-2" '
|
||
'placeholder="e.g. Events, Gigs, Meetings" /></div>'
|
||
'<button type="submit" class="border rounded px-3 py-2">Add calendar</button></form>'
|
||
)
|
||
|
||
parts.append('<div id="calendars-list" class="mt-6">')
|
||
parts.append(_calendars_list_html(ctx, calendars))
|
||
parts.append('</div></section>')
|
||
return "".join(parts)
|
||
|
||
|
||
def _calendars_list_html(ctx: dict, calendars: list) -> str:
|
||
"""Render the calendars list items."""
|
||
from quart import url_for
|
||
from shared.utils import route_prefix
|
||
hx_select = ctx.get("hx_select_search", "#main-panel")
|
||
csrf_token = ctx.get("csrf_token")
|
||
csrf = csrf_token() if callable(csrf_token) else (csrf_token or "")
|
||
prefix = route_prefix()
|
||
|
||
if not calendars:
|
||
return '<p class="text-gray-500 mt-4">No calendars yet. Create one above.</p>'
|
||
|
||
parts = []
|
||
for cal in calendars:
|
||
cal_slug = getattr(cal, "slug", "")
|
||
cal_name = getattr(cal, "name", "")
|
||
href = prefix + url_for("calendars.calendar.get", calendar_slug=cal_slug)
|
||
del_url = url_for("calendars.calendar.delete", calendar_slug=cal_slug)
|
||
parts.append(
|
||
f'<div class="mt-6 border rounded-lg p-4"><div class="flex items-center justify-between gap-3">'
|
||
f'<a class="flex items-baseline gap-3" href="{href}" '
|
||
f'hx-get="{href}" hx-target="#main-panel" hx-select="{hx_select}" hx-swap="outerHTML" hx-push-url="true">'
|
||
f'<h3 class="font-semibold">{escape(cal_name)}</h3>'
|
||
f'<h4 class="text-gray-500">/{escape(cal_slug)}/</h4></a>'
|
||
f'<button class="text-sm border rounded px-3 py-1 hover:bg-red-50 hover:border-red-400" '
|
||
f'data-confirm data-confirm-title="Delete calendar?" '
|
||
f'data-confirm-text="Entries will be hidden (soft delete)" '
|
||
f'data-confirm-icon="warning" data-confirm-confirm-text="Yes, delete it" '
|
||
f'data-confirm-cancel-text="Cancel" data-confirm-event="confirmed" '
|
||
f'hx-delete="{del_url}" hx-trigger="confirmed" '
|
||
f'hx-target="#calendars-list" hx-select="#calendars-list" hx-swap="outerHTML" '
|
||
f"""hx-headers='{{"X-CSRFToken":"{csrf}"}}'>"""
|
||
f'<i class="fa-solid fa-trash"></i></button></div></div>'
|
||
)
|
||
return "".join(parts)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Calendar month grid
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _calendar_main_panel_html(ctx: dict) -> str:
|
||
"""Render the calendar month grid."""
|
||
from quart import url_for
|
||
from quart import session as qsession
|
||
|
||
calendar = ctx.get("calendar")
|
||
if not calendar:
|
||
return ""
|
||
cal_slug = getattr(calendar, "slug", "")
|
||
hx_select = ctx.get("hx_select_search", "#main-panel")
|
||
styles = ctx.get("styles") or {}
|
||
pill_cls = getattr(styles, "pill", "") if hasattr(styles, "pill") else styles.get("pill", "")
|
||
|
||
year = ctx.get("year", 2024)
|
||
month = ctx.get("month", 1)
|
||
month_name = ctx.get("month_name", "")
|
||
weekday_names = ctx.get("weekday_names", [])
|
||
weeks = ctx.get("weeks", [])
|
||
prev_month = ctx.get("prev_month", 1)
|
||
prev_month_year = ctx.get("prev_month_year", year)
|
||
next_month = ctx.get("next_month", 1)
|
||
next_month_year = ctx.get("next_month_year", year)
|
||
prev_year = ctx.get("prev_year", year - 1)
|
||
next_year = ctx.get("next_year", year + 1)
|
||
month_entries = ctx.get("month_entries") or []
|
||
user = ctx.get("user")
|
||
qs = qsession if "qsession" not in ctx else ctx["qsession"]
|
||
|
||
def nav_link(y, m):
|
||
href = url_for("calendars.calendar.get", calendar_slug=cal_slug, year=y, month=m)
|
||
return href
|
||
|
||
# Month navigation header
|
||
parts = ['<section class="bg-orange-100">']
|
||
parts.append('<header class="flex items-center justify-center mt-2">')
|
||
parts.append('<nav class="flex items-center gap-2 text-2xl">')
|
||
|
||
# Year/month nav arrows
|
||
for label, yr, mn in [
|
||
("«", prev_year, month),
|
||
("‹", prev_month_year, prev_month),
|
||
]:
|
||
href = nav_link(yr, mn)
|
||
parts.append(
|
||
f'<a class="{pill_cls} text-xl" href="{href}" '
|
||
f'hx-get="{href}" hx-target="#main-panel" hx-select="{hx_select}" '
|
||
f'hx-swap="outerHTML" hx-push-url="true">{label}</a>'
|
||
)
|
||
|
||
parts.append(f'<div class="px-3 font-medium">{escape(month_name)} {year}</div>')
|
||
|
||
for label, yr, mn in [
|
||
("›", next_month_year, next_month),
|
||
("»", next_year, month),
|
||
]:
|
||
href = nav_link(yr, mn)
|
||
parts.append(
|
||
f'<a class="{pill_cls} text-xl" href="{href}" '
|
||
f'hx-get="{href}" hx-target="#main-panel" hx-select="{hx_select}" '
|
||
f'hx-swap="outerHTML" hx-push-url="true">{label}</a>'
|
||
)
|
||
parts.append('</nav></header>')
|
||
|
||
# Calendar grid
|
||
parts.append('<div class="rounded-2xl border border-stone-200 bg-white/80 p-4">')
|
||
# Weekday headers
|
||
parts.append('<div class="hidden sm:grid grid-cols-7 text-center text-md font-semibold text-stone-700 mb-2">')
|
||
for wd in weekday_names:
|
||
parts.append(f'<div class="py-1">{wd}</div>')
|
||
parts.append('</div>')
|
||
|
||
# Weeks grid
|
||
parts.append('<div class="grid grid-cols-1 sm:grid-cols-7 gap-px bg-stone-200 rounded-xl overflow-hidden">')
|
||
for week in weeks:
|
||
for day_cell in week:
|
||
if isinstance(day_cell, dict):
|
||
in_month = day_cell.get("in_month", True)
|
||
is_today = day_cell.get("is_today", False)
|
||
day_date = day_cell.get("date")
|
||
else:
|
||
in_month = getattr(day_cell, "in_month", True)
|
||
is_today = getattr(day_cell, "is_today", False)
|
||
day_date = getattr(day_cell, "date", None)
|
||
|
||
cell_cls = "min-h-20 sm:min-h-24 bg-white px-3 py-2 text-xs"
|
||
if not in_month:
|
||
cell_cls += " bg-stone-50 text-stone-400"
|
||
if is_today:
|
||
cell_cls += " ring-2 ring-blue-500 z-10 relative"
|
||
|
||
parts.append(f'<div class="{cell_cls}">')
|
||
parts.append('<div class="flex justify-between items-center"><div class="flex flex-col">')
|
||
if day_date:
|
||
parts.append(f'<span class="sm:hidden text-[16px] text-stone-500">{day_date.strftime("%a")}</span>')
|
||
|
||
# Clickable day number
|
||
if day_date:
|
||
day_href = url_for(
|
||
"calendars.calendar.day.show_day",
|
||
calendar_slug=cal_slug,
|
||
year=day_date.year,
|
||
month=day_date.month,
|
||
day=day_date.day,
|
||
)
|
||
parts.append(
|
||
f'<a class="{pill_cls}" href="{day_href}" '
|
||
f'hx-get="{day_href}" hx-target="#main-panel" hx-select="{hx_select}" '
|
||
f'hx-swap="outerHTML" hx-push-url="true">{day_date.day}</a>'
|
||
)
|
||
parts.append('</div></div>')
|
||
|
||
# Entries for this day
|
||
parts.append('<div class="mt-1 space-y-0.5">')
|
||
if day_date:
|
||
for e in month_entries:
|
||
if e.start_at and e.start_at.date() == day_date:
|
||
is_mine = (
|
||
(user and e.user_id == user.id)
|
||
or (not user and e.session_id == qs.get("calendar_sid"))
|
||
)
|
||
if e.state == "confirmed":
|
||
bg_cls = "bg-emerald-200 text-emerald-900" if is_mine else "bg-emerald-100 text-emerald-800"
|
||
else:
|
||
bg_cls = "bg-sky-100 text-sky-800" if is_mine else "bg-stone-100 text-stone-700"
|
||
|
||
state_label = (e.state or "pending").replace("_", " ")
|
||
parts.append(
|
||
f'<div class="flex items-center justify-between gap-1 text-[11px] rounded px-1 py-0.5 {bg_cls}">'
|
||
f'<span class="truncate">{escape(e.name)}</span>'
|
||
f'<span class="shrink-0 text-[10px] font-semibold uppercase tracking-tight">{state_label}</span>'
|
||
f'</div>'
|
||
)
|
||
parts.append('</div></div>')
|
||
|
||
parts.append('</div></div></section>')
|
||
return "".join(parts)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Day main panel
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _day_main_panel_html(ctx: dict) -> str:
|
||
"""Render the day entries table + add button."""
|
||
from quart import url_for
|
||
|
||
calendar = ctx.get("calendar")
|
||
if not calendar:
|
||
return ""
|
||
cal_slug = getattr(calendar, "slug", "")
|
||
day_entries = ctx.get("day_entries") or []
|
||
day = ctx.get("day")
|
||
month = ctx.get("month")
|
||
year = ctx.get("year")
|
||
hx_select = ctx.get("hx_select_search", "#main-panel")
|
||
styles = ctx.get("styles") or {}
|
||
list_container = getattr(styles, "list_container", "") if hasattr(styles, "list_container") else styles.get("list_container", "")
|
||
pill_cls = getattr(styles, "pill", "") if hasattr(styles, "pill") else styles.get("pill", "")
|
||
tr_cls = getattr(styles, "tr", "") if hasattr(styles, "tr") else styles.get("tr", "")
|
||
pre_action = getattr(styles, "pre_action_button", "") if hasattr(styles, "pre_action_button") else styles.get("pre_action_button", "")
|
||
|
||
parts = [f'<section id="day-entries" class="{list_container}">']
|
||
parts.append(
|
||
'<table class="w-full text-sm border table-fixed"><thead class="bg-stone-100"><tr>'
|
||
'<th class="p-2 text-left w-2/6">Name</th>'
|
||
'<th class="text-left p-2 w-1/6">Slot/Time</th>'
|
||
'<th class="text-left p-2 w-1/6">State</th>'
|
||
'<th class="text-left p-2 w-1/6">Cost</th>'
|
||
'<th class="text-left p-2 w-1/6">Tickets</th>'
|
||
'<th class="text-left p-2 w-1/6">Actions</th>'
|
||
'</tr></thead><tbody>'
|
||
)
|
||
|
||
if day_entries:
|
||
for entry in day_entries:
|
||
parts.append(_day_row_html(ctx, entry))
|
||
else:
|
||
parts.append('<tr><td colspan="6" class="p-3 text-stone-500">No entries yet.</td></tr>')
|
||
|
||
parts.append('</tbody></table>')
|
||
|
||
# Add entry button
|
||
add_url = url_for(
|
||
"calendars.calendar.day.calendar_entries.add_form",
|
||
calendar_slug=cal_slug,
|
||
day=day, month=month, year=year,
|
||
)
|
||
parts.append(
|
||
f'<div id="entry-add-container" class="mt-4">'
|
||
f'<button type="button" class="{pre_action}" '
|
||
f'hx-get="{add_url}" hx-target="#entry-add-container" hx-swap="innerHTML">'
|
||
f'+ Add entry</button></div>'
|
||
)
|
||
parts.append('</section>')
|
||
return "".join(parts)
|
||
|
||
|
||
def _day_row_html(ctx: dict, entry) -> str:
|
||
"""Render a single day table row."""
|
||
from quart import url_for
|
||
calendar = ctx.get("calendar")
|
||
cal_slug = getattr(calendar, "slug", "")
|
||
day = ctx.get("day")
|
||
month = ctx.get("month")
|
||
year = ctx.get("year")
|
||
hx_select = ctx.get("hx_select_search", "#main-panel")
|
||
styles = ctx.get("styles") or {}
|
||
pill_cls = getattr(styles, "pill", "") if hasattr(styles, "pill") else styles.get("pill", "")
|
||
tr_cls = getattr(styles, "tr", "") if hasattr(styles, "tr") else styles.get("tr", "")
|
||
|
||
entry_href = url_for(
|
||
"calendars.calendar.day.calendar_entries.calendar_entry.get",
|
||
calendar_slug=cal_slug, day=day, month=month, year=year, entry_id=entry.id,
|
||
)
|
||
|
||
# Name
|
||
name_html = (
|
||
f'<td class="p-2 align-top w-2/6"><div class="font-medium">'
|
||
f'<a href="{entry_href}" class="{pill_cls}" '
|
||
f'hx-get="{entry_href}" hx-target="#main-panel" hx-select="{hx_select}" hx-swap="outerHTML" hx-push-url="true">'
|
||
f'{escape(entry.name)}</a></div></td>'
|
||
)
|
||
|
||
# Slot/Time
|
||
slot = getattr(entry, "slot", None)
|
||
if slot:
|
||
slot_href = url_for("calendars.calendar.slots.slot.get", calendar_slug=cal_slug, slot_id=slot.id)
|
||
time_start = slot.time_start.strftime("%H:%M") if slot.time_start else ""
|
||
time_end = f" → {slot.time_end.strftime('%H:%M')}" if slot.time_end else ""
|
||
slot_html = (
|
||
f'<td class="p-2 align-top w-1/6"><div class="text-xs font-medium">'
|
||
f'<a href="{slot_href}" class="{pill_cls}" '
|
||
f'hx-get="{slot_href}" hx-target="#main-panel" hx-select="{hx_select}" hx-swap="outerHTML" hx-push-url="true">'
|
||
f'{escape(slot.name)}</a>'
|
||
f'<span class="text-stone-600 font-normal">({time_start}{time_end})</span>'
|
||
f'</div></td>'
|
||
)
|
||
else:
|
||
start = entry.start_at.strftime("%H:%M") if entry.start_at else ""
|
||
end = f" → {entry.end_at.strftime('%H:%M')}" if entry.end_at else ""
|
||
slot_html = f'<td class="p-2 align-top w-1/6"><div class="text-xs text-stone-600">{start}{end}</div></td>'
|
||
|
||
# State
|
||
state = getattr(entry, "state", "pending") or "pending"
|
||
state_html = _entry_state_badge_html(state)
|
||
state_td = f'<td class="p-2 align-top w-1/6"><div id="entry-state-{entry.id}">{state_html}</div></td>'
|
||
|
||
# Cost
|
||
cost = getattr(entry, "cost", None)
|
||
cost_str = f"£{cost:.2f}" if cost is not None else "£0.00"
|
||
cost_td = f'<td class="p-2 align-top w-1/6"><span class="font-medium text-green-600">{cost_str}</span></td>'
|
||
|
||
# Tickets
|
||
tp = getattr(entry, "ticket_price", None)
|
||
if tp is not None:
|
||
tc = getattr(entry, "ticket_count", None)
|
||
tc_str = f"{tc} tickets" if tc is not None else "Unlimited"
|
||
tickets_td = (
|
||
f'<td class="p-2 align-top w-1/6"><div class="text-xs space-y-1">'
|
||
f'<div class="font-medium text-green-600">£{tp:.2f}</div>'
|
||
f'<div class="text-stone-600">{tc_str}</div></div></td>'
|
||
)
|
||
else:
|
||
tickets_td = '<td class="p-2 align-top w-1/6"><span class="text-xs text-stone-400">No tickets</span></td>'
|
||
|
||
# Actions (entry options) - keep simple, just link to entry
|
||
actions_td = f'<td class="p-2 align-top w-1/6"></td>'
|
||
|
||
return f'<tr class="{tr_cls}">{name_html}{slot_html}{state_td}{cost_td}{tickets_td}{actions_td}</tr>'
|
||
|
||
|
||
def _entry_state_badge_html(state: str) -> str:
|
||
"""Render an entry state badge."""
|
||
state_classes = {
|
||
"confirmed": "bg-emerald-100 text-emerald-800",
|
||
"provisional": "bg-amber-100 text-amber-800",
|
||
"ordered": "bg-sky-100 text-sky-800",
|
||
"pending": "bg-stone-100 text-stone-700",
|
||
"declined": "bg-red-100 text-red-800",
|
||
}
|
||
cls = state_classes.get(state, "bg-stone-100 text-stone-700")
|
||
label = state.replace("_", " ").capitalize()
|
||
return f'<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium {cls}">{label}</span>'
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Day admin main panel
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _day_admin_main_panel_html(ctx: dict) -> str:
|
||
"""Render day admin panel (placeholder nav)."""
|
||
return '<div class="p-4 text-sm text-stone-500">Admin options</div>'
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Calendar admin main panel
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _calendar_admin_main_panel_html(ctx: dict) -> str:
|
||
"""Render calendar admin config panel with description editor."""
|
||
from quart import url_for
|
||
calendar = ctx.get("calendar")
|
||
if not calendar:
|
||
return ""
|
||
csrf_token = ctx.get("csrf_token")
|
||
csrf = csrf_token() if callable(csrf_token) else (csrf_token or "")
|
||
cal_slug = getattr(calendar, "slug", "")
|
||
desc = getattr(calendar, "description", "") or ""
|
||
hx_select = ctx.get("hx_select_search", "#main-panel")
|
||
|
||
desc_edit_url = url_for("calendars.calendar.admin.calendar_description_edit", calendar_slug=cal_slug)
|
||
description_html = _calendar_description_display_html(calendar, desc_edit_url)
|
||
|
||
parts = ['<section class="max-w-3xl mx-auto p-4 space-y-10">']
|
||
parts.append('<div><h2 class="text-xl font-semibold">Calendar configuration</h2>')
|
||
parts.append('<div id="cal-put-errors" class="mt-2 text-sm text-red-600"></div>')
|
||
parts.append(f'<div><label class="block text-sm font-medium text-stone-700">Description</label>')
|
||
parts.append(description_html)
|
||
parts.append('</div>')
|
||
|
||
# Hidden form for direct PUT
|
||
parts.append(
|
||
f'<form id="calendar-form" method="post" hx-target="#main-panel" hx-select="{hx_select}" '
|
||
"""hx-on::before-request="document.querySelector('#cal-put-errors').textContent='';" """
|
||
"""hx-on::response-error="document.querySelector('#cal-put-errors').innerHTML = event.detail.xhr.responseText;" """
|
||
"""hx-on::after-request="if (event.detail.successful) this.reset()" """
|
||
'class="hidden space-y-4 mt-4" autocomplete="off">'
|
||
f'<input type="hidden" name="csrf_token" value="{csrf}">'
|
||
'<div><label class="block text-sm font-medium text-stone-700">Description</label>'
|
||
f'<div>{escape(desc)}</div>'
|
||
f'<textarea name="description" autocomplete="off" rows="4" class="w-full p-2 border rounded">{escape(desc)}</textarea>'
|
||
'</div><div><button class="px-3 py-2 rounded bg-stone-800 text-white">Save</button></div></form>'
|
||
)
|
||
parts.append('</div><hr class="border-stone-200"></section>')
|
||
return "".join(parts)
|
||
|
||
|
||
def _calendar_description_display_html(calendar, edit_url: str) -> str:
|
||
"""Render calendar description display with edit button."""
|
||
desc = getattr(calendar, "description", "") or ""
|
||
if desc:
|
||
desc_html = f'<p class="text-stone-700 whitespace-pre-line break-all">{escape(desc)}</p>'
|
||
else:
|
||
desc_html = '<p class="text-stone-400 italic">No description yet.</p>'
|
||
return (
|
||
f'<div id="calendar-description">{desc_html}'
|
||
f'<button type="button" class="mt-2 text-xs underline" '
|
||
f'hx-get="{edit_url}" hx-target="#calendar-description" hx-swap="outerHTML">'
|
||
f'<i class="fas fa-edit"></i></button></div>'
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Markets main panel
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _markets_main_panel_html(ctx: dict) -> str:
|
||
"""Render markets list + create form panel."""
|
||
from quart import url_for
|
||
rights = ctx.get("rights") or {}
|
||
is_admin = getattr(rights, "admin", False) if hasattr(rights, "admin") else rights.get("admin", False)
|
||
has_access = ctx.get("has_access")
|
||
can_create = has_access("markets.create_market") if callable(has_access) else is_admin
|
||
csrf_token = ctx.get("csrf_token")
|
||
csrf = csrf_token() if callable(csrf_token) else (csrf_token or "")
|
||
markets = ctx.get("markets") or []
|
||
post = ctx.get("post") or {}
|
||
|
||
parts = ['<section class="p-4">']
|
||
if can_create:
|
||
create_url = url_for("markets.create_market")
|
||
parts.append(
|
||
'<div id="market-create-errors" class="mt-2 text-sm text-red-600"></div>'
|
||
f'<form class="mt-4 flex gap-2 items-end" hx-post="{create_url}" '
|
||
'hx-target="#markets-list" hx-select="#markets-list" hx-swap="outerHTML" '
|
||
"""hx-on::before-request="document.querySelector('#market-create-errors').textContent='';" """
|
||
"""hx-on::response-error="document.querySelector('#market-create-errors').innerHTML = event.detail.xhr.responseText;">"""
|
||
f'<input type="hidden" name="csrf_token" value="{csrf}">'
|
||
'<div class="flex-1"><label class="block text-sm text-gray-600">Name</label>'
|
||
'<input name="name" type="text" required class="w-full border rounded px-3 py-2" '
|
||
'placeholder="e.g. Farm Shop, Bakery" /></div>'
|
||
'<button type="submit" class="border rounded px-3 py-2">Add market</button></form>'
|
||
)
|
||
parts.append('<div id="markets-list" class="mt-6">')
|
||
parts.append(_markets_list_html(ctx, markets))
|
||
parts.append('</div></section>')
|
||
return "".join(parts)
|
||
|
||
|
||
def _markets_list_html(ctx: dict, markets: list) -> str:
|
||
"""Render markets list items."""
|
||
from quart import url_for
|
||
csrf_token = ctx.get("csrf_token")
|
||
csrf = csrf_token() if callable(csrf_token) else (csrf_token or "")
|
||
post = ctx.get("post") or {}
|
||
slug = post.get("slug", "")
|
||
|
||
if not markets:
|
||
return '<p class="text-gray-500 mt-4">No markets yet. Create one above.</p>'
|
||
|
||
parts = []
|
||
for m in markets:
|
||
m_slug = getattr(m, "slug", "") if hasattr(m, "slug") else m.get("slug", "")
|
||
m_name = getattr(m, "name", "") if hasattr(m, "name") else m.get("name", "")
|
||
market_href = call_url(ctx, "market_url", f"/{slug}/{m_slug}/")
|
||
del_url = url_for("markets.delete_market", market_slug=m_slug)
|
||
parts.append(
|
||
f'<div class="mt-6 border rounded-lg p-4"><div class="flex items-center justify-between gap-3">'
|
||
f'<a class="flex items-baseline gap-3" href="{market_href}">'
|
||
f'<h3 class="font-semibold">{escape(m_name)}</h3>'
|
||
f'<h4 class="text-gray-500">/{escape(m_slug)}/</h4></a>'
|
||
f'<button class="text-sm border rounded px-3 py-1 hover:bg-red-50 hover:border-red-400" '
|
||
f'data-confirm data-confirm-title="Delete market?" '
|
||
f'data-confirm-text="Products will be hidden (soft delete)" '
|
||
f'data-confirm-icon="warning" data-confirm-confirm-text="Yes, delete it" '
|
||
f'data-confirm-cancel-text="Cancel" data-confirm-event="confirmed" '
|
||
f'hx-delete="{del_url}" hx-trigger="confirmed" '
|
||
f'hx-target="#markets-list" hx-select="#markets-list" hx-swap="outerHTML" '
|
||
f"""hx-headers='{{"X-CSRFToken":"{csrf}"}}'>"""
|
||
f'<i class="fa-solid fa-trash"></i></button></div></div>'
|
||
)
|
||
return "".join(parts)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Payments main panel
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _payments_main_panel_html(ctx: dict) -> str:
|
||
"""Render SumUp payment config form."""
|
||
from quart import url_for
|
||
csrf_token = ctx.get("csrf_token")
|
||
csrf = csrf_token() if callable(csrf_token) else (csrf_token or "")
|
||
sumup_configured = ctx.get("sumup_configured", False)
|
||
merchant_code = ctx.get("sumup_merchant_code", "")
|
||
checkout_prefix = ctx.get("sumup_checkout_prefix", "")
|
||
update_url = url_for("payments.update_sumup")
|
||
|
||
placeholder = "--------" if sumup_configured else "sup_sk_..."
|
||
key_note = '<p class="text-xs text-stone-400 mt-0.5">Key is set. Leave blank to keep current key.</p>' if sumup_configured else ""
|
||
connected = ('<span class="ml-2 text-xs text-green-600">'
|
||
'<i class="fa fa-check-circle"></i> Connected</span>') if sumup_configured else ""
|
||
|
||
return (
|
||
'<section class="p-4 max-w-lg mx-auto">'
|
||
'<div id="payments-panel" class="space-y-4 p-4 bg-white rounded-lg border border-stone-200">'
|
||
'<h3 class="text-lg font-semibold text-stone-800">'
|
||
'<i class="fa fa-credit-card text-purple-600 mr-1"></i> SumUp Payment</h3>'
|
||
'<p class="text-xs text-stone-400">Configure per-page SumUp credentials. Leave blank to use the global merchant account.</p>'
|
||
f'<form hx-put="{update_url}" hx-target="#payments-panel" hx-swap="outerHTML" hx-select="#payments-panel" class="space-y-3">'
|
||
f'<input type="hidden" name="csrf_token" value="{csrf}">'
|
||
'<div><label class="block text-xs font-medium text-stone-600 mb-1">Merchant Code</label>'
|
||
f'<input type="text" name="merchant_code" value="{escape(merchant_code)}" placeholder="e.g. ME4J6100" '
|
||
'class="w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500"></div>'
|
||
'<div><label class="block text-xs font-medium text-stone-600 mb-1">API Key</label>'
|
||
f'<input type="password" name="api_key" value="" placeholder="{placeholder}" '
|
||
f'class="w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500">'
|
||
f'{key_note}</div>'
|
||
'<div><label class="block text-xs font-medium text-stone-600 mb-1">Checkout Reference Prefix</label>'
|
||
f'<input type="text" name="checkout_prefix" value="{escape(checkout_prefix)}" placeholder="e.g. ROSE-" '
|
||
'class="w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500"></div>'
|
||
'<button type="submit" class="px-4 py-1.5 text-sm font-medium text-white bg-purple-600 rounded hover:bg-purple-700 focus:ring-2 focus:ring-purple-500">'
|
||
f'Save SumUp Settings</button>{connected}</form></div></section>'
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Ticket state badge helper
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _ticket_state_badge_html(state: str) -> str:
|
||
"""Render a ticket state badge."""
|
||
cls_map = {
|
||
"confirmed": "bg-emerald-100 text-emerald-800",
|
||
"checked_in": "bg-blue-100 text-blue-800",
|
||
"reserved": "bg-amber-100 text-amber-800",
|
||
"cancelled": "bg-red-100 text-red-800",
|
||
}
|
||
cls = cls_map.get(state, "bg-stone-100 text-stone-700")
|
||
label = (state or "").replace("_", " ").capitalize()
|
||
return f'<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium {cls}">{label}</span>'
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Tickets main panel (my tickets)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _tickets_main_panel_html(ctx: dict, tickets: list) -> str:
|
||
"""Render my tickets list."""
|
||
from quart import url_for
|
||
|
||
parts = [f'<section id="tickets-list" class="{_list_container(ctx)}">']
|
||
parts.append('<h1 class="text-2xl font-bold mb-6">My Tickets</h1>')
|
||
|
||
if tickets:
|
||
parts.append('<div class="space-y-4">')
|
||
for ticket in tickets:
|
||
href = url_for("tickets.ticket_detail", code=ticket.code)
|
||
entry = getattr(ticket, "entry", None)
|
||
entry_name = entry.name if entry else "Unknown event"
|
||
tt = getattr(ticket, "ticket_type", None)
|
||
state = getattr(ticket, "state", "")
|
||
|
||
parts.append(
|
||
f'<a href="{href}" class="block rounded-xl border border-stone-200 bg-white p-4 hover:shadow-md transition">'
|
||
'<div class="flex items-start justify-between gap-4"><div class="flex-1 min-w-0">'
|
||
f'<div class="font-semibold text-lg truncate">{escape(entry_name)}</div>'
|
||
)
|
||
if tt:
|
||
parts.append(f'<div class="text-sm text-stone-600 mt-0.5">{escape(tt.name)}</div>')
|
||
if entry and entry.start_at:
|
||
parts.append(
|
||
'<div class="text-sm text-stone-500 mt-1">'
|
||
f'{entry.start_at.strftime("%A, %B %d, %Y at %H:%M")}'
|
||
)
|
||
if entry.end_at:
|
||
parts.append(f' – {entry.end_at.strftime("%H:%M")}')
|
||
parts.append('</div>')
|
||
cal = getattr(entry, "calendar", None)
|
||
if cal:
|
||
parts.append(f'<div class="text-xs text-stone-400 mt-0.5">{escape(cal.name)}</div>')
|
||
|
||
parts.append('</div><div class="flex flex-col items-end gap-1 flex-shrink-0">')
|
||
parts.append(_ticket_state_badge_html(state))
|
||
parts.append(f'<span class="text-xs text-stone-400 font-mono">{ticket.code[:8]}...</span>')
|
||
parts.append('</div></div></a>')
|
||
parts.append('</div>')
|
||
else:
|
||
parts.append(
|
||
'<div class="text-center py-12 text-stone-500">'
|
||
'<i class="fa fa-ticket text-4xl mb-4 block" aria-hidden="true"></i>'
|
||
'<p class="text-lg">No tickets yet</p>'
|
||
'<p class="text-sm mt-1">Tickets will appear here after you purchase them.</p></div>'
|
||
)
|
||
parts.append('</section>')
|
||
return "".join(parts)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Ticket detail panel
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _ticket_detail_panel_html(ctx: dict, ticket) -> str:
|
||
"""Render a single ticket detail with QR code."""
|
||
from quart import url_for
|
||
|
||
entry = getattr(ticket, "entry", None)
|
||
tt = getattr(ticket, "ticket_type", None)
|
||
state = getattr(ticket, "state", "")
|
||
code = ticket.code
|
||
|
||
# Background color for header
|
||
bg_map = {"confirmed": "bg-emerald-50", "checked_in": "bg-blue-50", "reserved": "bg-amber-50"}
|
||
header_bg = bg_map.get(state, "bg-stone-50")
|
||
|
||
entry_name = entry.name if entry else "Ticket"
|
||
back_href = url_for("tickets.my_tickets")
|
||
|
||
parts = [f'<section id="ticket-detail" class="{_list_container(ctx)} max-w-lg mx-auto">']
|
||
parts.append(
|
||
f'<a href="{back_href}" class="inline-flex items-center gap-1 text-sm text-stone-500 hover:text-stone-700 mb-4">'
|
||
'<i class="fa fa-arrow-left" aria-hidden="true"></i> Back to my tickets</a>'
|
||
)
|
||
|
||
parts.append('<div class="rounded-2xl border border-stone-200 bg-white overflow-hidden">')
|
||
# Header
|
||
parts.append(f'<div class="px-6 py-4 border-b border-stone-100 {header_bg}">')
|
||
parts.append(f'<div class="flex items-center justify-between"><h1 class="text-xl font-bold">{escape(entry_name)}</h1>')
|
||
parts.append(_ticket_state_badge_html(state).replace('px-2 py-0.5 text-xs', 'px-3 py-1 text-sm'))
|
||
parts.append('</div>')
|
||
if tt:
|
||
parts.append(f'<div class="text-sm text-stone-600 mt-1">{escape(tt.name)}</div>')
|
||
parts.append('</div>')
|
||
|
||
# QR code
|
||
parts.append(
|
||
f'<div class="px-6 py-8 flex flex-col items-center border-b border-stone-100">'
|
||
f'<div id="ticket-qr-{code}" class="bg-white p-4 rounded-lg border border-stone-200"></div>'
|
||
f'<p class="text-xs text-stone-400 mt-3 font-mono select-all">{code}</p></div>'
|
||
)
|
||
|
||
# Event details
|
||
parts.append('<div class="px-6 py-4 space-y-3">')
|
||
if entry and entry.start_at:
|
||
parts.append(
|
||
'<div class="flex items-start gap-3"><i class="fa fa-calendar text-stone-400 mt-0.5" aria-hidden="true"></i>'
|
||
f'<div><div class="text-sm font-medium">{entry.start_at.strftime("%A, %B %d, %Y")}</div>'
|
||
f'<div class="text-sm text-stone-500">{entry.start_at.strftime("%H:%M")}'
|
||
)
|
||
if entry.end_at:
|
||
parts.append(f' – {entry.end_at.strftime("%H:%M")}')
|
||
parts.append('</div></div></div>')
|
||
|
||
cal = getattr(entry, "calendar", None)
|
||
if cal:
|
||
parts.append(
|
||
'<div class="flex items-start gap-3"><i class="fa fa-map-pin text-stone-400 mt-0.5" aria-hidden="true"></i>'
|
||
f'<div class="text-sm">{escape(cal.name)}</div></div>'
|
||
)
|
||
|
||
if tt and getattr(tt, "cost", None):
|
||
parts.append(
|
||
'<div class="flex items-start gap-3"><i class="fa fa-tag text-stone-400 mt-0.5" aria-hidden="true"></i>'
|
||
f'<div class="text-sm">{escape(tt.name)} — £{tt.cost:.2f}</div></div>'
|
||
)
|
||
|
||
checked_in_at = getattr(ticket, "checked_in_at", None)
|
||
if checked_in_at:
|
||
parts.append(
|
||
'<div class="flex items-start gap-3"><i class="fa fa-check-circle text-blue-500 mt-0.5" aria-hidden="true"></i>'
|
||
f'<div class="text-sm text-blue-700">Checked in: {checked_in_at.strftime("%B %d, %Y at %H:%M")}</div></div>'
|
||
)
|
||
parts.append('</div></div>')
|
||
|
||
# QR code script
|
||
parts.append(
|
||
'<script src="https://cdn.jsdelivr.net/npm/qrcode@1.5.3/build/qrcode.min.js"></script>'
|
||
'<script>(function(){'
|
||
f"var c=document.getElementById('ticket-qr-{code}');"
|
||
"if(c&&typeof QRCode!=='undefined'){"
|
||
"var cv=document.createElement('canvas');"
|
||
f"QRCode.toCanvas(cv,'{code}',{{width:200,margin:2,color:{{dark:'#1c1917',light:'#ffffff'}}}},function(e){{if(!e)c.appendChild(cv)}});"
|
||
'}})();</script>'
|
||
)
|
||
parts.append('</section>')
|
||
return "".join(parts)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Ticket admin main panel
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _ticket_admin_main_panel_html(ctx: dict, tickets: list, stats: dict) -> str:
|
||
"""Render ticket admin dashboard."""
|
||
from quart import url_for
|
||
csrf_token = ctx.get("csrf_token")
|
||
csrf = csrf_token() if callable(csrf_token) else (csrf_token or "")
|
||
lookup_url = url_for("ticket_admin.lookup")
|
||
|
||
parts = [f'<section id="ticket-admin" class="{_list_container(ctx)}">']
|
||
parts.append('<h1 class="text-2xl font-bold mb-6">Ticket Admin</h1>')
|
||
|
||
# Stats
|
||
parts.append('<div class="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-8">')
|
||
for label, key, border, bg, text_cls in [
|
||
("Total", "total", "border-stone-200", "", "text-stone-900"),
|
||
("Confirmed", "confirmed", "border-emerald-200", "bg-emerald-50", "text-emerald-700"),
|
||
("Checked In", "checked_in", "border-blue-200", "bg-blue-50", "text-blue-700"),
|
||
("Reserved", "reserved", "border-amber-200", "bg-amber-50", "text-amber-700"),
|
||
]:
|
||
val = stats.get(key, 0)
|
||
lbl_cls = text_cls.replace("700", "600").replace("900", "500") if "stone" not in text_cls else "text-stone-500"
|
||
parts.append(
|
||
f'<div class="rounded-xl border {border} {bg} p-4 text-center">'
|
||
f'<div class="text-2xl font-bold {text_cls}">{val}</div>'
|
||
f'<div class="text-xs {lbl_cls} uppercase tracking-wide">{label}</div></div>'
|
||
)
|
||
parts.append('</div>')
|
||
|
||
# Scanner
|
||
parts.append(
|
||
'<div class="rounded-xl border border-stone-200 bg-white p-6 mb-8">'
|
||
'<h2 class="text-lg font-semibold mb-4"><i class="fa fa-qrcode mr-2" aria-hidden="true"></i>Scan / Look Up Ticket</h2>'
|
||
'<div class="flex gap-3 mb-4">'
|
||
f'<input type="text" id="ticket-code-input" name="code" placeholder="Enter or scan ticket code..." '
|
||
f'class="flex-1 px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" '
|
||
f'hx-get="{lookup_url}" hx-trigger="keyup changed delay:300ms" hx-target="#lookup-result" hx-include="this" autofocus />'
|
||
'<button type="button" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition" '
|
||
"""onclick="document.getElementById('ticket-code-input').dispatchEvent(new Event('keyup'))">"""
|
||
'<i class="fa fa-search" aria-hidden="true"></i></button></div>'
|
||
'<div id="lookup-result"><div class="text-sm text-stone-400 text-center py-4">'
|
||
'Enter a ticket code to look it up</div></div></div>'
|
||
)
|
||
|
||
# Recent tickets table
|
||
parts.append('<div class="rounded-xl border border-stone-200 bg-white overflow-hidden">')
|
||
parts.append('<h2 class="text-lg font-semibold px-6 py-4 border-b border-stone-100">Recent Tickets</h2>')
|
||
|
||
if tickets:
|
||
parts.append('<div class="overflow-x-auto"><table class="w-full text-sm"><thead class="bg-stone-50"><tr>')
|
||
for col in ["Code", "Event", "Type", "State", "Actions"]:
|
||
parts.append(f'<th class="px-4 py-3 text-left font-medium text-stone-600">{col}</th>')
|
||
parts.append('</tr></thead><tbody class="divide-y divide-stone-100">')
|
||
|
||
for ticket in tickets:
|
||
entry = getattr(ticket, "entry", None)
|
||
tt = getattr(ticket, "ticket_type", None)
|
||
state = getattr(ticket, "state", "")
|
||
code = ticket.code
|
||
|
||
parts.append(f'<tr class="hover:bg-stone-50 transition" id="ticket-row-{code}">')
|
||
parts.append(f'<td class="px-4 py-3"><span class="font-mono text-xs">{code[:12]}...</span></td>')
|
||
parts.append(f'<td class="px-4 py-3"><div class="font-medium">{escape(entry.name) if entry else "—"}</div>')
|
||
if entry and entry.start_at:
|
||
parts.append(f'<div class="text-xs text-stone-500">{entry.start_at.strftime("%d %b %Y, %H:%M")}</div>')
|
||
parts.append('</td>')
|
||
parts.append(f'<td class="px-4 py-3 text-sm">{escape(tt.name) if tt else "—"}</td>')
|
||
parts.append(f'<td class="px-4 py-3">{_ticket_state_badge_html(state)}</td>')
|
||
|
||
# Actions
|
||
parts.append('<td class="px-4 py-3">')
|
||
if state in ("confirmed", "reserved"):
|
||
checkin_url = url_for("ticket_admin.do_checkin", code=code)
|
||
parts.append(
|
||
f'<form hx-post="{checkin_url}" hx-target="#ticket-row-{code}" hx-swap="outerHTML">'
|
||
f'<input type="hidden" name="csrf_token" value="{csrf}" />'
|
||
'<button type="submit" class="px-3 py-1 bg-blue-600 text-white text-xs rounded hover:bg-blue-700 transition">'
|
||
'<i class="fa fa-check mr-1" aria-hidden="true"></i>Check in</button></form>'
|
||
)
|
||
elif state == "checked_in":
|
||
checked_in_at = getattr(ticket, "checked_in_at", None)
|
||
t_str = checked_in_at.strftime("%H:%M") if checked_in_at else ""
|
||
parts.append(f'<span class="text-xs text-blue-600"><i class="fa fa-check-circle" aria-hidden="true"></i> {t_str}</span>')
|
||
parts.append('</td></tr>')
|
||
|
||
parts.append('</tbody></table></div>')
|
||
else:
|
||
parts.append('<div class="px-6 py-8 text-center text-stone-500">No tickets yet</div>')
|
||
|
||
parts.append('</div></section>')
|
||
return "".join(parts)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# All events / page summary entry cards
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _entry_card_html(entry, page_info: dict, pending_tickets: dict,
|
||
ticket_url: str, events_url_fn, *, is_page_scoped: bool = False,
|
||
post: dict | None = None) -> str:
|
||
"""Render a list card for one event entry."""
|
||
pi = page_info.get(getattr(entry, "calendar_container_id", 0), {})
|
||
if is_page_scoped and post:
|
||
page_slug = pi.get("slug", post.get("slug", ""))
|
||
else:
|
||
page_slug = pi.get("slug", "")
|
||
page_title = pi.get("title")
|
||
|
||
day_href = ""
|
||
if page_slug and entry.start_at:
|
||
day_href = events_url_fn(f"/{page_slug}/calendars/{entry.calendar_slug}/day/{entry.start_at.strftime('%Y/%-m/%-d')}/")
|
||
entry_href = f"{day_href}entries/{entry.id}/" if day_href else ""
|
||
|
||
parts = ['<article class="rounded-xl bg-white shadow-sm border border-stone-200 p-4">']
|
||
parts.append('<div class="flex flex-col sm:flex-row sm:items-start justify-between gap-3">')
|
||
parts.append('<div class="flex-1 min-w-0">')
|
||
|
||
if entry_href:
|
||
parts.append(f'<a href="{entry_href}" class="hover:text-emerald-700">')
|
||
parts.append(f'<h2 class="text-lg font-semibold text-stone-900">{escape(entry.name)}</h2>')
|
||
if entry_href:
|
||
parts.append('</a>')
|
||
|
||
# Badges
|
||
parts.append('<div class="flex flex-wrap items-center gap-1.5 mt-1">')
|
||
if page_title and (not is_page_scoped or page_title != (post or {}).get("title")):
|
||
page_href = events_url_fn(f"/{page_slug}/")
|
||
parts.append(
|
||
f'<a href="{page_href}" class="inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 hover:bg-amber-200">'
|
||
f'{escape(page_title)}</a>'
|
||
)
|
||
cal_name = getattr(entry, "calendar_name", "")
|
||
if cal_name:
|
||
parts.append(f'<span class="inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-sky-100 text-sky-700">{escape(cal_name)}</span>')
|
||
parts.append('</div>')
|
||
|
||
# Time
|
||
parts.append('<div class="mt-1 text-sm text-stone-500">')
|
||
if day_href and not is_page_scoped:
|
||
parts.append(f'<a href="{day_href}" class="hover:text-stone-700">{entry.start_at.strftime("%a %-d %b")}</a> · ')
|
||
elif not is_page_scoped:
|
||
parts.append(f'{entry.start_at.strftime("%a %-d %b")} · ')
|
||
parts.append(entry.start_at.strftime("%H:%M"))
|
||
if entry.end_at:
|
||
parts.append(f' – {entry.end_at.strftime("%H:%M")}')
|
||
parts.append('</div>')
|
||
|
||
cost = getattr(entry, "cost", None)
|
||
if cost:
|
||
parts.append(f'<div class="mt-1 text-sm font-medium text-green-600">£{cost:.2f}</div>')
|
||
parts.append('</div>')
|
||
|
||
# Ticket widget
|
||
tp = getattr(entry, "ticket_price", None)
|
||
if tp is not None:
|
||
qty = pending_tickets.get(entry.id, 0)
|
||
parts.append('<div class="shrink-0">')
|
||
parts.append(_ticket_widget_html(entry, qty, ticket_url, ctx={}))
|
||
parts.append('</div>')
|
||
parts.append('</div></article>')
|
||
return "".join(parts)
|
||
|
||
|
||
def _entry_card_tile_html(entry, page_info: dict, pending_tickets: dict,
|
||
ticket_url: str, events_url_fn, *, is_page_scoped: bool = False,
|
||
post: dict | None = None) -> str:
|
||
"""Render a tile card for one event entry."""
|
||
pi = page_info.get(getattr(entry, "calendar_container_id", 0), {})
|
||
if is_page_scoped and post:
|
||
page_slug = pi.get("slug", post.get("slug", ""))
|
||
else:
|
||
page_slug = pi.get("slug", "")
|
||
page_title = pi.get("title")
|
||
|
||
day_href = ""
|
||
if page_slug and entry.start_at:
|
||
day_href = events_url_fn(f"/{page_slug}/calendars/{entry.calendar_slug}/day/{entry.start_at.strftime('%Y/%-m/%-d')}/")
|
||
entry_href = f"{day_href}entries/{entry.id}/" if day_href else ""
|
||
|
||
parts = ['<article class="rounded-xl bg-white shadow-sm border border-stone-200 overflow-hidden"><div class="p-3">']
|
||
if entry_href:
|
||
parts.append(f'<a href="{entry_href}" class="hover:text-emerald-700">')
|
||
parts.append(f'<h2 class="text-base font-semibold text-stone-900 line-clamp-2">{escape(entry.name)}</h2>')
|
||
if entry_href:
|
||
parts.append('</a>')
|
||
|
||
parts.append('<div class="flex flex-wrap items-center gap-1 mt-1">')
|
||
if page_title and (not is_page_scoped or page_title != (post or {}).get("title")):
|
||
page_href = events_url_fn(f"/{page_slug}/")
|
||
parts.append(
|
||
f'<a href="{page_href}" class="inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 hover:bg-amber-200">'
|
||
f'{escape(page_title)}</a>'
|
||
)
|
||
cal_name = getattr(entry, "calendar_name", "")
|
||
if cal_name:
|
||
parts.append(f'<span class="inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-sky-100 text-sky-700">{escape(cal_name)}</span>')
|
||
parts.append('</div>')
|
||
|
||
parts.append('<div class="mt-1 text-xs text-stone-500">')
|
||
if day_href:
|
||
parts.append(f'<a href="{day_href}" class="hover:text-stone-700">{entry.start_at.strftime("%a %-d %b")}</a>')
|
||
else:
|
||
parts.append(entry.start_at.strftime("%a %-d %b"))
|
||
parts.append(f' · {entry.start_at.strftime("%H:%M")}')
|
||
if entry.end_at:
|
||
parts.append(f' – {entry.end_at.strftime("%H:%M")}')
|
||
parts.append('</div>')
|
||
|
||
cost = getattr(entry, "cost", None)
|
||
if cost:
|
||
parts.append(f'<div class="mt-1 text-sm font-medium text-green-600">£{cost:.2f}</div>')
|
||
parts.append('</div>')
|
||
|
||
tp = getattr(entry, "ticket_price", None)
|
||
if tp is not None:
|
||
qty = pending_tickets.get(entry.id, 0)
|
||
parts.append('<div class="border-t border-stone-100 px-3 py-2">')
|
||
parts.append(_ticket_widget_html(entry, qty, ticket_url, ctx={}))
|
||
parts.append('</div>')
|
||
parts.append('</article>')
|
||
return "".join(parts)
|
||
|
||
|
||
def _ticket_widget_html(entry, qty: int, ticket_url: str, *, ctx: dict) -> str:
|
||
"""Render the inline +/- ticket widget."""
|
||
csrf_token_val = ""
|
||
if ctx:
|
||
ct = ctx.get("csrf_token")
|
||
csrf_token_val = ct() if callable(ct) else (ct or "")
|
||
else:
|
||
from quart import g as _g
|
||
ct = getattr(_g, "_csrf_token", None)
|
||
try:
|
||
from quart import current_app
|
||
with current_app.app_context():
|
||
pass
|
||
except Exception:
|
||
pass
|
||
# Use a deferred approach - get CSRF from template context
|
||
csrf_token_val = ""
|
||
|
||
# For the ticket widget, we need to get csrf token from the app
|
||
try:
|
||
from flask_wtf.csrf import generate_csrf
|
||
csrf_token_val = generate_csrf()
|
||
except Exception:
|
||
pass
|
||
|
||
if not csrf_token_val:
|
||
try:
|
||
from quart import current_app
|
||
csrf_token_val = current_app.config.get("WTF_CSRF_SECRET_KEY", "")
|
||
except Exception:
|
||
pass
|
||
|
||
eid = entry.id
|
||
tp = getattr(entry, "ticket_price", 0) or 0
|
||
cart_url_fn = None
|
||
|
||
parts = [f'<div id="page-ticket-{eid}" class="flex items-center gap-2">']
|
||
parts.append(f'<span class="text-green-600 font-medium text-sm">£{tp:.2f}</span>')
|
||
|
||
if qty == 0:
|
||
parts.append(
|
||
f'<form action="{ticket_url}" method="post" hx-post="{ticket_url}" '
|
||
f'hx-target="#page-ticket-{eid}" hx-swap="outerHTML">'
|
||
f'<input type="hidden" name="csrf_token" value="{csrf_token_val}">'
|
||
f'<input type="hidden" name="entry_id" value="{eid}">'
|
||
'<input type="hidden" name="count" value="1">'
|
||
'<button type="submit" class="relative inline-flex items-center justify-center text-stone-500 hover:bg-emerald-50 rounded p-1">'
|
||
'<i class="fa fa-cart-plus text-2xl" aria-hidden="true"></i></button></form>'
|
||
)
|
||
else:
|
||
# Minus button
|
||
parts.append(
|
||
f'<form action="{ticket_url}" method="post" hx-post="{ticket_url}" '
|
||
f'hx-target="#page-ticket-{eid}" hx-swap="outerHTML">'
|
||
f'<input type="hidden" name="csrf_token" value="{csrf_token_val}">'
|
||
f'<input type="hidden" name="entry_id" value="{eid}">'
|
||
f'<input type="hidden" name="count" value="{qty - 1}">'
|
||
'<button type="submit" class="inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl">-</button></form>'
|
||
)
|
||
# Cart icon with count
|
||
parts.append(
|
||
'<span class="relative inline-flex items-center justify-center text-emerald-700">'
|
||
'<span class="relative inline-flex items-center justify-center">'
|
||
'<i class="fa-solid fa-shopping-cart text-xl" aria-hidden="true"></i>'
|
||
'<span class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none">'
|
||
f'<span class="flex items-center justify-center bg-black text-white rounded-full w-4 h-4 text-xs font-bold">{qty}</span>'
|
||
'</span></span></span>'
|
||
)
|
||
# Plus button
|
||
parts.append(
|
||
f'<form action="{ticket_url}" method="post" hx-post="{ticket_url}" '
|
||
f'hx-target="#page-ticket-{eid}" hx-swap="outerHTML">'
|
||
f'<input type="hidden" name="csrf_token" value="{csrf_token_val}">'
|
||
f'<input type="hidden" name="entry_id" value="{eid}">'
|
||
f'<input type="hidden" name="count" value="{qty + 1}">'
|
||
'<button type="submit" class="inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl">+</button></form>'
|
||
)
|
||
parts.append('</div>')
|
||
return "".join(parts)
|
||
|
||
|
||
def _entry_cards_html(entries, page_info, pending_tickets, ticket_url,
|
||
events_url_fn, view, page, has_more, next_url,
|
||
*, is_page_scoped=False, post=None) -> str:
|
||
"""Render entry cards (list or tile) with sentinel."""
|
||
parts = []
|
||
last_date = None
|
||
for entry in entries:
|
||
if view == "tile":
|
||
parts.append(_entry_card_tile_html(
|
||
entry, page_info, pending_tickets, ticket_url, events_url_fn,
|
||
is_page_scoped=is_page_scoped, post=post,
|
||
))
|
||
else:
|
||
entry_date = entry.start_at.strftime("%A %-d %B %Y") if entry.start_at else ""
|
||
if entry_date != last_date:
|
||
parts.append(
|
||
f'<div class="pt-2 pb-1"><h3 class="text-sm font-semibold text-stone-500 uppercase tracking-wide">'
|
||
f'{entry_date}</h3></div>'
|
||
)
|
||
last_date = entry_date
|
||
parts.append(_entry_card_html(
|
||
entry, page_info, pending_tickets, ticket_url, events_url_fn,
|
||
is_page_scoped=is_page_scoped, post=post,
|
||
))
|
||
|
||
if has_more:
|
||
parts.append(
|
||
f'<div id="sentinel-{page}" class="h-4 opacity-0 pointer-events-none" '
|
||
f'hx-get="{next_url}" hx-trigger="intersect once delay:250ms" hx-swap="outerHTML" '
|
||
f'role="status" aria-hidden="true">'
|
||
f'<div class="text-center text-xs text-stone-400">loading...</div></div>'
|
||
)
|
||
return "".join(parts)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# All events / page summary main panels
|
||
# ---------------------------------------------------------------------------
|
||
|
||
_LIST_SVG = '<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16" /></svg>'
|
||
_TILE_SVG = '<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" /></svg>'
|
||
|
||
|
||
def _view_toggle_html(ctx: dict, view: str) -> str:
|
||
"""Render the list/tile view toggle bar."""
|
||
from shared.utils import route_prefix
|
||
prefix = route_prefix()
|
||
clh = ctx.get("current_local_href", "/")
|
||
qs_fn = ctx.get("qs")
|
||
hx_select = ctx.get("hx_select_search", "#main-panel")
|
||
|
||
# Build hrefs - list removes view param, tile sets view=tile
|
||
list_href = prefix + str(clh)
|
||
tile_href = prefix + str(clh)
|
||
# Use simple query parameter manipulation
|
||
if "?" in list_href:
|
||
list_href = list_href.split("?")[0]
|
||
if "?" in tile_href:
|
||
tile_href = tile_href.split("?")[0] + "?view=tile"
|
||
else:
|
||
tile_href = tile_href + "?view=tile"
|
||
|
||
list_active = 'bg-stone-200 text-stone-800' if view != 'tile' else 'text-stone-400 hover:text-stone-600'
|
||
tile_active = 'bg-stone-200 text-stone-800' if view == 'tile' else 'text-stone-400 hover:text-stone-600'
|
||
|
||
return (
|
||
'<div class="hidden md:flex justify-end px-3 pt-3 gap-1">'
|
||
f'<a href="{list_href}" hx-get="{list_href}" hx-target="#main-panel" hx-select="{hx_select}" '
|
||
f'hx-swap="outerHTML" hx-push-url="true" class="p-1.5 rounded {list_active}" title="List view" '
|
||
f"""_="on click js localStorage.removeItem('events_view') end">{_LIST_SVG}</a>"""
|
||
f'<a href="{tile_href}" hx-get="{tile_href}" hx-target="#main-panel" hx-select="{hx_select}" '
|
||
f'hx-swap="outerHTML" hx-push-url="true" class="p-1.5 rounded {tile_active}" title="Tile view" '
|
||
f"""_="on click js localStorage.setItem('events_view','tile') end">{_TILE_SVG}</a></div>"""
|
||
)
|
||
|
||
|
||
def _events_main_panel_html(ctx: dict, entries, has_more, pending_tickets, page_info,
|
||
page, view, ticket_url, next_url, events_url_fn,
|
||
*, is_page_scoped=False, post=None) -> str:
|
||
"""Render the events main panel with view toggle + cards."""
|
||
parts = [_view_toggle_html(ctx, view)]
|
||
|
||
if entries:
|
||
cards = _entry_cards_html(
|
||
entries, page_info, pending_tickets, ticket_url, events_url_fn,
|
||
view, page, has_more, next_url,
|
||
is_page_scoped=is_page_scoped, post=post,
|
||
)
|
||
if view == "tile":
|
||
parts.append(f'<div class="max-w-full px-3 py-3 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">{cards}</div>')
|
||
else:
|
||
parts.append(f'<div class="max-w-full px-3 py-3 space-y-3">{cards}</div>')
|
||
else:
|
||
parts.append(
|
||
'<div class="px-3 py-12 text-center text-stone-400">'
|
||
'<i class="fa fa-calendar-xmark text-4xl mb-3" aria-hidden="true"></i>'
|
||
'<p class="text-lg">No upcoming events</p></div>'
|
||
)
|
||
parts.append('<div class="pb-8"></div>')
|
||
return "".join(parts)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Utility
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _list_container(ctx: dict) -> str:
|
||
styles = ctx.get("styles") or {}
|
||
return getattr(styles, "list_container", "") if hasattr(styles, "list_container") else styles.get("list_container", "")
|
||
|
||
|
||
# ===========================================================================
|
||
# PUBLIC API
|
||
# ===========================================================================
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# All events
|
||
# ---------------------------------------------------------------------------
|
||
|
||
async def render_all_events_page(ctx: dict, entries, has_more, pending_tickets,
|
||
page_info, page, view) -> str:
|
||
"""Full page: all events listing."""
|
||
from quart import url_for
|
||
from shared.utils import route_prefix
|
||
from shared.infrastructure.urls import events_url
|
||
|
||
prefix = route_prefix()
|
||
view_param = f"&view={view}" if view != "list" else ""
|
||
ticket_url = url_for("all_events.adjust_ticket")
|
||
next_url = prefix + url_for("all_events.entries_fragment", page=page + 1) + (f"?view={view}" if view != "list" else "")
|
||
|
||
content = _events_main_panel_html(
|
||
ctx, entries, has_more, pending_tickets, page_info,
|
||
page, view, ticket_url, next_url, events_url,
|
||
)
|
||
hdr = root_header_html(ctx)
|
||
return full_page(ctx, header_rows_html=hdr, content_html=content)
|
||
|
||
|
||
async def render_all_events_oob(ctx: dict, entries, has_more, pending_tickets,
|
||
page_info, page, view) -> str:
|
||
"""OOB response: all events listing (htmx nav)."""
|
||
from quart import url_for
|
||
from shared.utils import route_prefix
|
||
from shared.infrastructure.urls import events_url
|
||
|
||
prefix = route_prefix()
|
||
ticket_url = url_for("all_events.adjust_ticket")
|
||
next_url = prefix + url_for("all_events.entries_fragment", page=page + 1) + (f"?view={view}" if view != "list" else "")
|
||
|
||
content = _events_main_panel_html(
|
||
ctx, entries, has_more, pending_tickets, page_info,
|
||
page, view, ticket_url, next_url, events_url,
|
||
)
|
||
return oob_page(ctx, content_html=content)
|
||
|
||
|
||
async def render_all_events_cards(entries, has_more, pending_tickets,
|
||
page_info, page, view) -> str:
|
||
"""Pagination fragment: all events cards only."""
|
||
from quart import url_for
|
||
from shared.utils import route_prefix
|
||
from shared.infrastructure.urls import events_url
|
||
|
||
prefix = route_prefix()
|
||
ticket_url = url_for("all_events.adjust_ticket")
|
||
next_url = prefix + url_for("all_events.entries_fragment", page=page + 1) + (f"?view={view}" if view != "list" else "")
|
||
|
||
return _entry_cards_html(
|
||
entries, page_info, pending_tickets, ticket_url, events_url,
|
||
view, page, has_more, next_url,
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Page summary
|
||
# ---------------------------------------------------------------------------
|
||
|
||
async def render_page_summary_page(ctx: dict, entries, has_more, pending_tickets,
|
||
page_info, page, view) -> str:
|
||
"""Full page: page-scoped events listing."""
|
||
from quart import url_for
|
||
from shared.utils import route_prefix
|
||
from shared.infrastructure.urls import events_url
|
||
|
||
prefix = route_prefix()
|
||
post = ctx.get("post") or {}
|
||
ticket_url = url_for("page_summary.adjust_ticket")
|
||
next_url = prefix + url_for("page_summary.entries_fragment", page=page + 1) + (f"?view={view}" if view != "list" else "")
|
||
|
||
content = _events_main_panel_html(
|
||
ctx, entries, has_more, pending_tickets, page_info,
|
||
page, view, ticket_url, next_url, events_url,
|
||
is_page_scoped=True, post=post,
|
||
)
|
||
|
||
hdr = root_header_html(ctx)
|
||
hdr += sexp(
|
||
'(div :id "root-header-child" :class "w-full" (raw! ph))',
|
||
ph=_post_header_html(ctx),
|
||
)
|
||
return full_page(ctx, header_rows_html=hdr, content_html=content)
|
||
|
||
|
||
async def render_page_summary_oob(ctx: dict, entries, has_more, pending_tickets,
|
||
page_info, page, view) -> str:
|
||
"""OOB response: page-scoped events (htmx nav)."""
|
||
from quart import url_for
|
||
from shared.utils import route_prefix
|
||
from shared.infrastructure.urls import events_url
|
||
|
||
prefix = route_prefix()
|
||
post = ctx.get("post") or {}
|
||
ticket_url = url_for("page_summary.adjust_ticket")
|
||
next_url = prefix + url_for("page_summary.entries_fragment", page=page + 1) + (f"?view={view}" if view != "list" else "")
|
||
|
||
content = _events_main_panel_html(
|
||
ctx, entries, has_more, pending_tickets, page_info,
|
||
page, view, ticket_url, next_url, events_url,
|
||
is_page_scoped=True, post=post,
|
||
)
|
||
|
||
oobs = _post_header_html(ctx, oob=True)
|
||
return oob_page(ctx, oobs_html=oobs, content_html=content)
|
||
|
||
|
||
async def render_page_summary_cards(entries, has_more, pending_tickets,
|
||
page_info, page, view, post) -> str:
|
||
"""Pagination fragment: page-scoped events cards only."""
|
||
from quart import url_for
|
||
from shared.utils import route_prefix
|
||
from shared.infrastructure.urls import events_url
|
||
|
||
prefix = route_prefix()
|
||
ticket_url = url_for("page_summary.adjust_ticket")
|
||
next_url = prefix + url_for("page_summary.entries_fragment", page=page + 1) + (f"?view={view}" if view != "list" else "")
|
||
|
||
return _entry_cards_html(
|
||
entries, page_info, pending_tickets, ticket_url, events_url,
|
||
view, page, has_more, next_url,
|
||
is_page_scoped=True, post=post,
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Calendars home
|
||
# ---------------------------------------------------------------------------
|
||
|
||
async def render_calendars_page(ctx: dict) -> str:
|
||
"""Full page: calendars listing."""
|
||
content = _calendars_main_panel_html(ctx)
|
||
hdr = root_header_html(ctx)
|
||
child = _post_header_html(ctx) + _calendars_header_html(ctx)
|
||
hdr += sexp('(div :id "root-header-child" :class "w-full" (raw! h))', h=child)
|
||
return full_page(ctx, header_rows_html=hdr, content_html=content)
|
||
|
||
|
||
async def render_calendars_oob(ctx: dict) -> str:
|
||
"""OOB response: calendars listing."""
|
||
content = _calendars_main_panel_html(ctx)
|
||
oobs = _post_header_html(ctx, oob=True)
|
||
oobs += _oob_header_html("post-header-child", "calendars-header-child",
|
||
_calendars_header_html(ctx))
|
||
return oob_page(ctx, oobs_html=oobs, content_html=content)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Calendar month view
|
||
# ---------------------------------------------------------------------------
|
||
|
||
async def render_calendar_page(ctx: dict) -> str:
|
||
"""Full page: calendar month view."""
|
||
content = _calendar_main_panel_html(ctx)
|
||
hdr = root_header_html(ctx)
|
||
child = _post_header_html(ctx) + _calendar_header_html(ctx)
|
||
hdr += sexp('(div :id "root-header-child" :class "w-full" (raw! h))', h=child)
|
||
return full_page(ctx, header_rows_html=hdr, content_html=content)
|
||
|
||
|
||
async def render_calendar_oob(ctx: dict) -> str:
|
||
"""OOB response: calendar month view."""
|
||
content = _calendar_main_panel_html(ctx)
|
||
oobs = _post_header_html(ctx, oob=True)
|
||
oobs += _oob_header_html("post-header-child", "calendar-header-child",
|
||
_calendar_header_html(ctx))
|
||
return oob_page(ctx, oobs_html=oobs, content_html=content)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Day detail
|
||
# ---------------------------------------------------------------------------
|
||
|
||
async def render_day_page(ctx: dict) -> str:
|
||
"""Full page: day detail."""
|
||
content = _day_main_panel_html(ctx)
|
||
hdr = root_header_html(ctx)
|
||
child = (_post_header_html(ctx)
|
||
+ _calendar_header_html(ctx) + _day_header_html(ctx))
|
||
hdr += sexp('(div :id "root-header-child" :class "w-full" (raw! h))', h=child)
|
||
return full_page(ctx, header_rows_html=hdr, content_html=content)
|
||
|
||
|
||
async def render_day_oob(ctx: dict) -> str:
|
||
"""OOB response: day detail."""
|
||
content = _day_main_panel_html(ctx)
|
||
oobs = _calendar_header_html(ctx, oob=True)
|
||
oobs += _oob_header_html("calendar-header-child", "day-header-child",
|
||
_day_header_html(ctx))
|
||
return oob_page(ctx, oobs_html=oobs, content_html=content)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Day admin
|
||
# ---------------------------------------------------------------------------
|
||
|
||
async def render_day_admin_page(ctx: dict) -> str:
|
||
"""Full page: day admin."""
|
||
content = _day_admin_main_panel_html(ctx)
|
||
hdr = root_header_html(ctx)
|
||
child = (_post_header_html(ctx)
|
||
+ _calendar_header_html(ctx) + _day_header_html(ctx)
|
||
+ _day_admin_header_html(ctx))
|
||
hdr += sexp('(div :id "root-header-child" :class "w-full" (raw! h))', h=child)
|
||
return full_page(ctx, header_rows_html=hdr, content_html=content)
|
||
|
||
|
||
async def render_day_admin_oob(ctx: dict) -> str:
|
||
"""OOB response: day admin."""
|
||
content = _day_admin_main_panel_html(ctx)
|
||
oobs = _calendar_header_html(ctx, oob=True)
|
||
oobs += _oob_header_html("day-header-child", "day-admin-header-child",
|
||
_day_admin_header_html(ctx))
|
||
return oob_page(ctx, oobs_html=oobs, content_html=content)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Calendar admin
|
||
# ---------------------------------------------------------------------------
|
||
|
||
async def render_calendar_admin_page(ctx: dict) -> str:
|
||
"""Full page: calendar admin."""
|
||
content = _calendar_admin_main_panel_html(ctx)
|
||
hdr = root_header_html(ctx)
|
||
child = (_post_header_html(ctx)
|
||
+ _calendar_header_html(ctx) + _calendar_admin_header_html(ctx))
|
||
hdr += sexp('(div :id "root-header-child" :class "w-full" (raw! h))', h=child)
|
||
return full_page(ctx, header_rows_html=hdr, content_html=content)
|
||
|
||
|
||
async def render_calendar_admin_oob(ctx: dict) -> str:
|
||
"""OOB response: calendar admin."""
|
||
content = _calendar_admin_main_panel_html(ctx)
|
||
oobs = _calendar_header_html(ctx, oob=True)
|
||
oobs += _oob_header_html("calendar-header-child", "calendar-admin-header-child",
|
||
_calendar_admin_header_html(ctx))
|
||
return oob_page(ctx, oobs_html=oobs, content_html=content)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Slots
|
||
# ---------------------------------------------------------------------------
|
||
|
||
async def render_slots_page(ctx: dict) -> str:
|
||
"""Full page: slots listing."""
|
||
from quart import g
|
||
slots = ctx.get("slots") or []
|
||
calendar = ctx.get("calendar")
|
||
content = render_slots_table(slots, calendar)
|
||
hdr = root_header_html(ctx)
|
||
child = (_post_header_html(ctx)
|
||
+ _calendar_header_html(ctx) + _calendar_admin_header_html(ctx))
|
||
hdr += sexp('(div :id "root-header-child" :class "w-full" (raw! h))', h=child)
|
||
return full_page(ctx, header_rows_html=hdr, content_html=content)
|
||
|
||
|
||
async def render_slots_oob(ctx: dict) -> str:
|
||
"""OOB response: slots listing."""
|
||
slots = ctx.get("slots") or []
|
||
calendar = ctx.get("calendar")
|
||
content = render_slots_table(slots, calendar)
|
||
oobs = _calendar_admin_header_html(ctx, oob=True)
|
||
return oob_page(ctx, oobs_html=oobs, content_html=content)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Tickets
|
||
# ---------------------------------------------------------------------------
|
||
|
||
async def render_tickets_page(ctx: dict, tickets: list) -> str:
|
||
"""Full page: my tickets."""
|
||
content = _tickets_main_panel_html(ctx, tickets)
|
||
hdr = root_header_html(ctx)
|
||
return full_page(ctx, header_rows_html=hdr, content_html=content)
|
||
|
||
|
||
async def render_tickets_oob(ctx: dict, tickets: list) -> str:
|
||
"""OOB response: my tickets."""
|
||
content = _tickets_main_panel_html(ctx, tickets)
|
||
return oob_page(ctx, content_html=content)
|
||
|
||
|
||
async def render_ticket_detail_page(ctx: dict, ticket) -> str:
|
||
"""Full page: ticket detail with QR."""
|
||
content = _ticket_detail_panel_html(ctx, ticket)
|
||
hdr = root_header_html(ctx)
|
||
return full_page(ctx, header_rows_html=hdr, content_html=content)
|
||
|
||
|
||
async def render_ticket_detail_oob(ctx: dict, ticket) -> str:
|
||
"""OOB response: ticket detail."""
|
||
content = _ticket_detail_panel_html(ctx, ticket)
|
||
return oob_page(ctx, content_html=content)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Ticket admin
|
||
# ---------------------------------------------------------------------------
|
||
|
||
async def render_ticket_admin_page(ctx: dict, tickets: list, stats: dict) -> str:
|
||
"""Full page: ticket admin dashboard."""
|
||
content = _ticket_admin_main_panel_html(ctx, tickets, stats)
|
||
hdr = root_header_html(ctx)
|
||
return full_page(ctx, header_rows_html=hdr, content_html=content)
|
||
|
||
|
||
async def render_ticket_admin_oob(ctx: dict, tickets: list, stats: dict) -> str:
|
||
"""OOB response: ticket admin dashboard."""
|
||
content = _ticket_admin_main_panel_html(ctx, tickets, stats)
|
||
return oob_page(ctx, content_html=content)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Markets
|
||
# ---------------------------------------------------------------------------
|
||
|
||
async def render_markets_page(ctx: dict) -> str:
|
||
"""Full page: markets listing."""
|
||
content = _markets_main_panel_html(ctx)
|
||
hdr = root_header_html(ctx)
|
||
child = _post_header_html(ctx) + _markets_header_html(ctx)
|
||
hdr += sexp('(div :id "root-header-child" :class "w-full" (raw! h))', h=child)
|
||
return full_page(ctx, header_rows_html=hdr, content_html=content)
|
||
|
||
|
||
async def render_markets_oob(ctx: dict) -> str:
|
||
"""OOB response: markets listing."""
|
||
content = _markets_main_panel_html(ctx)
|
||
oobs = _post_header_html(ctx, oob=True)
|
||
oobs += _oob_header_html("post-header-child", "markets-header-child",
|
||
_markets_header_html(ctx))
|
||
return oob_page(ctx, oobs_html=oobs, content_html=content)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Payments
|
||
# ---------------------------------------------------------------------------
|
||
|
||
async def render_payments_page(ctx: dict) -> str:
|
||
"""Full page: payments admin."""
|
||
content = _payments_main_panel_html(ctx)
|
||
hdr = root_header_html(ctx)
|
||
child = _post_header_html(ctx) + _payments_header_html(ctx)
|
||
hdr += sexp('(div :id "root-header-child" :class "w-full" (raw! h))', h=child)
|
||
return full_page(ctx, header_rows_html=hdr, content_html=content)
|
||
|
||
|
||
async def render_payments_oob(ctx: dict) -> str:
|
||
"""OOB response: payments admin."""
|
||
content = _payments_main_panel_html(ctx)
|
||
oobs = _post_header_html(ctx, oob=True)
|
||
oobs += _oob_header_html("post-header-child", "payments-header-child",
|
||
_payments_header_html(ctx))
|
||
return oob_page(ctx, oobs_html=oobs, content_html=content)
|
||
|
||
|
||
# ===========================================================================
|
||
# POST / PUT / DELETE response components
|
||
# ===========================================================================
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Ticket widget (public wrapper for _ticket_widget_html)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def render_ticket_widget(entry, qty: int, ticket_url: str) -> str:
|
||
"""Render the +/- ticket widget for page_summary / all_events adjust_ticket."""
|
||
return _ticket_widget_html(entry, qty, ticket_url, ctx={})
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Ticket admin: checkin result
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def render_checkin_result(success: bool, error: str | None, ticket) -> str:
|
||
"""Render checkin result: table row on success, error div on failure."""
|
||
if not success:
|
||
err_msg = escape(error or "Check-in failed")
|
||
return (
|
||
'<div class="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-800">'
|
||
f'<i class="fa fa-exclamation-circle mr-2" aria-hidden="true"></i>{err_msg}'
|
||
'</div>'
|
||
)
|
||
if not ticket:
|
||
return ""
|
||
code = ticket.code
|
||
entry = getattr(ticket, "entry", None)
|
||
tt = getattr(ticket, "ticket_type", None)
|
||
checked_in_at = getattr(ticket, "checked_in_at", None)
|
||
time_str = checked_in_at.strftime("%H:%M") if checked_in_at else "Just now"
|
||
|
||
entry_name = escape(entry.name) if entry else "—"
|
||
date_html = ""
|
||
if entry and entry.start_at:
|
||
date_html = f'<div class="text-xs text-stone-500">{entry.start_at.strftime("%d %b %Y, %H:%M")}</div>'
|
||
|
||
tt_name = escape(tt.name) if tt else "—"
|
||
|
||
return (
|
||
f'<tr class="bg-blue-50" id="ticket-row-{code}">'
|
||
f'<td class="px-4 py-3"><span class="font-mono text-xs">{code[:12]}...</span></td>'
|
||
f'<td class="px-4 py-3"><div class="font-medium">{entry_name}</div>{date_html}</td>'
|
||
f'<td class="px-4 py-3 text-sm">{tt_name}</td>'
|
||
f'<td class="px-4 py-3">{_ticket_state_badge_html("checked_in")}</td>'
|
||
f'<td class="px-4 py-3"><span class="text-xs text-blue-600">'
|
||
f'<i class="fa fa-check-circle" aria-hidden="true"></i> {time_str}</span></td>'
|
||
'</tr>'
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Ticket admin: lookup result
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def render_lookup_result(ticket, error: str | None) -> str:
|
||
"""Render ticket lookup result: error div or ticket info card."""
|
||
from quart import url_for
|
||
from shared.browser.app.csrf import generate_csrf_token
|
||
|
||
if error:
|
||
return (
|
||
'<div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">'
|
||
f'<i class="fa fa-exclamation-circle mr-2" aria-hidden="true"></i>{escape(error)}'
|
||
'</div>'
|
||
)
|
||
if not ticket:
|
||
return ""
|
||
|
||
entry = getattr(ticket, "entry", None)
|
||
tt = getattr(ticket, "ticket_type", None)
|
||
state = getattr(ticket, "state", "")
|
||
code = ticket.code
|
||
checked_in_at = getattr(ticket, "checked_in_at", None)
|
||
csrf = generate_csrf_token()
|
||
|
||
entry_name = escape(entry.name) if entry else "Unknown event"
|
||
parts = ['<div class="rounded-lg border border-stone-200 bg-stone-50 p-4">']
|
||
parts.append('<div class="flex items-start justify-between gap-4"><div class="flex-1">')
|
||
parts.append(f'<div class="font-semibold text-lg">{entry_name}</div>')
|
||
if tt:
|
||
parts.append(f'<div class="text-sm text-stone-600">{escape(tt.name)}</div>')
|
||
if entry and entry.start_at:
|
||
parts.append(f'<div class="text-sm text-stone-500 mt-1">{entry.start_at.strftime("%A, %B %d, %Y at %H:%M")}</div>')
|
||
cal = getattr(entry, "calendar", None) if entry else None
|
||
if cal:
|
||
parts.append(f'<div class="text-xs text-stone-400 mt-0.5">{escape(cal.name)}</div>')
|
||
|
||
parts.append('<div class="mt-2">')
|
||
parts.append(_ticket_state_badge_html(state))
|
||
parts.append(f'<span class="text-xs text-stone-400 ml-2 font-mono">{code}</span>')
|
||
parts.append('</div>')
|
||
|
||
if checked_in_at:
|
||
parts.append(f'<div class="text-xs text-blue-600 mt-1">Checked in: {checked_in_at.strftime("%B %d, %Y at %H:%M")}</div>')
|
||
|
||
parts.append('</div>')
|
||
|
||
# Action area
|
||
parts.append(f'<div id="checkin-action-{code}">')
|
||
if state in ("confirmed", "reserved"):
|
||
checkin_url = url_for("ticket_admin.do_checkin", code=code)
|
||
parts.append(
|
||
f'<form hx-post="{checkin_url}" hx-target="#checkin-action-{code}" hx-swap="innerHTML">'
|
||
f'<input type="hidden" name="csrf_token" value="{csrf}" />'
|
||
'<button type="submit" class="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition font-semibold text-lg">'
|
||
'<i class="fa fa-check mr-2" aria-hidden="true"></i>Check In</button></form>'
|
||
)
|
||
elif state == "checked_in":
|
||
parts.append(
|
||
'<div class="text-blue-600 text-center">'
|
||
'<i class="fa fa-check-circle text-3xl" aria-hidden="true"></i>'
|
||
'<div class="text-sm font-medium mt-1">Checked In</div></div>'
|
||
)
|
||
elif state == "cancelled":
|
||
parts.append(
|
||
'<div class="text-red-600 text-center">'
|
||
'<i class="fa fa-times-circle text-3xl" aria-hidden="true"></i>'
|
||
'<div class="text-sm font-medium mt-1">Cancelled</div></div>'
|
||
)
|
||
parts.append('</div></div></div>')
|
||
return "".join(parts)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Ticket admin: entry tickets table
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def render_entry_tickets_admin(entry, tickets: list) -> str:
|
||
"""Render admin ticket table for a specific entry."""
|
||
from quart import url_for
|
||
from shared.browser.app.csrf import generate_csrf_token
|
||
csrf = generate_csrf_token()
|
||
|
||
count = len(tickets)
|
||
suffix = "s" if count != 1 else ""
|
||
parts = ['<div class="space-y-4">']
|
||
parts.append(
|
||
'<div class="flex items-center justify-between">'
|
||
f'<h3 class="text-lg font-semibold">Tickets for: {escape(entry.name)}</h3>'
|
||
f'<span class="text-sm text-stone-500">{count} ticket{suffix}</span>'
|
||
'</div>'
|
||
)
|
||
|
||
if tickets:
|
||
parts.append('<div class="overflow-x-auto rounded-xl border border-stone-200">')
|
||
parts.append(
|
||
'<table class="w-full text-sm"><thead class="bg-stone-50"><tr>'
|
||
'<th class="px-4 py-2 text-left font-medium text-stone-600">Code</th>'
|
||
'<th class="px-4 py-2 text-left font-medium text-stone-600">Type</th>'
|
||
'<th class="px-4 py-2 text-left font-medium text-stone-600">State</th>'
|
||
'<th class="px-4 py-2 text-left font-medium text-stone-600">Actions</th>'
|
||
'</tr></thead><tbody class="divide-y divide-stone-100">'
|
||
)
|
||
for ticket in tickets:
|
||
tt = getattr(ticket, "ticket_type", None)
|
||
state = getattr(ticket, "state", "")
|
||
code = ticket.code
|
||
checked_in_at = getattr(ticket, "checked_in_at", None)
|
||
|
||
parts.append(f'<tr class="hover:bg-stone-50" id="entry-ticket-row-{code}">')
|
||
parts.append(f'<td class="px-4 py-2 font-mono text-xs">{code[:12]}...</td>')
|
||
parts.append(f'<td class="px-4 py-2">{escape(tt.name) if tt else "—"}</td>')
|
||
parts.append(f'<td class="px-4 py-2">{_ticket_state_badge_html(state)}</td>')
|
||
parts.append('<td class="px-4 py-2">')
|
||
if state in ("confirmed", "reserved"):
|
||
checkin_url = url_for("ticket_admin.do_checkin", code=code)
|
||
parts.append(
|
||
f'<form hx-post="{checkin_url}" hx-target="#entry-ticket-row-{code}" hx-swap="outerHTML">'
|
||
f'<input type="hidden" name="csrf_token" value="{csrf}" />'
|
||
'<button type="submit" class="px-3 py-1 bg-blue-600 text-white text-xs rounded hover:bg-blue-700">'
|
||
'Check in</button></form>'
|
||
)
|
||
elif state == "checked_in":
|
||
t_str = checked_in_at.strftime("%H:%M") if checked_in_at else ""
|
||
parts.append(f'<span class="text-xs text-blue-600"><i class="fa fa-check-circle" aria-hidden="true"></i> {t_str}</span>')
|
||
parts.append('</td></tr>')
|
||
|
||
parts.append('</tbody></table></div>')
|
||
else:
|
||
parts.append('<div class="text-center py-6 text-stone-500 text-sm">No tickets for this entry</div>')
|
||
|
||
parts.append('</div>')
|
||
return "".join(parts)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Day main panel — public API
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def render_day_main_panel(ctx: dict) -> str:
|
||
"""Public wrapper for day main panel rendering."""
|
||
return _day_main_panel_html(ctx)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Entry main panel
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _entry_main_panel_html(ctx: dict) -> str:
|
||
"""Render the entry detail panel (name, slot, time, state, cost, tickets,
|
||
buy form, date, posts, options + edit button)."""
|
||
from quart import url_for
|
||
from shared.browser.app.csrf import generate_csrf_token
|
||
|
||
entry = ctx.get("entry")
|
||
if not entry:
|
||
return ""
|
||
|
||
calendar = ctx.get("calendar")
|
||
cal_slug = getattr(calendar, "slug", "") if calendar else ""
|
||
day = ctx.get("day")
|
||
month = ctx.get("month")
|
||
year = ctx.get("year")
|
||
styles = ctx.get("styles") or {}
|
||
list_container = getattr(styles, "list_container", "") if hasattr(styles, "list_container") else styles.get("list_container", "")
|
||
pre_action = getattr(styles, "pre_action_button", "") if hasattr(styles, "pre_action_button") else styles.get("pre_action_button", "")
|
||
|
||
eid = entry.id
|
||
state = getattr(entry, "state", "pending") or "pending"
|
||
|
||
parts = [f'<section id="entry-{eid}" class="{list_container}">']
|
||
|
||
# Name
|
||
parts.append(
|
||
'<div class="flex flex-col mb-4">'
|
||
'<div class="text-xs font-semibold uppercase tracking-wide text-stone-500">Name</div>'
|
||
f'<div class="mt-1 text-lg font-medium">{escape(entry.name)}</div>'
|
||
'</div>'
|
||
)
|
||
|
||
# Slot
|
||
slot = getattr(entry, "slot", None)
|
||
parts.append(
|
||
'<div class="flex flex-col mb-4">'
|
||
'<div class="text-xs font-semibold uppercase tracking-wide text-stone-500">Slot</div>'
|
||
'<div class="mt-1">'
|
||
)
|
||
if slot:
|
||
flex_label = "(flexible)" if getattr(slot, "flexible", False) else "(fixed)"
|
||
parts.append(
|
||
f'<span class="px-2 py-1 rounded text-sm bg-blue-100 text-blue-700">{escape(slot.name)}</span>'
|
||
f'<span class="ml-2 text-xs text-stone-500">{flex_label}</span>'
|
||
)
|
||
else:
|
||
parts.append('<span class="text-sm text-stone-400">No slot assigned</span>')
|
||
parts.append('</div></div>')
|
||
|
||
# Time Period
|
||
start_str = entry.start_at.strftime("%H:%M") if entry.start_at else ""
|
||
end_str = f" – {entry.end_at.strftime('%H:%M')}" if entry.end_at else " – open-ended"
|
||
parts.append(
|
||
'<div class="flex flex-col mb-4">'
|
||
'<div class="text-xs font-semibold uppercase tracking-wide text-stone-500">Time Period</div>'
|
||
f'<div class="mt-1">{start_str}{end_str}</div>'
|
||
'</div>'
|
||
)
|
||
|
||
# State
|
||
state_badge = _entry_state_badge_html(state)
|
||
parts.append(
|
||
'<div class="flex flex-col mb-4">'
|
||
'<div class="text-xs font-semibold uppercase tracking-wide text-stone-500">State</div>'
|
||
f'<div class="mt-1"><div id="entry-state-{eid}">{state_badge}</div></div>'
|
||
'</div>'
|
||
)
|
||
|
||
# Cost
|
||
cost = getattr(entry, "cost", None)
|
||
cost_str = f"{cost:.2f}" if cost is not None else "0.00"
|
||
parts.append(
|
||
'<div class="flex flex-col mb-4">'
|
||
'<div class="text-xs font-semibold uppercase tracking-wide text-stone-500">Cost</div>'
|
||
f'<div class="mt-1"><span class="font-medium text-green-600">£{cost_str}</span></div>'
|
||
'</div>'
|
||
)
|
||
|
||
# Ticket Configuration (admin)
|
||
parts.append(
|
||
'<div class="flex flex-col mb-4">'
|
||
'<div class="text-xs font-semibold uppercase tracking-wide text-stone-500">Tickets</div>'
|
||
f'<div class="mt-1" id="entry-tickets-{eid}">'
|
||
)
|
||
parts.append(render_entry_tickets_config(entry, calendar, day, month, year))
|
||
parts.append('</div></div>')
|
||
|
||
# Buy Tickets (public-facing)
|
||
ticket_remaining = ctx.get("ticket_remaining")
|
||
ticket_sold_count = ctx.get("ticket_sold_count", 0)
|
||
user_ticket_count = ctx.get("user_ticket_count", 0)
|
||
user_ticket_counts_by_type = ctx.get("user_ticket_counts_by_type") or {}
|
||
parts.append(render_buy_form(
|
||
entry, ticket_remaining, ticket_sold_count,
|
||
user_ticket_count, user_ticket_counts_by_type,
|
||
))
|
||
|
||
# Date
|
||
date_str = entry.start_at.strftime("%A, %B %d, %Y") if entry.start_at else ""
|
||
parts.append(
|
||
'<div class="flex flex-col mb-4">'
|
||
'<div class="text-xs font-semibold uppercase tracking-wide text-stone-500">Date</div>'
|
||
f'<div class="mt-1">{date_str}</div>'
|
||
'</div>'
|
||
)
|
||
|
||
# Associated Posts
|
||
entry_posts = ctx.get("entry_posts") or []
|
||
parts.append(
|
||
'<div class="flex flex-col mb-4">'
|
||
'<div class="text-xs font-semibold uppercase tracking-wide text-stone-500">Associated Posts</div>'
|
||
f'<div class="mt-1" id="entry-posts-{eid}">'
|
||
)
|
||
parts.append(render_entry_posts_panel(entry_posts, entry, calendar, day, month, year))
|
||
parts.append('</div></div>')
|
||
|
||
# Options and Edit Button
|
||
parts.append('<div class="flex gap-2 mt-6">')
|
||
parts.append(_entry_options_html(entry, calendar, day, month, year))
|
||
|
||
edit_url = url_for(
|
||
"calendars.calendar.day.calendar_entries.calendar_entry.get_edit",
|
||
entry_id=eid, calendar_slug=cal_slug,
|
||
day=day, month=month, year=year,
|
||
)
|
||
parts.append(
|
||
f'<button type="button" class="{pre_action}" '
|
||
f'hx-get="{edit_url}" hx-target="#entry-{eid}" hx-swap="outerHTML">'
|
||
'Edit</button>'
|
||
)
|
||
parts.append('</div></section>')
|
||
return "".join(parts)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Entry header row
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _entry_header_html(ctx: dict, *, oob: bool = False) -> str:
|
||
"""Build entry detail header row."""
|
||
from quart import url_for
|
||
|
||
calendar = ctx.get("calendar")
|
||
if not calendar:
|
||
return ""
|
||
cal_slug = getattr(calendar, "slug", "")
|
||
entry = ctx.get("entry")
|
||
if not entry:
|
||
return ""
|
||
day = ctx.get("day")
|
||
month = ctx.get("month")
|
||
year = ctx.get("year")
|
||
|
||
link_href = url_for(
|
||
"calendars.calendar.day.calendar_entries.calendar_entry.get",
|
||
calendar_slug=cal_slug,
|
||
year=year, month=month, day=day,
|
||
entry_id=entry.id,
|
||
)
|
||
label_html = (
|
||
f'<div id="entry-title-{entry.id}" class="flex gap-1 items-center">'
|
||
+ _entry_title_html(entry)
|
||
+ _entry_times_html(entry)
|
||
+ '</div>'
|
||
)
|
||
|
||
nav_html = _entry_nav_html(ctx)
|
||
|
||
return sexp(
|
||
'(~menu-row :id "entry-row" :level 5'
|
||
' :link-href lh :link-label-html llh'
|
||
' :nav-html nh :child-id "entry-header-child" :oob oob)',
|
||
lh=link_href,
|
||
llh=label_html,
|
||
nh=nav_html,
|
||
oob=oob,
|
||
)
|
||
|
||
|
||
def _entry_times_html(entry) -> str:
|
||
"""Render entry times label."""
|
||
start = entry.start_at
|
||
end = entry.end_at
|
||
if not start:
|
||
return ""
|
||
start_str = start.strftime("%H:%M")
|
||
end_str = f" → {end.strftime('%H:%M')}" if end else ""
|
||
return f'<div class="text-sm text-gray-600">{start_str}{end_str}</div>'
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Entry nav (desktop + admin link)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _entry_nav_html(ctx: dict) -> str:
|
||
"""Entry desktop nav: associated posts scrolling menu + admin link."""
|
||
from quart import url_for
|
||
|
||
calendar = ctx.get("calendar")
|
||
if not calendar:
|
||
return ""
|
||
cal_slug = getattr(calendar, "slug", "")
|
||
entry = ctx.get("entry")
|
||
if not entry:
|
||
return ""
|
||
day = ctx.get("day")
|
||
month = ctx.get("month")
|
||
year = ctx.get("year")
|
||
entry_posts = ctx.get("entry_posts") or []
|
||
rights = ctx.get("rights") or {}
|
||
is_admin = getattr(rights, "admin", False) if hasattr(rights, "admin") else rights.get("admin", False)
|
||
|
||
blog_url_fn = ctx.get("blog_url")
|
||
|
||
parts = []
|
||
|
||
# Associated Posts scrolling menu
|
||
if entry_posts:
|
||
parts.append(
|
||
'<div class="flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"'
|
||
' id="entry-posts-nav-wrapper">'
|
||
'<div class="flex overflow-x-auto gap-1 scrollbar-thin">'
|
||
)
|
||
for ep in entry_posts:
|
||
slug = getattr(ep, "slug", "")
|
||
title = escape(getattr(ep, "title", ""))
|
||
feat = getattr(ep, "feature_image", None)
|
||
href = blog_url_fn(f"/{slug}/") if blog_url_fn else f"/{slug}/"
|
||
if feat:
|
||
img = f'<img src="{feat}" alt="{title}" class="w-8 h-8 rounded-full object-cover flex-shrink-0" />'
|
||
else:
|
||
img = '<div class="w-8 h-8 rounded-full bg-stone-200 flex-shrink-0"></div>'
|
||
parts.append(
|
||
f'<a href="{href}" '
|
||
'class="flex items-center gap-2 px-3 py-2 hover:bg-stone-100 rounded transition text-sm border sm:whitespace-nowrap sm:flex-shrink-0">'
|
||
f'{img}<div class="flex-1 min-w-0"><div class="font-medium truncate">{title}</div></div></a>'
|
||
)
|
||
parts.append('</div></div>')
|
||
|
||
# Admin link
|
||
if is_admin:
|
||
admin_url = url_for(
|
||
"calendars.calendar.day.calendar_entries.calendar_entry.admin.admin",
|
||
calendar_slug=cal_slug,
|
||
day=day, month=month, year=year,
|
||
entry_id=entry.id,
|
||
)
|
||
parts.append(
|
||
f'<a href="{admin_url}" '
|
||
'class="inline-flex items-center gap-1 px-2 py-1 text-xs text-stone-500 hover:text-stone-700 hover:bg-stone-100 rounded">'
|
||
'<i class="fa fa-cog" aria-hidden="true"></i> Admin</a>'
|
||
)
|
||
|
||
return "".join(parts)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Entry page / OOB rendering
|
||
# ---------------------------------------------------------------------------
|
||
|
||
async def render_entry_page(ctx: dict) -> str:
|
||
"""Full page: entry detail."""
|
||
content = _entry_main_panel_html(ctx)
|
||
hdr = root_header_html(ctx)
|
||
child = (_post_header_html(ctx)
|
||
+ _calendar_header_html(ctx) + _day_header_html(ctx)
|
||
+ _entry_header_html(ctx))
|
||
hdr += sexp('(div :id "root-header-child" :class "w-full" (raw! h))', h=child)
|
||
nav_html = _entry_nav_html(ctx)
|
||
return full_page(ctx, header_rows_html=hdr, content_html=content, menu_html=nav_html)
|
||
|
||
|
||
async def render_entry_oob(ctx: dict) -> str:
|
||
"""OOB response: entry detail."""
|
||
content = _entry_main_panel_html(ctx)
|
||
oobs = _day_header_html(ctx, oob=True)
|
||
oobs += _oob_header_html("day-header-child", "entry-header-child",
|
||
_entry_header_html(ctx))
|
||
nav_html = _entry_nav_html(ctx)
|
||
return oob_page(ctx, oobs_html=oobs, content_html=content, menu_html=nav_html)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Entry optioned (confirm/decline/provisional response)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def render_entry_optioned(entry, calendar, day, month, year) -> str:
|
||
"""Render entry options buttons + OOB title & state swaps."""
|
||
options = _entry_options_html(entry, calendar, day, month, year)
|
||
title = _entry_title_html(entry)
|
||
state = _entry_state_badge_html(getattr(entry, "state", "pending") or "pending")
|
||
|
||
return (
|
||
options
|
||
+ f'<div id="entry-title-{entry.id}" hx-swap-oob="innerHTML">{title}</div>'
|
||
+ f'<div id="entry-state-{entry.id}" hx-swap-oob="innerHTML">{state}</div>'
|
||
)
|
||
|
||
|
||
def _entry_title_html(entry) -> str:
|
||
"""Render entry title (icon + name + state badge)."""
|
||
state = getattr(entry, "state", "pending") or "pending"
|
||
return (
|
||
f'<i class="fa fa-clock"></i> {escape(entry.name)} '
|
||
+ _entry_state_badge_html(state)
|
||
)
|
||
|
||
|
||
def _entry_options_html(entry, calendar, day, month, year) -> str:
|
||
"""Render confirm/decline/provisional buttons based on entry state."""
|
||
from quart import url_for, g
|
||
from shared.browser.app.csrf import generate_csrf_token
|
||
csrf = generate_csrf_token()
|
||
|
||
styles = getattr(g, "styles", None) or {}
|
||
action_btn = getattr(styles, "action_button", "") if hasattr(styles, "action_button") else styles.get("action_button", "")
|
||
|
||
cal_slug = getattr(calendar, "slug", "")
|
||
eid = entry.id
|
||
state = getattr(entry, "state", "pending") or "pending"
|
||
target = f"#calendar_entry_options_{eid}"
|
||
|
||
parts = [f'<div id="calendar_entry_options_{eid}" class="flex flex-col md:flex-row gap-1">']
|
||
|
||
def _make_button(action_name, label, confirm_title, confirm_text, *, trigger_type="submit"):
|
||
url = url_for(
|
||
f"calendars.calendar.day.calendar_entries.calendar_entry.{action_name}",
|
||
calendar_slug=cal_slug, day=day, month=month, year=year, entry_id=eid,
|
||
)
|
||
btn_type = "button" if trigger_type == "button" else "submit"
|
||
trigger_attr = ' hx-trigger="confirmed"' if trigger_type == "button" else ""
|
||
return (
|
||
f'<form hx-post="{url}" hx-select="{target}" hx-target="{target}" hx-swap="outerHTML"{trigger_attr}>'
|
||
f'<input type="hidden" name="csrf_token" value="{csrf}">'
|
||
f'<button type="{btn_type}" class="{action_btn}" '
|
||
f'data-confirm="true" data-confirm-title="{confirm_title}" '
|
||
f'data-confirm-text="{confirm_text}" data-confirm-icon="question" '
|
||
f'data-confirm-confirm-text="Yes, {label} it" data-confirm-cancel-text="Cancel"'
|
||
+ (f' data-confirm-event="confirmed"' if trigger_type == "button" else "")
|
||
+ f'><i class="fa-solid fa-rotate mr-2" aria-hidden="true"></i>{label}</button></form>'
|
||
)
|
||
|
||
if state == "provisional":
|
||
parts.append(_make_button(
|
||
"confirm_entry", "confirm",
|
||
"Confirm entry?", "Are you sure you want to confirm this entry?",
|
||
))
|
||
parts.append(_make_button(
|
||
"decline_entry", "decline",
|
||
"Decline entry?", "Are you sure you want to decline this entry?",
|
||
))
|
||
elif state == "confirmed":
|
||
parts.append(_make_button(
|
||
"provisional_entry", "provisional",
|
||
"Provisional entry?", "Are you sure you want to provisional this entry?",
|
||
trigger_type="button",
|
||
))
|
||
|
||
parts.append("</div>")
|
||
return "".join(parts)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Entry tickets config (display + form)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def render_entry_tickets_config(entry, calendar, day, month, year) -> str:
|
||
"""Render ticket config display + edit form for admin entry view."""
|
||
from quart import url_for
|
||
from shared.browser.app.csrf import generate_csrf_token
|
||
csrf = generate_csrf_token()
|
||
|
||
cal_slug = getattr(calendar, "slug", "")
|
||
eid = entry.id
|
||
tp = getattr(entry, "ticket_price", None)
|
||
tc = getattr(entry, "ticket_count", None)
|
||
|
||
parts = []
|
||
|
||
if tp is not None:
|
||
parts.append('<div class="space-y-2">')
|
||
parts.append(f'<div class="flex items-center gap-2"><span class="text-sm font-medium text-stone-700">Price:</span>')
|
||
parts.append(f'<span class="font-medium text-green-600">£{tp:.2f}</span></div>')
|
||
parts.append(f'<div class="flex items-center gap-2"><span class="text-sm font-medium text-stone-700">Available:</span>')
|
||
tc_str = f"{tc} tickets" if tc is not None else "Unlimited"
|
||
parts.append(f'<span class="font-medium text-blue-600">{tc_str}</span></div>')
|
||
parts.append(
|
||
f'<button type="button" class="text-xs text-blue-600 hover:text-blue-800 underline" '
|
||
f"""onclick="document.getElementById('ticket-form-{eid}').classList.remove('hidden'); this.classList.add('hidden');">"""
|
||
'Edit ticket config</button></div>'
|
||
)
|
||
else:
|
||
parts.append('<div class="space-y-2">')
|
||
parts.append('<span class="text-sm text-stone-400">No tickets configured</span>')
|
||
parts.append(
|
||
f'<button type="button" class="block text-xs text-blue-600 hover:text-blue-800 underline" '
|
||
f"""onclick="document.getElementById('ticket-form-{eid}').classList.remove('hidden'); this.classList.add('hidden');">"""
|
||
'Configure tickets</button></div>'
|
||
)
|
||
|
||
# Form
|
||
update_url = url_for(
|
||
"calendars.calendar.day.calendar_entries.calendar_entry.update_tickets",
|
||
entry_id=eid, calendar_slug=cal_slug, day=day, month=month, year=year,
|
||
)
|
||
hidden_cls = "" if tp is None else "hidden"
|
||
tp_val = f"{tp:.2f}" if tp is not None else ""
|
||
tc_val = str(tc) if tc is not None else ""
|
||
|
||
parts.append(
|
||
f'<form id="ticket-form-{eid}" class="{hidden_cls} space-y-3 mt-2 p-3 border rounded bg-stone-50" '
|
||
f'hx-post="{update_url}" hx-target="#entry-tickets-{eid}" hx-swap="innerHTML">'
|
||
f'<input type="hidden" name="csrf_token" value="{csrf}" />'
|
||
f'<div><label for="ticket-price-{eid}" class="block text-sm font-medium text-stone-700 mb-1">Ticket Price (£)</label>'
|
||
f'<input type="number" id="ticket-price-{eid}" name="ticket_price" step="0.01" min="0" value="{tp_val}" '
|
||
'class="w-full px-3 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="e.g., 5.00" /></div>'
|
||
f'<div><label for="ticket-count-{eid}" class="block text-sm font-medium text-stone-700 mb-1">Total Tickets</label>'
|
||
f'<input type="number" id="ticket-count-{eid}" name="ticket_count" min="0" value="{tc_val}" '
|
||
'class="w-full px-3 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="Leave empty for unlimited" /></div>'
|
||
'<div class="flex gap-2">'
|
||
'<button type="submit" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 text-sm">Save</button>'
|
||
f'<button type="button" class="px-4 py-2 bg-stone-200 text-stone-700 rounded hover:bg-stone-300 text-sm" '
|
||
f"""onclick="document.getElementById('ticket-form-{eid}').classList.add('hidden'); """
|
||
f"""document.getElementById('entry-tickets-{eid}').querySelectorAll('button:not([type=submit])').forEach(btn => btn.classList.remove('hidden'));">"""
|
||
'Cancel</button></div></form>'
|
||
)
|
||
return "".join(parts)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Entry posts panel
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def render_entry_posts_panel(entry_posts, entry, calendar, day, month, year) -> str:
|
||
"""Render associated posts list with remove buttons and search input."""
|
||
from quart import url_for
|
||
from shared.browser.app.csrf import generate_csrf_token
|
||
csrf = generate_csrf_token()
|
||
|
||
cal_slug = getattr(calendar, "slug", "")
|
||
eid = entry.id
|
||
|
||
parts = ['<div class="space-y-2">']
|
||
if entry_posts:
|
||
parts.append('<div class="space-y-2">')
|
||
for ep in entry_posts:
|
||
ep_title = escape(getattr(ep, "title", ""))
|
||
ep_id = getattr(ep, "id", 0)
|
||
feat = getattr(ep, "feature_image", None)
|
||
if feat:
|
||
img = f'<img src="{feat}" alt="{ep_title}" class="w-8 h-8 rounded-full object-cover flex-shrink-0" />'
|
||
else:
|
||
img = '<div class="w-8 h-8 rounded-full bg-stone-200 flex-shrink-0"></div>'
|
||
|
||
del_url = url_for(
|
||
"calendars.calendar.day.calendar_entries.calendar_entry.remove_post",
|
||
calendar_slug=cal_slug, day=day, month=month, year=year,
|
||
entry_id=eid, post_id=ep_id,
|
||
)
|
||
parts.append(
|
||
f'<div class="flex items-center justify-between gap-3 p-2 bg-stone-50 rounded border">'
|
||
f'{img}<span class="text-sm flex-1">{ep_title}</span>'
|
||
f'<button type="button" class="text-xs text-red-600 hover:text-red-800 flex-shrink-0" '
|
||
f'data-confirm data-confirm-title="Remove post?" '
|
||
f'data-confirm-text="This will remove {ep_title} from this entry" '
|
||
f'data-confirm-icon="warning" data-confirm-confirm-text="Yes, remove it" '
|
||
f'data-confirm-cancel-text="Cancel" data-confirm-event="confirmed" '
|
||
f'hx-delete="{del_url}" hx-trigger="confirmed" '
|
||
f'hx-target="#entry-posts-{eid}" hx-swap="innerHTML" '
|
||
f"""hx-headers='{{"X-CSRFToken": "{csrf}"}}'>"""
|
||
'<i class="fa fa-times"></i> Remove</button></div>'
|
||
)
|
||
parts.append('</div>')
|
||
else:
|
||
parts.append('<p class="text-sm text-stone-400">No posts associated</p>')
|
||
|
||
# Search to add
|
||
search_url = url_for(
|
||
"calendars.calendar.day.calendar_entries.calendar_entry.search_posts",
|
||
calendar_slug=cal_slug, day=day, month=month, year=year, entry_id=eid,
|
||
)
|
||
parts.append(
|
||
'<div class="mt-3 pt-3 border-t">'
|
||
'<label class="block text-xs font-medium text-stone-700 mb-1">Add Post</label>'
|
||
f'<input type="text" placeholder="Search posts..." class="w-full px-3 py-2 border rounded text-sm" '
|
||
f'hx-get="{search_url}" hx-trigger="keyup changed delay:300ms, load" '
|
||
f'hx-target="#post-search-results-{eid}" hx-swap="innerHTML" name="q" />'
|
||
f'<div id="post-search-results-{eid}" class="mt-2 max-h-96 overflow-y-auto border rounded"></div></div>'
|
||
)
|
||
parts.append('</div>')
|
||
return "".join(parts)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Entry posts nav OOB
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def render_entry_posts_nav_oob(entry_posts) -> str:
|
||
"""Render OOB nav for entry posts (scrolling menu)."""
|
||
from quart import g
|
||
styles = getattr(g, "styles", None) or {}
|
||
nav_btn = getattr(styles, "nav_button", "") if hasattr(styles, "nav_button") else styles.get("nav_button", "")
|
||
blog_url_fn = getattr(g, "blog_url", None)
|
||
|
||
if not entry_posts:
|
||
return '<div id="entry-posts-nav-wrapper" hx-swap-oob="true"></div>'
|
||
|
||
parts = [
|
||
'<div class="flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"'
|
||
' id="entry-posts-nav-wrapper" hx-swap-oob="true">'
|
||
'<div class="flex overflow-x-auto gap-1 scrollbar-thin">'
|
||
]
|
||
for ep in entry_posts:
|
||
slug = getattr(ep, "slug", "")
|
||
title = escape(getattr(ep, "title", ""))
|
||
feat = getattr(ep, "feature_image", None)
|
||
if blog_url_fn:
|
||
href = blog_url_fn(f"/{slug}/")
|
||
else:
|
||
href = f"/{slug}/"
|
||
|
||
if feat:
|
||
img = f'<img src="{feat}" alt="{title}" class="w-8 h-8 rounded-full object-cover flex-shrink-0" />'
|
||
else:
|
||
img = '<div class="w-8 h-8 rounded-full bg-stone-200 flex-shrink-0"></div>'
|
||
|
||
parts.append(
|
||
f'<a href="{href}" class="{nav_btn}">'
|
||
f'{img}<div class="flex-1 min-w-0"><div class="font-medium truncate">{title}</div></div></a>'
|
||
)
|
||
parts.append('</div></div>')
|
||
return "".join(parts)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Day entries nav OOB
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def render_day_entries_nav_oob(confirmed_entries, calendar, day_date) -> str:
|
||
"""Render OOB nav for confirmed entries in a day."""
|
||
from quart import url_for, g
|
||
|
||
styles = getattr(g, "styles", None) or {}
|
||
nav_btn = getattr(styles, "nav_button", "") if hasattr(styles, "nav_button") else styles.get("nav_button", "")
|
||
cal_slug = getattr(calendar, "slug", "")
|
||
|
||
if not confirmed_entries:
|
||
return '<div id="day-entries-nav-wrapper" hx-swap-oob="true"></div>'
|
||
|
||
parts = [
|
||
'<div class="flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"'
|
||
' id="day-entries-nav-wrapper" hx-swap-oob="true">'
|
||
'<div class="flex overflow-x-auto gap-1 scrollbar-thin">'
|
||
]
|
||
for entry in confirmed_entries:
|
||
href = url_for(
|
||
"calendars.calendar.day.calendar_entries.calendar_entry.get",
|
||
calendar_slug=cal_slug,
|
||
year=day_date.year, month=day_date.month, day=day_date.day,
|
||
entry_id=entry.id,
|
||
)
|
||
name = escape(entry.name)
|
||
start = entry.start_at.strftime("%H:%M") if entry.start_at else ""
|
||
end = f" – {entry.end_at.strftime('%H:%M')}" if entry.end_at else ""
|
||
parts.append(
|
||
f'<a href="{href}" class="{nav_btn}">'
|
||
'<div class="flex-1 min-w-0">'
|
||
f'<div class="font-medium truncate">{name}</div>'
|
||
f'<div class="text-xs text-stone-600 truncate">{start}{end}</div>'
|
||
'</div></a>'
|
||
)
|
||
parts.append('</div></div>')
|
||
return "".join(parts)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Post nav entries OOB
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def render_post_nav_entries_oob(associated_entries, calendars, post) -> str:
|
||
"""Render OOB nav for associated entries and calendars of a post."""
|
||
from quart import g
|
||
from shared.infrastructure.urls import events_url
|
||
|
||
styles = getattr(g, "styles", None) or {}
|
||
nav_btn = getattr(styles, "nav_button_less_pad", "") if hasattr(styles, "nav_button_less_pad") else styles.get("nav_button_less_pad", "")
|
||
|
||
has_entries = associated_entries and getattr(associated_entries, "entries", None)
|
||
has_items = has_entries or calendars
|
||
|
||
if not has_items:
|
||
return '<div id="entries-calendars-nav-wrapper" hx-swap-oob="true"></div>'
|
||
|
||
slug = post.get("slug", "") if isinstance(post, dict) else getattr(post, "slug", "")
|
||
|
||
parts = [
|
||
'<div class="flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"'
|
||
' id="entries-calendars-nav-wrapper" hx-swap-oob="true">'
|
||
'<button class="entries-nav-arrow hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded" aria-label="Scroll left"'
|
||
' _="on click set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft - 200">'
|
||
'<i class="fa fa-chevron-left"></i></button>'
|
||
'<div id="associated-items-container"'
|
||
' class="overflow-y-auto sm:overflow-x-auto sm:overflow-y-visible scrollbar-hide max-h-[50vh] sm:max-h-none"'
|
||
' style="scroll-behavior: smooth;"'
|
||
' _="on load or scroll'
|
||
' if window.innerWidth >= 640 and my.scrollWidth > my.clientWidth'
|
||
' remove .hidden from .entries-nav-arrow add .flex to .entries-nav-arrow'
|
||
' else add .hidden to .entries-nav-arrow remove .flex from .entries-nav-arrow end">'
|
||
'<div class="flex flex-col sm:flex-row gap-1">'
|
||
]
|
||
|
||
if has_entries:
|
||
for entry in associated_entries.entries:
|
||
entry_path = (
|
||
f"/{slug}/calendars/{entry.calendar_slug}/"
|
||
f"{entry.start_at.year}/{entry.start_at.month}/{entry.start_at.day}/"
|
||
f"entries/{entry.id}/"
|
||
)
|
||
href = events_url(entry_path)
|
||
name = escape(entry.name)
|
||
time_str = entry.start_at.strftime("%b %d, %Y at %H:%M")
|
||
end_str = f" – {entry.end_at.strftime('%H:%M')}" if entry.end_at else ""
|
||
parts.append(
|
||
f'<a href="{href}" class="{nav_btn}">'
|
||
'<div class="w-8 h-8 rounded bg-stone-200 flex-shrink-0"></div>'
|
||
'<div class="flex-1 min-w-0">'
|
||
f'<div class="font-medium truncate">{name}</div>'
|
||
f'<div class="text-xs text-stone-600 truncate">{time_str}{end_str}</div>'
|
||
'</div></a>'
|
||
)
|
||
|
||
if calendars:
|
||
for cal in calendars:
|
||
cal_slug = getattr(cal, "slug", "")
|
||
cal_name = escape(getattr(cal, "name", ""))
|
||
local_href = events_url(f"/{slug}/calendars/{cal_slug}/")
|
||
parts.append(
|
||
f'<a href="{local_href}" class="{nav_btn}">'
|
||
f'<i class="fa fa-calendar" aria-hidden="true"></i>'
|
||
f'<div>{cal_name}</div></a>'
|
||
)
|
||
|
||
parts.append('</div></div>')
|
||
parts.append(
|
||
'<style>'
|
||
'.scrollbar-hide::-webkit-scrollbar { display: none; }'
|
||
'.scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }'
|
||
'</style>'
|
||
)
|
||
parts.append(
|
||
'<button class="entries-nav-arrow hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded" aria-label="Scroll right"'
|
||
' _="on click set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft + 200">'
|
||
'<i class="fa fa-chevron-right"></i></button>'
|
||
)
|
||
parts.append('</div>')
|
||
return "".join(parts)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Calendar description display + edit form
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def render_calendar_description(calendar, *, oob: bool = False) -> str:
|
||
"""Render calendar description display with edit button, optionally with OOB title."""
|
||
from quart import url_for
|
||
|
||
cal_slug = getattr(calendar, "slug", "")
|
||
edit_url = url_for("calendars.calendar.admin.calendar_description_edit", calendar_slug=cal_slug)
|
||
html = _calendar_description_display_html(calendar, edit_url)
|
||
|
||
if oob:
|
||
desc = getattr(calendar, "description", "") or ""
|
||
html += (
|
||
'<div id="calendar-description-title" hx-swap-oob="outerHTML"'
|
||
' class="text-base font-normal break-words whitespace-normal min-w-0 break-all w-full text-center block">'
|
||
f'{escape(desc)}</div>'
|
||
)
|
||
return html
|
||
|
||
|
||
def render_calendar_description_edit(calendar) -> str:
|
||
"""Render calendar description edit form."""
|
||
from quart import url_for
|
||
from shared.browser.app.csrf import generate_csrf_token
|
||
csrf = generate_csrf_token()
|
||
cal_slug = getattr(calendar, "slug", "")
|
||
desc = getattr(calendar, "description", "") or ""
|
||
|
||
save_url = url_for("calendars.calendar.admin.calendar_description_save", calendar_slug=cal_slug)
|
||
cancel_url = url_for("calendars.calendar.admin.calendar_description_view", calendar_slug=cal_slug)
|
||
|
||
return (
|
||
'<div id="calendar-description">'
|
||
f'<form hx-post="{save_url}" hx-target="#calendar-description" hx-swap="outerHTML">'
|
||
f'<input type="hidden" name="csrf_token" value="{csrf}">'
|
||
f'<textarea name="description" autocomplete="off" rows="4" class="w-full p-2 border rounded">{escape(desc)}</textarea>'
|
||
'<div class="mt-2 flex gap-2 text-xs">'
|
||
'<button type="submit" class="px-3 py-1 rounded bg-stone-800 text-white">Save</button>'
|
||
f'<button type="button" class="px-3 py-1 rounded border" '
|
||
f'hx-get="{cancel_url}" hx-target="#calendar-description" hx-swap="outerHTML">Cancel</button>'
|
||
'</div></form></div>'
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Payments panel (public wrapper)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def render_payments_panel(ctx: dict) -> str:
|
||
"""Render the payments config panel for PUT response."""
|
||
return _payments_main_panel_html(ctx)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Calendars list panel (for POST create / DELETE)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def render_calendars_list_panel(ctx: dict) -> str:
|
||
"""Render the calendars main panel HTML for POST/DELETE response."""
|
||
return _calendars_main_panel_html(ctx)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Markets list panel (for POST create / DELETE)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def render_markets_list_panel(ctx: dict) -> str:
|
||
"""Render the markets main panel HTML for POST/DELETE response."""
|
||
return _markets_main_panel_html(ctx)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Slot main panel
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def render_slot_main_panel(slot, calendar, *, oob: bool = False) -> str:
|
||
"""Render slot detail view."""
|
||
from quart import url_for, g
|
||
|
||
styles = getattr(g, "styles", None) or {}
|
||
list_container = getattr(styles, "list_container", "") if hasattr(styles, "list_container") else styles.get("list_container", "")
|
||
pre_action = getattr(styles, "pre_action_button", "") if hasattr(styles, "pre_action_button") else styles.get("pre_action_button", "")
|
||
cal_slug = getattr(calendar, "slug", "")
|
||
|
||
days_display = getattr(slot, "days_display", "—")
|
||
days = days_display.split(", ")
|
||
flexible = getattr(slot, "flexible", False)
|
||
time_start = slot.time_start.strftime("%H:%M") if slot.time_start else ""
|
||
time_end = slot.time_end.strftime("%H:%M") if slot.time_end else ""
|
||
cost = getattr(slot, "cost", None)
|
||
cost_str = f"{cost:.2f}" if cost is not None else ""
|
||
desc = getattr(slot, "description", "") or ""
|
||
|
||
edit_url = url_for("calendars.calendar.slots.slot.get_edit", slot_id=slot.id, calendar_slug=cal_slug)
|
||
|
||
parts = [f'<section id="slot-{slot.id}" class="{list_container}">']
|
||
|
||
# Days
|
||
parts.append(
|
||
'<div class="flex flex-col">'
|
||
'<div class="text-xs font-semibold uppercase tracking-wide text-stone-500">Days</div>'
|
||
'<div class="mt-1">'
|
||
)
|
||
if days and days[0] != "—":
|
||
parts.append('<div class="flex flex-wrap gap-1">')
|
||
for d in days:
|
||
parts.append(f'<span class="px-2 py-0.5 rounded-full text-xs bg-slate-200">{escape(d)}</span>')
|
||
parts.append('</div>')
|
||
else:
|
||
parts.append('<span class="text-xs text-slate-400">No days</span>')
|
||
parts.append('</div></div>')
|
||
|
||
# Flexible
|
||
parts.append(
|
||
'<div class="flex flex-col">'
|
||
'<div class="text-xs font-semibold uppercase tracking-wide text-stone-500">Flexible</div>'
|
||
f'<div class="mt-1">{"yes" if flexible else "no"}</div></div>'
|
||
)
|
||
|
||
# Time & Cost
|
||
parts.append(
|
||
'<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">'
|
||
'<div class="flex flex-col">'
|
||
'<div class="text-xs font-semibold uppercase tracking-wide text-stone-500">Time</div>'
|
||
f'<div class="mt-1">{time_start} — {time_end}</div></div>'
|
||
'<div class="flex flex-col">'
|
||
'<div class="text-xs font-semibold uppercase tracking-wide text-stone-500">Cost</div>'
|
||
f'<div class="mt-1">{cost_str}</div></div></div>'
|
||
)
|
||
|
||
# Edit button
|
||
parts.append(
|
||
f'<button type="button" class="{pre_action}" '
|
||
f'hx-get="{edit_url}" hx-target="#slot-{slot.id}" hx-swap="outerHTML">Edit</button>'
|
||
)
|
||
parts.append('</section>')
|
||
|
||
if oob:
|
||
parts.append(
|
||
f'<div id="slot-description-title" hx-swap-oob="outerHTML"'
|
||
f' class="text-base font-normal break-words whitespace-normal min-w-0 break-all w-full text-center block">'
|
||
f'{escape(desc)}</div>'
|
||
)
|
||
|
||
return "".join(parts)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Slots table
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def render_slots_table(slots, calendar) -> str:
|
||
"""Render slots table with rows and add button."""
|
||
from quart import url_for, g
|
||
from shared.browser.app.csrf import generate_csrf_token
|
||
csrf = generate_csrf_token()
|
||
|
||
styles = getattr(g, "styles", None) or {}
|
||
list_container = getattr(styles, "list_container", "") if hasattr(styles, "list_container") else styles.get("list_container", "")
|
||
tr_cls = getattr(styles, "tr", "") if hasattr(styles, "tr") else styles.get("tr", "")
|
||
pill_cls = getattr(styles, "pill", "") if hasattr(styles, "pill") else styles.get("pill", "")
|
||
action_btn = getattr(styles, "action_button", "") if hasattr(styles, "action_button") else styles.get("action_button", "")
|
||
pre_action = getattr(styles, "pre_action_button", "") if hasattr(styles, "pre_action_button") else styles.get("pre_action_button", "")
|
||
hx_select = getattr(g, "hx_select_search", "#main-panel")
|
||
cal_slug = getattr(calendar, "slug", "")
|
||
|
||
parts = [f'<section id="slots-table" class="{list_container}">']
|
||
parts.append(
|
||
'<table class="w-full text-sm border table-fixed"><thead class="bg-stone-100"><tr>'
|
||
'<th class="p-2 text-left w-1/6">Name</th>'
|
||
'<th class="p-2 text-left w-1/6">Flexible</th>'
|
||
'<th class="text-left p-2 w-1/6">Days</th>'
|
||
'<th class="text-left p-1/6">Time</th>'
|
||
'<th class="text-left p-2 w-1/6">Cost</th>'
|
||
'<th class="text-left p-2 w-1/6">Actions</th>'
|
||
'</tr></thead><tbody>'
|
||
)
|
||
|
||
if slots:
|
||
for s in slots:
|
||
slot_href = url_for("calendars.calendar.slots.slot.get", calendar_slug=cal_slug, slot_id=s.id)
|
||
del_url = url_for("calendars.calendar.slots.slot.slot_delete", calendar_slug=cal_slug, slot_id=s.id)
|
||
desc = getattr(s, "description", "") or ""
|
||
desc_html = f'<p class="text-stone-500 whitespace-pre-line break-all w-full">{escape(desc)}</p>' if desc else '<p class="text-stone-500 whitespace-pre-line break-all w-full"></p>'
|
||
|
||
days_display = getattr(s, "days_display", "—")
|
||
day_list = days_display.split(", ")
|
||
if day_list and day_list[0] != "—":
|
||
days_html = '<div class="flex flex-wrap gap-1">' + "".join(
|
||
f'<span class="px-2 py-0.5 rounded-full text-xs bg-slate-200">{escape(d)}</span>' for d in day_list
|
||
) + '</div>'
|
||
else:
|
||
days_html = '<span class="text-xs text-slate-400">No days</span>'
|
||
|
||
time_start = s.time_start.strftime("%H:%M") if s.time_start else ""
|
||
time_end = s.time_end.strftime("%H:%M") if s.time_end else ""
|
||
cost = getattr(s, "cost", None)
|
||
cost_str = f"{cost:.2f}" if cost is not None else ""
|
||
|
||
parts.append(
|
||
f'<tr class="{tr_cls}">'
|
||
f'<td class="p-2 align-top w-1/6"><div class="font-medium">'
|
||
f'<a href="{slot_href}" class="{pill_cls}" '
|
||
f'hx-get="{slot_href}" hx-target="#main-panel" hx-select="{hx_select}" hx-swap="outerHTML" hx-push-url="true">'
|
||
f'{escape(s.name)}</a></div>{desc_html}</td>'
|
||
f'<td class="p-2 align-top w-1/6">{"yes" if s.flexible else "no"}</td>'
|
||
f'<td class="p-2 align-top w-1/6">{days_html}</td>'
|
||
f'<td class="p-2 align-top w-1/6">{time_start} - {time_end}</td>'
|
||
f'<td class="p-2 align-top w-1/6">{cost_str}</td>'
|
||
f'<td class="p-2 align-top w-1/6">'
|
||
f'<button class="{action_btn}" '
|
||
f'data-confirm="true" data-confirm-title="Delete slot?" '
|
||
f'data-confirm-text="This action cannot be undone." '
|
||
f'data-confirm-icon="warning" data-confirm-confirm-text="Yes, delete it" '
|
||
f'data-confirm-cancel-text="Cancel" data-confirm-event="confirmed" '
|
||
f'hx-delete="{del_url}" hx-target="#slots-table" hx-select="#slots-table" '
|
||
f"""hx-swap="outerHTML" hx-headers='{{"X-CSRFToken": "{csrf}"}}' hx-trigger="confirmed" """
|
||
f'type="button"><i class="fa-solid fa-trash"></i></button></td></tr>'
|
||
)
|
||
else:
|
||
parts.append('<tr><td colspan="5" class="p-3 text-stone-500">No slots yet.</td></tr>')
|
||
|
||
parts.append('</tbody></table>')
|
||
|
||
# Add button
|
||
add_url = url_for("calendars.calendar.slots.add_form", calendar_slug=cal_slug)
|
||
parts.append(
|
||
f'<div id="slot-add-container" class="mt-4">'
|
||
f'<button type="button" class="{pre_action}" '
|
||
f'hx-get="{add_url}" hx-target="#slot-add-container" hx-swap="innerHTML">'
|
||
'+ Add slot</button></div>'
|
||
)
|
||
parts.append('</section>')
|
||
return "".join(parts)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Ticket type main panel
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def render_ticket_type_main_panel(ticket_type, entry, calendar, day, month, year, *, oob: bool = False) -> str:
|
||
"""Render ticket type detail view."""
|
||
from quart import url_for, g
|
||
|
||
styles = getattr(g, "styles", None) or {}
|
||
list_container = getattr(styles, "list_container", "") if hasattr(styles, "list_container") else styles.get("list_container", "")
|
||
pre_action = getattr(styles, "pre_action_button", "") if hasattr(styles, "pre_action_button") else styles.get("pre_action_button", "")
|
||
cal_slug = getattr(calendar, "slug", "")
|
||
|
||
name = escape(getattr(ticket_type, "name", ""))
|
||
cost = getattr(ticket_type, "cost", None)
|
||
cost_str = f"£{cost:.2f}" if cost is not None else "£0.00"
|
||
count = getattr(ticket_type, "count", 0)
|
||
|
||
edit_url = url_for(
|
||
"calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.get_edit",
|
||
ticket_type_id=ticket_type.id, calendar_slug=cal_slug,
|
||
year=year, month=month, day=day, entry_id=entry.id,
|
||
)
|
||
|
||
return (
|
||
f'<section id="ticket-{ticket_type.id}" class="{list_container}">'
|
||
'<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 text-sm">'
|
||
'<div class="flex flex-col">'
|
||
'<div class="text-xs font-semibold uppercase tracking-wide text-stone-500">Name</div>'
|
||
f'<div class="mt-1">{name}</div></div>'
|
||
'<div class="flex flex-col">'
|
||
'<div class="text-xs font-semibold uppercase tracking-wide text-stone-500">Cost</div>'
|
||
f'<div class="mt-1">{cost_str}</div></div>'
|
||
'<div class="flex flex-col">'
|
||
'<div class="text-xs font-semibold uppercase tracking-wide text-stone-500">Count</div>'
|
||
f'<div class="mt-1">{count}</div></div></div>'
|
||
f'<button type="button" class="{pre_action}" '
|
||
f'hx-get="{edit_url}" hx-target="#ticket-{ticket_type.id}" hx-swap="outerHTML">Edit</button>'
|
||
'</section>'
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Ticket types table
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def render_ticket_types_table(ticket_types, entry, calendar, day, month, year) -> str:
|
||
"""Render ticket types table with rows and add button."""
|
||
from quart import url_for, g
|
||
from shared.browser.app.csrf import generate_csrf_token
|
||
csrf = generate_csrf_token()
|
||
|
||
styles = getattr(g, "styles", None) or {}
|
||
list_container = getattr(styles, "list_container", "") if hasattr(styles, "list_container") else styles.get("list_container", "")
|
||
tr_cls = getattr(styles, "tr", "") if hasattr(styles, "tr") else styles.get("tr", "")
|
||
pill_cls = getattr(styles, "pill", "") if hasattr(styles, "pill") else styles.get("pill", "")
|
||
action_btn = getattr(styles, "action_button", "") if hasattr(styles, "action_button") else styles.get("action_button", "")
|
||
hx_select = getattr(g, "hx_select_search", "#main-panel")
|
||
cal_slug = getattr(calendar, "slug", "")
|
||
eid = entry.id
|
||
|
||
parts = [f'<section id="tickets-table" class="{list_container}">']
|
||
parts.append(
|
||
'<table class="w-full text-sm border table-fixed"><thead class="bg-stone-100"><tr>'
|
||
'<th class="p-2 text-left w-1/3">Name</th>'
|
||
'<th class="text-left p-2 w-1/4">Cost</th>'
|
||
'<th class="text-left p-2 w-1/4">Count</th>'
|
||
'<th class="text-left p-2 w-1/6">Actions</th>'
|
||
'</tr></thead><tbody>'
|
||
)
|
||
|
||
if ticket_types:
|
||
for tt in ticket_types:
|
||
tt_href = url_for(
|
||
"calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.get",
|
||
calendar_slug=cal_slug, year=year, month=month, day=day,
|
||
entry_id=eid, ticket_type_id=tt.id,
|
||
)
|
||
del_url = url_for(
|
||
"calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.delete",
|
||
calendar_slug=cal_slug, year=year, month=month, day=day,
|
||
entry_id=eid, ticket_type_id=tt.id,
|
||
)
|
||
cost = getattr(tt, "cost", None)
|
||
cost_str = f"£{cost:.2f}" if cost is not None else "£0.00"
|
||
|
||
parts.append(
|
||
f'<tr class="{tr_cls}">'
|
||
f'<td class="p-2 align-top w-1/3"><div class="font-medium">'
|
||
f'<a href="{tt_href}" class="{pill_cls}" '
|
||
f'hx-get="{tt_href}" hx-target="#main-panel" hx-select="{hx_select}" hx-swap="outerHTML" hx-push-url="true">'
|
||
f'{escape(tt.name)}</a></div></td>'
|
||
f'<td class="p-2 align-top w-1/4">{cost_str}</td>'
|
||
f'<td class="p-2 align-top w-1/4">{tt.count}</td>'
|
||
f'<td class="p-2 align-top w-1/6">'
|
||
f'<button class="{action_btn}" '
|
||
f'data-confirm="true" data-confirm-title="Delete ticket type?" '
|
||
f'data-confirm-text="This action cannot be undone." '
|
||
f'data-confirm-icon="warning" data-confirm-confirm-text="Yes, delete it" '
|
||
f'data-confirm-cancel-text="Cancel" data-confirm-event="confirmed" '
|
||
f'hx-delete="{del_url}" hx-target="#tickets-table" hx-select="#tickets-table" '
|
||
f"""hx-swap="outerHTML" hx-headers='{{"X-CSRFToken": "{csrf}"}}' hx-trigger="confirmed" """
|
||
f'type="button"><i class="fa-solid fa-trash"></i></button></td></tr>'
|
||
)
|
||
else:
|
||
parts.append('<tr><td colspan="4" class="p-3 text-stone-500">No ticket types yet.</td></tr>')
|
||
|
||
parts.append('</tbody></table>')
|
||
|
||
# Add button
|
||
add_url = url_for(
|
||
"calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.add_form",
|
||
calendar_slug=cal_slug, entry_id=eid, year=year, month=month, day=day,
|
||
)
|
||
parts.append(
|
||
f'<div id="ticket-add-container" class="mt-4">'
|
||
f'<button class="{action_btn}" '
|
||
f'hx-get="{add_url}" hx-target="#ticket-add-container" hx-swap="innerHTML">'
|
||
'<i class="fa fa-plus"></i> Add ticket type</button></div>'
|
||
)
|
||
parts.append('</section>')
|
||
return "".join(parts)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Buy result (ticket purchase confirmation)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def render_buy_result(entry, created_tickets, remaining, cart_count) -> str:
|
||
"""Render buy result card with created tickets + OOB cart icon."""
|
||
from quart import url_for
|
||
|
||
# OOB cart icon
|
||
html = _cart_icon_oob(cart_count)
|
||
|
||
count = len(created_tickets)
|
||
suffix = "s" if count != 1 else ""
|
||
parts = [f'<div id="ticket-buy-{entry.id}" class="rounded-xl border border-emerald-200 bg-emerald-50 p-4">']
|
||
parts.append(
|
||
'<div class="flex items-center gap-2 mb-3">'
|
||
'<i class="fa fa-check-circle text-emerald-600" aria-hidden="true"></i>'
|
||
f'<span class="font-semibold text-emerald-800">{count} ticket{suffix} reserved</span></div>'
|
||
)
|
||
|
||
parts.append('<div class="space-y-2 mb-4">')
|
||
for ticket in created_tickets:
|
||
href = url_for("tickets.ticket_detail", code=ticket.code)
|
||
parts.append(
|
||
f'<a href="{href}" class="flex items-center justify-between p-2 rounded-lg bg-white border border-emerald-100 hover:border-emerald-300 transition text-sm">'
|
||
'<div class="flex items-center gap-2">'
|
||
'<i class="fa fa-ticket text-emerald-500" aria-hidden="true"></i>'
|
||
f'<span class="font-mono text-xs text-stone-500">{ticket.code[:12]}...</span></div>'
|
||
'<span class="text-xs text-emerald-600 font-medium">View ticket</span></a>'
|
||
)
|
||
parts.append('</div>')
|
||
|
||
if remaining is not None:
|
||
r_suffix = "s" if remaining != 1 else ""
|
||
parts.append(f'<p class="text-xs text-stone-500">{remaining} ticket{r_suffix} remaining</p>')
|
||
|
||
my_href = url_for("tickets.my_tickets")
|
||
parts.append(
|
||
'<div class="mt-3 flex gap-2">'
|
||
f'<a href="{my_href}" class="text-sm text-emerald-700 hover:text-emerald-900 underline">'
|
||
'View all my tickets</a></div>'
|
||
)
|
||
parts.append('</div>')
|
||
return html + "".join(parts)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Buy form (ticket +/- controls)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def render_buy_form(entry, ticket_remaining, ticket_sold_count,
|
||
user_ticket_count, user_ticket_counts_by_type) -> str:
|
||
"""Render the ticket buy/adjust form with +/- controls."""
|
||
from quart import url_for
|
||
from shared.browser.app.csrf import generate_csrf_token
|
||
csrf = generate_csrf_token()
|
||
|
||
eid = entry.id
|
||
tp = getattr(entry, "ticket_price", None)
|
||
state = getattr(entry, "state", "")
|
||
ticket_types = getattr(entry, "ticket_types", None) or []
|
||
|
||
if tp is None:
|
||
return ""
|
||
|
||
if tp is not None and state != "confirmed":
|
||
return (
|
||
f'<div id="ticket-buy-{eid}" class="rounded-xl border border-stone-200 bg-stone-50 p-4 text-sm text-stone-500">'
|
||
'<i class="fa fa-ticket mr-1" aria-hidden="true"></i>'
|
||
'Tickets available once this event is confirmed.</div>'
|
||
)
|
||
|
||
adjust_url = url_for("tickets.adjust_quantity")
|
||
target = f"#ticket-buy-{eid}"
|
||
|
||
parts = [f'<div id="ticket-buy-{eid}" class="rounded-xl border border-stone-200 bg-white p-4">']
|
||
parts.append(
|
||
'<h3 class="text-sm font-semibold text-stone-700 mb-3">'
|
||
'<i class="fa fa-ticket mr-1" aria-hidden="true"></i>Tickets</h3>'
|
||
)
|
||
|
||
# Info line
|
||
info_parts = []
|
||
if ticket_sold_count:
|
||
info_parts.append(f'<span>{ticket_sold_count} sold</span>')
|
||
if ticket_remaining is not None:
|
||
info_parts.append(f'<span>{ticket_remaining} remaining</span>')
|
||
if user_ticket_count:
|
||
info_parts.append(
|
||
'<span class="text-emerald-600 font-medium">'
|
||
f'<i class="fa fa-shopping-cart text-[0.6rem]" aria-hidden="true"></i> {user_ticket_count} in basket</span>'
|
||
)
|
||
if info_parts:
|
||
parts.append(f'<div class="flex items-center gap-3 mb-3 text-xs text-stone-500">{"".join(info_parts)}</div>')
|
||
|
||
active_types = [tt for tt in ticket_types if getattr(tt, "deleted_at", None) is None]
|
||
|
||
if active_types:
|
||
# Multiple ticket types
|
||
parts.append('<div class="space-y-2">')
|
||
for tt in active_types:
|
||
type_count = user_ticket_counts_by_type.get(tt.id, 0) if user_ticket_counts_by_type else 0
|
||
cost_str = f"£{tt.cost:.2f}" if tt.cost is not None else "£0.00"
|
||
|
||
parts.append(
|
||
'<div class="flex items-center justify-between p-3 rounded-lg bg-stone-50 border border-stone-100">'
|
||
f'<div><div class="font-medium text-sm">{escape(tt.name)}</div>'
|
||
f'<div class="text-xs text-stone-500">{cost_str}</div></div>'
|
||
)
|
||
parts.append(_ticket_adjust_controls(csrf, adjust_url, target, eid, type_count, ticket_type_id=tt.id))
|
||
parts.append('</div>')
|
||
parts.append('</div>')
|
||
else:
|
||
# Simple ticket
|
||
parts.append(
|
||
'<div class="flex items-center justify-between mb-4"><div>'
|
||
f'<span class="font-medium text-green-600">£{tp:.2f}</span>'
|
||
'<span class="text-sm text-stone-500 ml-2">per ticket</span></div></div>'
|
||
)
|
||
qty = user_ticket_count or 0
|
||
parts.append(_ticket_adjust_controls(csrf, adjust_url, target, eid, qty))
|
||
|
||
parts.append('</div>')
|
||
return "".join(parts)
|
||
|
||
|
||
def _ticket_adjust_controls(csrf, adjust_url, target, entry_id, count, *, ticket_type_id=None):
|
||
"""Render +/- ticket controls for buy form."""
|
||
from quart import url_for
|
||
|
||
tt_hidden = f'<input type="hidden" name="ticket_type_id" value="{ticket_type_id}" />' if ticket_type_id else ""
|
||
my_tickets_href = url_for("tickets.my_tickets")
|
||
|
||
if count == 0:
|
||
return (
|
||
f'<form hx-post="{adjust_url}" hx-target="{target}" hx-swap="outerHTML" class="flex items-center">'
|
||
f'<input type="hidden" name="csrf_token" value="{csrf}" />'
|
||
f'<input type="hidden" name="entry_id" value="{entry_id}" />'
|
||
f'{tt_hidden}'
|
||
'<input type="hidden" name="count" value="1" />'
|
||
'<button type="submit" class="relative inline-flex items-center justify-center text-sm font-medium text-stone-500 hover:bg-emerald-50 rounded p-1">'
|
||
'<i class="fa fa-cart-plus text-2xl" aria-hidden="true"></i></button></form>'
|
||
)
|
||
|
||
return (
|
||
'<div class="flex items-center gap-2">'
|
||
f'<form hx-post="{adjust_url}" hx-target="{target}" hx-swap="outerHTML">'
|
||
f'<input type="hidden" name="csrf_token" value="{csrf}" />'
|
||
f'<input type="hidden" name="entry_id" value="{entry_id}" />'
|
||
f'{tt_hidden}'
|
||
f'<input type="hidden" name="count" value="{count - 1}" />'
|
||
'<button type="submit" class="inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl">-</button></form>'
|
||
f'<a class="relative inline-flex items-center justify-center text-emerald-700" href="{my_tickets_href}">'
|
||
'<span class="relative inline-flex items-center justify-center">'
|
||
'<i class="fa-solid fa-shopping-cart text-2xl" aria-hidden="true"></i>'
|
||
'<span class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none">'
|
||
f'<span class="flex items-center justify-center bg-black text-white rounded-full w-4 h-4 text-xs font-bold">{count}</span>'
|
||
'</span></span></a>'
|
||
f'<form hx-post="{adjust_url}" hx-target="{target}" hx-swap="outerHTML">'
|
||
f'<input type="hidden" name="csrf_token" value="{csrf}" />'
|
||
f'<input type="hidden" name="entry_id" value="{entry_id}" />'
|
||
f'{tt_hidden}'
|
||
f'<input type="hidden" name="count" value="{count + 1}" />'
|
||
'<button type="submit" class="inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl">+</button></form>'
|
||
'</div>'
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Adjust response (OOB cart icon + buy form)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def render_adjust_response(entry, ticket_remaining, ticket_sold_count,
|
||
user_ticket_count, user_ticket_counts_by_type,
|
||
cart_count) -> str:
|
||
"""Render ticket adjust response: OOB cart icon + buy form."""
|
||
cart_html = _cart_icon_oob(cart_count)
|
||
form_html = render_buy_form(
|
||
entry, ticket_remaining, ticket_sold_count,
|
||
user_ticket_count, user_ticket_counts_by_type,
|
||
)
|
||
return cart_html + form_html
|
||
|
||
|
||
def _cart_icon_oob(count: int) -> str:
|
||
"""Render the OOB cart icon/badge swap."""
|
||
from quart import g
|
||
|
||
blog_url_fn = getattr(g, "blog_url", None)
|
||
cart_url_fn = getattr(g, "cart_url", None)
|
||
site_fn = getattr(g, "site", None)
|
||
logo = ""
|
||
if site_fn:
|
||
site_obj = site_fn() if callable(site_fn) else site_fn
|
||
logo = getattr(site_obj, "logo", "") if site_obj else ""
|
||
|
||
if count == 0:
|
||
blog_href = blog_url_fn("/") if blog_url_fn else "/"
|
||
return (
|
||
'<div id="cart-mini" hx-swap-oob="true">'
|
||
'<div class="h-12 w-12 rounded-full overflow-hidden border border-stone-300 flex-shrink-0">'
|
||
f'<a href="{blog_href}" class="h-full w-full font-bold text-5xl flex-shrink-0 flex flex-row items-center gap-1">'
|
||
f'<img src="{logo}" class="h-full w-full rounded-full object-cover border border-stone-300 flex-shrink-0"></a>'
|
||
'</div></div>'
|
||
)
|
||
|
||
cart_href = cart_url_fn("/") if cart_url_fn else "/"
|
||
return (
|
||
'<div id="cart-mini" hx-swap-oob="true">'
|
||
f'<a href="{cart_href}" class="relative inline-flex items-center justify-center text-stone-700 hover:text-emerald-700">'
|
||
'<i class="fa fa-shopping-cart text-5xl" aria-hidden="true"></i>'
|
||
'<span class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 inline-flex items-center justify-center rounded-full bg-emerald-600 text-white text-sm w-5 h-5">'
|
||
f'{count}</span></a></div>'
|
||
)
|