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