diff --git a/events/sexp/sexp_components.py b/events/sexp/sexp_components.py
index e9ca07a..3b07fd8 100644
--- a/events/sexp/sexp_components.py
+++ b/events/sexp/sexp_components.py
@@ -24,10 +24,12 @@ from shared.sexp.helpers import (
def _oob_header_html(parent_id: str, child_id: str, row_html: str) -> str:
"""Wrap a header row in OOB div with child placeholder."""
- return (
- f'
'
+ 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,
)
@@ -42,24 +44,23 @@ def _post_header_html(ctx: dict, *, oob: bool = False) -> str:
title = (post.get("title") or "")[:160]
feature_image = post.get("feature_image")
- label_parts = []
- if feature_image:
- label_parts.append(
- f' '
- )
- label_parts.append(f"{escape(title)} ")
- label_html = "".join(label_parts)
+ 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(
- f''
- f' '
- f'{page_cart_count} '
- )
+ 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))
@@ -124,7 +125,7 @@ def _calendars_header_html(ctx: dict, *, oob: bool = False) -> str:
' :link-href lh :link-label-html llh'
' :child-id "calendars-header-child" :oob oob)',
lh=link_href,
- llh='Calendars
',
+ llh=sexp('(<> (i :class "fa fa-calendar" :aria-hidden "true") (div "Calendars"))'),
oob=oob,
)
@@ -144,14 +145,15 @@ def _calendar_header_html(ctx: dict, *, oob: bool = False) -> str:
cal_desc = getattr(calendar, "description", "") or ""
link_href = url_for("calendars.calendar.get", calendar_slug=cal_slug)
- label_html = (
- ''
- '
'
- f'
'
- f'
{escape(cal_name)}
'
- '
'
- f'
{escape(cal_desc)}
'
- '
'
+ 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
@@ -188,10 +190,10 @@ def _calendar_nav_html(ctx: dict) -> str:
))
if is_admin:
admin_href = url_for("calendars.calendar.admin.admin", calendar_slug=cal_slug)
- parts.append(
- f''
- f' '
- )
+ parts.append(sexp(
+ '(~nav-link :href h :icon "fa fa-cog" :select-colours sc)',
+ h=admin_href, sc=select_colours,
+ ))
return "".join(parts)
@@ -217,11 +219,11 @@ def _day_header_html(ctx: dict, *, oob: bool = False) -> str:
month=day_date.month,
day=day_date.day,
)
- label_html = (
- ''
- f' '
- f' {escape(day_date.strftime("%A %d %B %Y"))}'
- '
'
+ 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)
@@ -252,11 +254,7 @@ def _day_nav_html(ctx: dict) -> str:
parts = []
# Confirmed entries nav (scrolling menu)
if confirmed_entries:
- parts.append(
- '')
+ 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(
@@ -286,10 +290,10 @@ def _day_nav_html(ctx: dict) -> str:
month=day_date.month,
day=day_date.day,
)
- parts.append(
- f''
- f' '
- )
+ parts.append(sexp(
+ '(~nav-link :href h :icon "fa fa-cog")',
+ h=admin_href,
+ ))
return "".join(parts)
@@ -370,7 +374,7 @@ def _markets_header_html(ctx: dict, *, oob: bool = False) -> str:
' :link-href lh :link-label-html llh'
' :child-id "markets-header-child" :oob oob)',
lh=link_href,
- llh='Markets
',
+ llh=sexp('(<> (i :class "fa fa-shopping-bag" :aria-hidden "true") (div "Markets"))'),
oob=oob,
)
@@ -388,7 +392,7 @@ def _payments_header_html(ctx: dict, *, oob: bool = False) -> str:
' :link-href lh :link-label-html llh'
' :child-id "payments-header-child" :oob oob)',
lh=link_href,
- llh='Payments
',
+ llh=sexp('(<> (i :class "fa fa-credit-card" :aria-hidden "true") (div "Payments"))'),
oob=oob,
)
@@ -408,41 +412,45 @@ def _calendars_main_panel_html(ctx: dict) -> str:
csrf = csrf_token() if callable(csrf_token) else (csrf_token or "")
calendars = ctx.get("calendars") or []
- hx_select = ctx.get("hx_select_search", "#main-panel")
- parts = ['']
+ form_html = ""
if can_create:
create_url = url_for("calendars.create_calendar")
- parts.append(
- '
'
- f''
+ 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,
)
- parts.append('')
- parts.append(_calendars_list_html(ctx, calendars))
- parts.append('
')
- return "".join(parts)
+ 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
- hx_select = ctx.get("hx_select_search", "#main-panel")
csrf_token = ctx.get("csrf_token")
csrf = csrf_token() if callable(csrf_token) else (csrf_token or "")
prefix = route_prefix()
if not calendars:
- return 'No calendars yet. Create one above.
'
+ return sexp('(p :class "text-gray-500 mt-4" "No calendars yet. Create one above.")')
parts = []
for cal in calendars:
@@ -450,22 +458,25 @@ def _calendars_list_html(ctx: dict, calendars: list) -> str:
cal_name = getattr(cal, "name", "")
href = prefix + url_for("calendars.calendar.get", calendar_slug=cal_slug)
del_url = url_for("calendars.calendar.delete", calendar_slug=cal_slug)
- parts.append(
- f''
- )
+ 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)
@@ -502,50 +513,39 @@ def _calendar_main_panel_html(ctx: dict) -> str:
qs = qsession if "qsession" not in ctx else ctx["qsession"]
def nav_link(y, m):
- href = url_for("calendars.calendar.get", calendar_slug=cal_slug, year=y, month=m)
- return href
+ return url_for("calendars.calendar.get", calendar_slug=cal_slug, year=y, month=m)
- # Month navigation header
- parts = ['']
- parts.append('')
+ 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,
+ ))
- # Calendar grid
- parts.append('')
# Weekday headers
- parts.append('
')
- for wd in weekday_names:
- parts.append(f'
{wd}
')
- parts.append('
')
+ wd_html = "".join(sexp('(div :class "py-1" w)', w=wd) for wd in weekday_names)
- # Weeks grid
- parts.append('
')
+ # Day cells
+ cells = []
for week in weeks:
for day_cell in week:
if isinstance(day_cell, dict):
@@ -563,29 +563,27 @@ def _calendar_main_panel_html(ctx: dict) -> str:
if is_today:
cell_cls += " ring-2 ring-blue-500 z-10 relative"
- parts.append(f'
')
- parts.append('
')
- if day_date:
- parts.append(f'
{day_date.strftime("%a")} ')
-
- # Clickable day number
+ # 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,
+ year=day_date.year, month=day_date.month, day=day_date.day,
)
- parts.append(
- f'
{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),
)
- parts.append('
')
- # Entries for this day
- parts.append('
')
+ # 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:
@@ -597,18 +595,34 @@ def _calendar_main_panel_html(ctx: dict) -> str:
bg_cls = "bg-emerald-200 text-emerald-900" if is_mine else "bg-emerald-100 text-emerald-800"
else:
bg_cls = "bg-sky-100 text-sky-800" if is_mine else "bg-stone-100 text-stone-700"
-
state_label = (e.state or "pending").replace("_", " ")
- parts.append(
- f'
'
- f'{escape(e.name)} '
- f'{state_label} '
- f'
'
- )
- parts.append('
')
+ 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,
+ ))
- parts.append('
')
- return "".join(parts)
+ 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,
+ )
# ---------------------------------------------------------------------------
@@ -634,40 +648,36 @@ def _day_main_panel_html(ctx: dict) -> str:
tr_cls = getattr(styles, "tr", "") if hasattr(styles, "tr") else styles.get("tr", "")
pre_action = getattr(styles, "pre_action_button", "") if hasattr(styles, "pre_action_button") else styles.get("pre_action_button", "")
- parts = [f'']
- parts.append(
- ''
- 'Name '
- 'Slot/Time '
- 'State '
- 'Cost '
- 'Tickets '
- 'Actions '
- ' '
- )
-
+ rows_html = ""
if day_entries:
- for entry in day_entries:
- parts.append(_day_row_html(ctx, entry))
+ rows_html = "".join(_day_row_html(ctx, entry) for entry in day_entries)
else:
- parts.append('No entries yet. ')
+ rows_html = sexp('(tr (td :colspan "6" :class "p-3 text-stone-500" "No entries yet."))')
- parts.append('
')
-
- # Add entry button
add_url = url_for(
"calendars.calendar.day.calendar_entries.add_form",
calendar_slug=cal_slug,
day=day, month=month, year=year,
)
- parts.append(
- f''
- f''
- f'+ Add entry
'
+
+ 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,
)
- parts.append(' ')
- return "".join(parts)
def _day_row_html(ctx: dict, entry) -> str:
@@ -689,11 +699,11 @@ def _day_row_html(ctx: dict, entry) -> str:
)
# Name
- name_html = (
- f' '
+ 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
@@ -701,47 +711,55 @@ def _day_row_html(ctx: dict, entry) -> str:
if slot:
slot_href = url_for("calendars.calendar.slots.slot.get", calendar_slug=cal_slug, slot_id=slot.id)
time_start = slot.time_start.strftime("%H:%M") if slot.time_start else ""
- time_end = f" → {slot.time_end.strftime('%H:%M')}" if slot.time_end else ""
- slot_html = (
- f''
- f'
'
- f'{escape(slot.name)} '
- f'
({time_start}{time_end}) '
- f'
'
+ 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" (str "(" ts te ")"))))',
+ h=slot_href, pc=pill_cls, sn=slot.name, ts=time_start, te=time_end,
)
else:
start = entry.start_at.strftime("%H:%M") if entry.start_at else ""
- end = f" → {entry.end_at.strftime('%H:%M')}" if entry.end_at else ""
- slot_html = f'{start}{end}
'
+ 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_html = _entry_state_badge_html(state)
- state_td = f'{state_html}
'
+ 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"£{cost:.2f}" if cost is not None else "£0.00"
- cost_td = f'{cost_str} '
+ 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 = (
- f''
- f'
£{tp:.2f}
'
- f'
{tc_str}
'
+ 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 = 'No tickets '
+ tickets_td = sexp('(td :class "p-2 align-top w-1/6" (span :class "text-xs text-stone-400" "No tickets"))')
- # Actions (entry options) - keep simple, just link to entry
- actions_td = f' '
+ actions_td = sexp('(td :class "p-2 align-top w-1/6")')
- return f'{name_html}{slot_html}{state_td}{cost_td}{tickets_td}{actions_td} '
+ 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:
@@ -755,7 +773,10 @@ def _entry_state_badge_html(state: str) -> str:
}
cls = state_classes.get(state, "bg-stone-100 text-stone-700")
label = state.replace("_", " ").capitalize()
- return f'{label} '
+ 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,
+ )
# ---------------------------------------------------------------------------
@@ -764,7 +785,7 @@ def _entry_state_badge_html(state: str) -> str:
def _day_admin_main_panel_html(ctx: dict) -> str:
"""Render day admin panel (placeholder nav)."""
- return 'Admin options
'
+ return sexp('(div :class "p-4 text-sm text-stone-500" "Admin options")')
# ---------------------------------------------------------------------------
@@ -786,42 +807,40 @@ def _calendar_admin_main_panel_html(ctx: dict) -> str:
desc_edit_url = url_for("calendars.calendar.admin.calendar_description_edit", calendar_slug=cal_slug)
description_html = _calendar_description_display_html(calendar, desc_edit_url)
- parts = ['']
- parts.append('Calendar configuration ')
- parts.append('
')
- parts.append(f'
Description ')
- parts.append(description_html)
- parts.append('
')
-
- # Hidden form for direct PUT
- parts.append(
- f'
'
+ 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,
)
- parts.append('
')
- return "".join(parts)
def _calendar_description_display_html(calendar, edit_url: str) -> str:
"""Render calendar description display with edit button."""
desc = getattr(calendar, "description", "") or ""
- if desc:
- desc_html = f'{escape(desc)}
'
- else:
- desc_html = 'No description yet.
'
- return (
- f'{desc_html}'
- f''
- f'
'
+ 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,
)
@@ -839,27 +858,33 @@ def _markets_main_panel_html(ctx: dict) -> str:
csrf_token = ctx.get("csrf_token")
csrf = csrf_token() if callable(csrf_token) else (csrf_token or "")
markets = ctx.get("markets") or []
- post = ctx.get("post") or {}
- parts = ['']
+ form_html = ""
if can_create:
create_url = url_for("markets.create_market")
- parts.append(
- '
'
- f'"""
- f' '
- 'Name '
- '
'
- 'Add 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,
)
- parts.append('')
- parts.append(_markets_list_html(ctx, markets))
- parts.append('
')
- return "".join(parts)
+
+ 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:
@@ -871,7 +896,7 @@ def _markets_list_html(ctx: dict, markets: list) -> str:
slug = post.get("slug", "")
if not markets:
- return 'No markets yet. Create one above.
'
+ return sexp('(p :class "text-gray-500 mt-4" "No markets yet. Create one above.")')
parts = []
for m in markets:
@@ -879,21 +904,24 @@ def _markets_list_html(ctx: dict, markets: list) -> str:
m_name = getattr(m, "name", "") if hasattr(m, "name") else m.get("name", "")
market_href = call_url(ctx, "market_url", f"/{slug}/{m_slug}/")
del_url = url_for("markets.delete_market", market_slug=m_slug)
- parts.append(
- f''
- )
+ 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)
@@ -912,30 +940,29 @@ def _payments_main_panel_html(ctx: dict) -> str:
update_url = url_for("payments.update_sumup")
placeholder = "--------" if sumup_configured else "sup_sk_..."
- key_note = 'Key is set. Leave blank to keep current key.
' if sumup_configured else ""
- connected = (''
- ' Connected ') if sumup_configured else ""
+ 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 (
- ''
+ 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,
)
@@ -953,7 +980,10 @@ def _ticket_state_badge_html(state: str) -> str:
}
cls = cls_map.get(state, "bg-stone-100 text-stone-700")
label = (state or "").replace("_", " ").capitalize()
- return f'{label} '
+ 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,
+ )
# ---------------------------------------------------------------------------