diff --git a/blog/bp/post/admin/routes.py b/blog/bp/post/admin/routes.py
index d06157e..5ae0db0 100644
--- a/blog/bp/post/admin/routes.py
+++ b/blog/bp/post/admin/routes.py
@@ -10,18 +10,11 @@ from quart import (
url_for,
)
from shared.browser.app.authz import require_admin, require_post_author
-from markupsafe import escape
from shared.sx.helpers import sx_response, sx_call
from shared.sx.parser import SxExpr, serialize as sx_serialize
from shared.utils import host_url
-def _raw_html_sx(html: str) -> str:
- """Wrap raw HTML in (raw! "...") so it's valid inside sx source."""
- if not html:
- return ""
- return "(raw! " + sx_serialize(html) + ")"
-
def _post_to_edit_dict(post) -> dict:
"""Convert an ORM Post to a dict matching the shape templates expect.
@@ -96,95 +89,58 @@ def _render_calendar_view(
prev_year, next_year, month_entries, associated_entry_ids,
post_slug: str,
) -> str:
- """Build calendar month grid HTML."""
+ """Build calendar month grid via ~blog-calendar-view defcomp."""
from quart import url_for as qurl
from shared.browser.app.csrf import generate_csrf_token
- esc = escape
- csrf = generate_csrf_token()
cal_id = calendar.id
+ csrf = generate_csrf_token()
def cal_url(y, m):
- return esc(host_url(qurl("blog.post.admin.calendar_view", slug=post_slug, calendar_id=cal_id, year=y, month=m)))
+ return host_url(qurl("blog.post.admin.calendar_view",
+ slug=post_slug, calendar_id=cal_id, year=y, month=m))
- cur_url = cal_url(year, month)
- toggle_url_fn = lambda eid: esc(host_url(qurl("blog.post.admin.toggle_entry", slug=post_slug, entry_id=eid)))
-
- nav = (
- f''
- f''
- )
-
- wd_cells = "".join(f'
{esc(wd)}
' for wd in weekday_names)
- wd_row = f'{wd_cells}
'
-
- cells: list[str] = []
+ # Flatten weeks into day dicts with pre-computed entries per day
+ days = []
for week in weeks:
for day in week:
- extra_cls = " bg-stone-50 text-stone-400" if not day.in_month else ""
day_date = day.date
-
- entry_btns: list[str] = []
+ day_entries = []
for e in month_entries:
e_start = getattr(e, "start_at", None)
if not e_start or e_start.date() != day_date:
continue
e_id = getattr(e, "id", None)
- e_name = esc(getattr(e, "name", ""))
- t_url = toggle_url_fn(e_id)
- hx_hdrs = f'{{"X-CSRFToken": "{csrf}"}}'
+ toggle_url = host_url(qurl("blog.post.admin.toggle_entry",
+ slug=post_slug, entry_id=e_id))
+ day_entries.append({
+ "name": str(getattr(e, "name", "")),
+ "toggle_url": toggle_url,
+ "is_associated": e_id in associated_entry_ids,
+ })
+ # Wrap nested entries list as SxExpr so it serializes as (list ...)
+ entries_sx = SxExpr("(list " + " ".join(
+ sx_serialize(e) for e in day_entries
+ ) + ")") if day_entries else None
+ days.append({
+ "day": day_date.day,
+ "in_month": day.in_month,
+ "entries": entries_sx,
+ })
- if e_id in associated_entry_ids:
- entry_btns.append(
- f''
- f'{e_name}'
- f'
'
- )
- else:
- entry_btns.append(
- f''
- )
-
- entries_html = '' + "".join(entry_btns) + '
' if entry_btns else ''
- cells.append(
- f''
- )
-
- grid = f'{"".join(cells)}
'
-
- html = (
- f''
- f'{nav}'
- f'
{wd_row}{grid}
'
- f'
'
+ return sx_call("blog-calendar-view",
+ cal_id=str(cal_id),
+ year=str(year),
+ month_name=month_name,
+ current_url=cal_url(year, month),
+ prev_month_url=cal_url(prev_month_year, prev_month),
+ prev_year_url=cal_url(prev_year, month),
+ next_month_url=cal_url(next_month_year, next_month),
+ next_year_url=cal_url(next_year, month),
+ weekday_names=list(weekday_names),
+ days=days,
+ csrf=csrf,
)
- return _raw_html_sx(html)
def _render_associated_entries(all_calendars, associated_entry_ids, post_slug: str) -> str:
@@ -201,37 +157,15 @@ def _render_associated_entries(all_calendars, associated_entry_ids, post_slug: s
def _render_nav_entries_oob(associated_entries, calendars, post: dict) -> str:
- """Render the OOB nav entries swap."""
+ """Render the OOB nav entries swap via ~blog-nav-entries-oob defcomp."""
entries_list = []
if associated_entries and hasattr(associated_entries, "entries"):
entries_list = associated_entries.entries or []
- has_items = bool(entries_list or calendars)
-
- if not has_items:
- return sx_call("blog-nav-entries-empty")
-
- select_colours = (
- "[.hover-capable_&]:hover:bg-yellow-300"
- " aria-selected:bg-stone-500 aria-selected:text-white"
- " [.hover-capable_&[aria-selected=true]:hover]:bg-orange-500"
- )
- nav_cls = (
- f"justify-center cursor-pointer flex flex-row items-center gap-2"
- f" rounded bg-stone-200 text-black {select_colours} p-2"
- )
-
post_slug = post.get("slug", "")
- scroll_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"
- )
-
- item_parts = []
-
+ # Extract entry data as list of dicts
+ entry_data = []
for entry in entries_list:
e_name = getattr(entry, "name", "")
e_start = getattr(entry, "start_at", None)
@@ -239,7 +173,7 @@ def _render_nav_entries_oob(associated_entries, calendars, post: dict) -> str:
cal_slug = getattr(entry, "calendar_slug", "")
if e_start:
- entry_path = (
+ href = (
f"/{post_slug}/{cal_slug}/"
f"{e_start.year}/{e_start.month}/{e_start.day}"
f"/entries/{getattr(entry, 'id', '')}/"
@@ -248,32 +182,19 @@ def _render_nav_entries_oob(associated_entries, calendars, post: dict) -> str:
if e_end:
date_str += f" \u2013 {e_end.strftime('%H:%M')}"
else:
- entry_path = f"/{post_slug}/{cal_slug}/"
+ href = f"/{post_slug}/{cal_slug}/"
date_str = ""
- item_parts.append(sx_call("calendar-entry-nav",
- href=entry_path, nav_class=nav_cls, name=e_name, date_str=date_str,
- ))
+ entry_data.append({"name": e_name, "href": href, "date_str": date_str})
+ # Extract calendar data as list of dicts
+ cal_data = []
for calendar in (calendars or []):
cal_name = getattr(calendar, "name", "")
cal_slug = getattr(calendar, "slug", "")
- cal_path = f"/{post_slug}/{cal_slug}/"
+ cal_data.append({"name": cal_name, "href": f"/{post_slug}/{cal_slug}/"})
- item_parts.append(sx_call("blog-nav-calendar-item",
- href=cal_path, nav_cls=nav_cls, name=cal_name,
- ))
-
- items_sx = "(<> " + " ".join(item_parts) + ")" if item_parts else ""
-
- return sx_call("scroll-nav-wrapper",
- wrapper_id="entries-calendars-nav-wrapper", container_id="associated-items-container",
- arrow_cls="entries-nav-arrow",
- left_hs="on click set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft - 200",
- scroll_hs=scroll_hs,
- right_hs="on click set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft + 200",
- items=SxExpr(items_sx) if items_sx else None, oob=True,
- )
+ return sx_call("blog-nav-entries-oob", entries=entry_data, calendars=cal_data)
def register():
diff --git a/blog/sx/admin.sx b/blog/sx/admin.sx
index c7b2cd9..dc39c46 100644
--- a/blog/sx/admin.sx
+++ b/blog/sx/admin.sx
@@ -412,3 +412,108 @@
(~blog-data-model-content
:columns (get model-data "columns")
:relationships (get model-data "relationships")))))
+
+;; ---------------------------------------------------------------------------
+;; Calendar month view for browsing/toggling entries (B1)
+;; ---------------------------------------------------------------------------
+
+(defcomp ~blog-cal-entry-associated (&key name toggle-url csrf)
+ (div :class "flex items-center gap-1 text-[10px] rounded px-1 py-0.5 bg-green-200 text-green-900"
+ (span :class "truncate flex-1" name)
+ (button :type "button" :class "flex-shrink-0 hover:text-red-600"
+ :data-confirm "" :data-confirm-title "Remove entry?"
+ :data-confirm-text (str "Remove " name " from this post?")
+ :data-confirm-icon "warning" :data-confirm-confirm-text "Yes, remove it"
+ :data-confirm-cancel-text "Cancel" :data-confirm-event "confirmed"
+ :sx-post toggle-url :sx-trigger "confirmed"
+ :sx-target "#associated-entries-list" :sx-swap "outerHTML"
+ :sx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}")
+ :sx-on:afterSwap "document.body.dispatchEvent(new CustomEvent('entryToggled'))"
+ (i :class "fa fa-times"))))
+
+(defcomp ~blog-cal-entry-unassociated (&key name toggle-url csrf)
+ (button :type "button"
+ :class "w-full text-left text-[10px] rounded px-1 py-0.5 bg-stone-100 text-stone-700 hover:bg-stone-200"
+ :data-confirm "" :data-confirm-title "Add entry?"
+ :data-confirm-text (str "Add " name " to this post?")
+ :data-confirm-icon "question" :data-confirm-confirm-text "Yes, add it"
+ :data-confirm-cancel-text "Cancel" :data-confirm-event "confirmed"
+ :sx-post toggle-url :sx-trigger "confirmed"
+ :sx-target "#associated-entries-list" :sx-swap "outerHTML"
+ :sx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}")
+ :sx-on:afterSwap "document.body.dispatchEvent(new CustomEvent('entryToggled'))"
+ (span :class "truncate block" name)))
+
+(defcomp ~blog-calendar-view (&key cal-id year month-name
+ current-url prev-month-url prev-year-url
+ next-month-url next-year-url
+ weekday-names days csrf)
+ (let* ((target (str "#calendar-view-" cal-id)))
+ (div :id (str "calendar-view-" cal-id)
+ :sx-get current-url :sx-trigger "entryToggled from:body" :sx-swap "outerHTML"
+ (header :class "flex items-center justify-center mb-4"
+ (nav :class "flex items-center gap-2 text-xl"
+ (a :class "px-2 py-1 hover:bg-stone-100 rounded"
+ :sx-get prev-year-url :sx-target target :sx-swap "outerHTML"
+ (raw! "«"))
+ (a :class "px-2 py-1 hover:bg-stone-100 rounded"
+ :sx-get prev-month-url :sx-target target :sx-swap "outerHTML"
+ (raw! "‹"))
+ (div :class "px-3 font-medium" (str month-name " " year))
+ (a :class "px-2 py-1 hover:bg-stone-100 rounded"
+ :sx-get next-month-url :sx-target target :sx-swap "outerHTML"
+ (raw! "›"))
+ (a :class "px-2 py-1 hover:bg-stone-100 rounded"
+ :sx-get next-year-url :sx-target target :sx-swap "outerHTML"
+ (raw! "»"))))
+ (div :class "rounded border bg-white"
+ (div :class "hidden sm:grid grid-cols-7 text-center text-xs font-semibold text-stone-700 bg-stone-50 border-b"
+ (map (lambda (wd) (div :class "py-2" wd)) (or weekday-names (list))))
+ (div :class "grid grid-cols-1 sm:grid-cols-7 gap-px bg-stone-200"
+ (map (lambda (day)
+ (let* ((extra-cls (if (get day "in_month") "" " bg-stone-50 text-stone-400"))
+ (entries (or (get day "entries") (list))))
+ (div :class (str "min-h-20 bg-white px-2 py-2 text-xs" extra-cls)
+ (div :class "font-medium mb-1" (str (get day "day")))
+ (when (not (empty? entries))
+ (div :class "space-y-0.5"
+ (map (lambda (e)
+ (if (get e "is_associated")
+ (~blog-cal-entry-associated
+ :name (get e "name") :toggle-url (get e "toggle_url") :csrf csrf)
+ (~blog-cal-entry-unassociated
+ :name (get e "name") :toggle-url (get e "toggle_url") :csrf csrf)))
+ entries))))))
+ (or days (list))))))))
+
+;; ---------------------------------------------------------------------------
+;; Nav entries OOB — renders associated entry/calendar items in scroll wrapper (B2)
+;; ---------------------------------------------------------------------------
+
+(defcomp ~blog-nav-entries-oob (&key entries calendars)
+ (let* ((entry-list (or entries (list)))
+ (cal-list (or calendars (list)))
+ (has-items (or (not (empty? entry-list)) (not (empty? cal-list))))
+ (nav-cls "justify-center cursor-pointer flex flex-row items-center gap-2 rounded bg-stone-200 text-black [.hover-capable_&]:hover:bg-yellow-300 aria-selected:bg-stone-500 aria-selected:text-white [.hover-capable_&[aria-selected=true]:hover]:bg-orange-500 p-2")
+ (scroll-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"))
+ (if (not has-items)
+ (~blog-nav-entries-empty)
+ (~scroll-nav-wrapper
+ :wrapper-id "entries-calendars-nav-wrapper"
+ :container-id "associated-items-container"
+ :arrow-cls "entries-nav-arrow"
+ :left-hs "on click set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft - 200"
+ :scroll-hs scroll-hs
+ :right-hs "on click set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft + 200"
+ :items (<>
+ (map (lambda (e)
+ (~calendar-entry-nav
+ :href (get e "href") :nav-class nav-cls
+ :name (get e "name") :date-str (get e "date_str")))
+ entry-list)
+ (map (lambda (c)
+ (~blog-nav-calendar-item
+ :href (get c "href") :nav-cls nav-cls
+ :name (get c "name")))
+ cal-list))
+ :oob true))))
diff --git a/events/sx/calendar.sx b/events/sx/calendar.sx
index c957092..d4c0dfc 100644
--- a/events/sx/calendar.sx
+++ b/events/sx/calendar.sx
@@ -36,6 +36,36 @@
(div :class "hidden sm:grid grid-cols-7 text-center text-md font-semibold text-stone-700 mb-2" weekdays)
(div :class "grid grid-cols-1 sm:grid-cols-7 gap-px bg-stone-200 rounded-xl overflow-hidden" cells))))
+;; Calendar grid from data — all iteration in sx
+(defcomp ~events-calendar-grid-from-data (&key pill-cls month-name year
+ prev-year-href prev-month-href
+ next-month-href next-year-href
+ weekday-names cells)
+ (~events-calendar-grid
+ :arrows (<>
+ (~events-calendar-nav-arrow :pill-cls pill-cls :href prev-year-href :label "\u00ab")
+ (~events-calendar-nav-arrow :pill-cls pill-cls :href prev-month-href :label "\u2039")
+ (~events-calendar-month-label :month-name month-name :year year)
+ (~events-calendar-nav-arrow :pill-cls pill-cls :href next-month-href :label "\u203a")
+ (~events-calendar-nav-arrow :pill-cls pill-cls :href next-year-href :label "\u00bb"))
+ :weekdays (<> (map (lambda (wd) (~events-calendar-weekday :name wd))
+ (or weekday-names (list))))
+ :cells (<> (map (lambda (cell)
+ (~events-calendar-cell
+ :cell-cls (get cell "cell-cls")
+ :day-short (when (get cell "day-str")
+ (~events-calendar-day-short :day-str (get cell "day-str")))
+ :day-num (when (get cell "day-href")
+ (~events-calendar-day-num :pill-cls pill-cls
+ :href (get cell "day-href") :num (get cell "day-num")))
+ :badges (when (get cell "badges")
+ (<> (map (lambda (b)
+ (~events-calendar-entry-badge
+ :bg-cls (get b "bg-cls") :name (get b "name")
+ :state-label (get b "state-label")))
+ (get cell "badges"))))))
+ (or cells (list))))))
+
(defcomp ~events-calendar-description-display (&key description edit-url)
(div :id "calendar-description"
(if description
diff --git a/events/sx/day.sx b/events/sx/day.sx
index 7c3056e..a8898d6 100644
--- a/events/sx/day.sx
+++ b/events/sx/day.sx
@@ -82,3 +82,42 @@
(div :class "flex-1 min-w-0"
(div :class "font-medium truncate" name)
(div :class "text-xs text-stone-600 truncate" time-str))))
+
+;; Day table from data — all row iteration in sx
+(defcomp ~events-day-table-from-data (&key list-container pre-action add-url tr-cls pill-cls rows)
+ (~events-day-table
+ :list-container list-container
+ :rows (if (empty? (or rows (list)))
+ (~events-day-empty-row)
+ (<> (map (lambda (r)
+ (~events-day-row
+ :tr-cls tr-cls
+ :name (~events-day-row-name
+ :href (get r "href") :pill-cls pill-cls :name (get r "name"))
+ :slot (if (get r "slot-name")
+ (~events-day-row-slot
+ :href (get r "slot-href") :pill-cls pill-cls
+ :slot-name (get r "slot-name") :time-str (get r "slot-time"))
+ (~events-day-row-time :start (get r "start") :end (get r "end")))
+ :state (~events-day-row-state
+ :state-id (get r "state-id")
+ :badge (~entry-state-badge :state (get r "state")))
+ :cost (~events-day-row-cost :cost-str (get r "cost-str"))
+ :tickets (if (get r "has-tickets")
+ (~events-day-row-tickets
+ :price-str (get r "price-str") :count-str (get r "count-str"))
+ (~events-day-row-no-tickets))
+ :actions (~events-day-row-actions)))
+ (or rows (list)))))
+ :pre-action pre-action :add-url add-url))
+
+;; Day entries nav OOB from data
+(defcomp ~events-day-entries-nav-oob-from-data (&key nav-btn entries)
+ (if (empty? (or entries (list)))
+ (~events-day-entries-nav-oob-empty)
+ (~events-day-entries-nav-oob
+ :items (<> (map (lambda (e)
+ (~events-day-nav-entry
+ :href (get e "href") :nav-btn nav-btn
+ :name (get e "name") :time-str (get e "time-str")))
+ entries)))))
diff --git a/events/sx/entries.sx b/events/sx/entries.sx
index 44208e6..4c2d1e5 100644
--- a/events/sx/entries.sx
+++ b/events/sx/entries.sx
@@ -1,5 +1,78 @@
;; Events entry card components (all events / page summary)
+;; ---------------------------------------------------------------------------
+;; State badges — cond maps state string to class + label
+;; ---------------------------------------------------------------------------
+
+(defcomp ~entry-state-badge (&key state)
+ (~badge
+ :cls (cond
+ ((= state "confirmed") "bg-emerald-100 text-emerald-800")
+ ((= state "provisional") "bg-amber-100 text-amber-800")
+ ((= state "ordered") "bg-sky-100 text-sky-800")
+ ((= state "pending") "bg-stone-100 text-stone-700")
+ ((= state "declined") "bg-red-100 text-red-800")
+ (true "bg-stone-100 text-stone-700"))
+ :label (cond
+ ((= state "confirmed") "Confirmed")
+ ((= state "provisional") "Provisional")
+ ((= state "ordered") "Ordered")
+ ((= state "pending") "Pending")
+ ((= state "declined") "Declined")
+ (true (or state "Unknown")))))
+
+(defcomp ~entry-state-badge-lg (&key state)
+ (span :class (str "inline-flex items-center rounded-full px-3 py-1 text-sm font-medium "
+ (cond
+ ((= state "confirmed") "bg-emerald-100 text-emerald-800")
+ ((= state "provisional") "bg-amber-100 text-amber-800")
+ ((= state "ordered") "bg-sky-100 text-sky-800")
+ ((= state "pending") "bg-stone-100 text-stone-700")
+ ((= state "declined") "bg-red-100 text-red-800")
+ (true "bg-stone-100 text-stone-700")))
+ (cond
+ ((= state "confirmed") "Confirmed")
+ ((= state "provisional") "Provisional")
+ ((= state "ordered") "Ordered")
+ ((= state "pending") "Pending")
+ ((= state "declined") "Declined")
+ (true (or state "Unknown")))))
+
+(defcomp ~ticket-state-badge (&key state)
+ (~badge
+ :cls (cond
+ ((= state "confirmed") "bg-emerald-100 text-emerald-800")
+ ((= state "checked_in") "bg-blue-100 text-blue-800")
+ ((= state "reserved") "bg-amber-100 text-amber-800")
+ ((= state "cancelled") "bg-red-100 text-red-800")
+ (true "bg-stone-100 text-stone-700"))
+ :label (cond
+ ((= state "confirmed") "Confirmed")
+ ((= state "checked_in") "Checked in")
+ ((= state "reserved") "Reserved")
+ ((= state "cancelled") "Cancelled")
+ (true (or state "Unknown")))))
+
+(defcomp ~ticket-state-badge-lg (&key state)
+ (span :class (str "inline-flex items-center rounded-full px-3 py-1 text-sm font-medium "
+ (cond
+ ((= state "confirmed") "bg-emerald-100 text-emerald-800")
+ ((= state "checked_in") "bg-blue-100 text-blue-800")
+ ((= state "reserved") "bg-amber-100 text-amber-800")
+ ((= state "cancelled") "bg-red-100 text-red-800")
+ (true "bg-stone-100 text-stone-700")))
+ (cond
+ ((= state "confirmed") "Confirmed")
+ ((= state "checked_in") "Checked in")
+ ((= state "reserved") "Reserved")
+ ((= state "cancelled") "Cancelled")
+ (true (or state "Unknown")))))
+
+
+;; ---------------------------------------------------------------------------
+;; Entry card components
+;; ---------------------------------------------------------------------------
+
(defcomp ~events-entry-title-linked (&key href name)
(a :href href :class "hover:text-emerald-700"
(h2 :class "text-lg font-semibold text-stone-900" name)))
@@ -63,3 +136,127 @@
(defcomp ~events-main-panel-body (&key toggle body)
(<> toggle body (div :class "pb-8")))
+
+
+;; ---------------------------------------------------------------------------
+;; Composition defcomps — receive data, compose entry card trees
+;; ---------------------------------------------------------------------------
+
+;; Ticket widget from data — replaces _ticket_widget_html Python composition
+(defcomp ~events-tw-widget-from-data (&key entry-id price qty ticket-url csrf)
+ (~events-tw-widget :entry-id (str entry-id) :price price
+ :inner (if (= (or qty 0) 0)
+ (~events-tw-form :ticket-url ticket-url :target (str "#page-ticket-" entry-id)
+ :csrf csrf :entry-id (str entry-id) :count-val "1"
+ :btn (~events-tw-cart-plus))
+ (<>
+ (~events-tw-form :ticket-url ticket-url :target (str "#page-ticket-" entry-id)
+ :csrf csrf :entry-id (str entry-id) :count-val (str (- qty 1))
+ :btn (~events-tw-minus))
+ (~events-tw-cart-icon :qty (str qty))
+ (~events-tw-form :ticket-url ticket-url :target (str "#page-ticket-" entry-id)
+ :csrf csrf :entry-id (str entry-id) :count-val (str (+ qty 1))
+ :btn (~events-tw-plus))))))
+
+;; Entry card (list view) from data
+(defcomp ~events-entry-card-from-data (&key entry-href name day-href
+ page-badge-href page-badge-title cal-name
+ date-str start-time end-time is-page-scoped
+ cost has-ticket ticket-data)
+ (~events-entry-card
+ :title (if entry-href
+ (~events-entry-title-linked :href entry-href :name name)
+ (~events-entry-title-plain :name name))
+ :badges (<>
+ (when page-badge-title
+ (~events-entry-page-badge :href page-badge-href :title page-badge-title))
+ (when cal-name
+ (~events-entry-cal-badge :name cal-name)))
+ :time-parts (<>
+ (when (and day-href (not is-page-scoped))
+ (~events-entry-time-linked :href day-href :date-str date-str))
+ (when (and (not day-href) (not is-page-scoped) date-str)
+ (~events-entry-time-plain :date-str date-str))
+ start-time
+ (when end-time (str " \u2013 " end-time)))
+ :cost (when cost (~events-entry-cost :cost cost))
+ :widget (when has-ticket
+ (~events-entry-widget-wrapper
+ :widget (~events-tw-widget-from-data
+ :entry-id (get ticket-data "entry-id")
+ :price (get ticket-data "price")
+ :qty (get ticket-data "qty")
+ :ticket-url (get ticket-data "ticket-url")
+ :csrf (get ticket-data "csrf"))))))
+
+;; Entry card (tile view) from data
+(defcomp ~events-entry-card-tile-from-data (&key entry-href name day-href
+ page-badge-href page-badge-title cal-name
+ date-str time-str
+ cost has-ticket ticket-data)
+ (~events-entry-card-tile
+ :title (if entry-href
+ (~events-entry-title-tile-linked :href entry-href :name name)
+ (~events-entry-title-tile-plain :name name))
+ :badges (<>
+ (when page-badge-title
+ (~events-entry-page-badge :href page-badge-href :title page-badge-title))
+ (when cal-name
+ (~events-entry-cal-badge :name cal-name)))
+ :time time-str
+ :cost (when cost (~events-entry-cost :cost cost))
+ :widget (when has-ticket
+ (~events-entry-tile-widget-wrapper
+ :widget (~events-tw-widget-from-data
+ :entry-id (get ticket-data "entry-id")
+ :price (get ticket-data "price")
+ :qty (get ticket-data "qty")
+ :ticket-url (get ticket-data "ticket-url")
+ :csrf (get ticket-data "csrf"))))))
+
+;; Entry cards list (with date separators + sentinel) from data
+(defcomp ~events-entry-cards-from-data (&key items view page has-more next-url)
+ (<>
+ (map (lambda (item)
+ (if (get item "is-separator")
+ (~events-date-separator :date-str (get item "date-str"))
+ (if (= view "tile")
+ (~events-entry-card-tile-from-data
+ :entry-href (get item "entry-href") :name (get item "name")
+ :day-href (get item "day-href")
+ :page-badge-href (get item "page-badge-href")
+ :page-badge-title (get item "page-badge-title")
+ :cal-name (get item "cal-name")
+ :date-str (get item "date-str") :time-str (get item "time-str")
+ :cost (get item "cost") :has-ticket (get item "has-ticket")
+ :ticket-data (get item "ticket-data"))
+ (~events-entry-card-from-data
+ :entry-href (get item "entry-href") :name (get item "name")
+ :day-href (get item "day-href")
+ :page-badge-href (get item "page-badge-href")
+ :page-badge-title (get item "page-badge-title")
+ :cal-name (get item "cal-name")
+ :date-str (get item "date-str")
+ :start-time (get item "start-time") :end-time (get item "end-time")
+ :is-page-scoped (get item "is-page-scoped")
+ :cost (get item "cost") :has-ticket (get item "has-ticket")
+ :ticket-data (get item "ticket-data")))))
+ (or items (list)))
+ (when has-more
+ (~sentinel-simple :id (str "sentinel-" page) :next-url next-url))))
+
+;; Events main panel (toggle + cards grid) from data
+(defcomp ~events-main-panel-from-data (&key toggle items view page has-more next-url)
+ (~events-main-panel-body
+ :toggle toggle
+ :body (if items
+ (~events-grid
+ :grid-cls (if (= view "tile")
+ "max-w-full px-3 py-3 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4"
+ "max-w-full px-3 py-3 space-y-3")
+ :cards (~events-entry-cards-from-data
+ :items items :view view :page page
+ :has-more has-more :next-url next-url))
+ (~empty-state :icon "fa fa-calendar-xmark"
+ :message "No upcoming events"
+ :cls "px-3 py-12 text-center text-stone-400"))))
diff --git a/events/sx/forms.sx b/events/sx/forms.sx
index 806f87a..4e79700 100644
--- a/events/sx/forms.sx
+++ b/events/sx/forms.sx
@@ -318,6 +318,64 @@
"+ Add slot"))
+;; ---------------------------------------------------------------------------
+;; Composition defcomps — receive data, compose form trees
+;; ---------------------------------------------------------------------------
+
+;; Day checkboxes from data — replaces Python loop
+(defcomp ~events-day-checkboxes-from-data (&key days-data all-checked)
+ (<>
+ (~events-day-all-checkbox :checked (when all-checked "checked"))
+ (map (lambda (d)
+ (~events-day-checkbox
+ :name (get d "name")
+ :label (get d "label")
+ :checked (when (get d "checked") "checked")))
+ (or days-data (list)))))
+
+;; Slot options from data — replaces _slot_options_html Python loop
+(defcomp ~events-slot-options-from-data (&key slots)
+ (<> (map (lambda (s)
+ (~events-slot-option
+ :value (get s "value")
+ :data-start (get s "data-start")
+ :data-end (get s "data-end")
+ :data-flexible (get s "data-flexible")
+ :data-cost (get s "data-cost")
+ :selected (get s "selected")
+ :label (get s "label")))
+ (or slots (list)))))
+
+;; Slot picker from data — wraps picker + options
+(defcomp ~events-slot-picker-from-data (&key id slots)
+ (if (empty? (or slots (list)))
+ (~events-no-slots)
+ (~events-slot-picker
+ :id id
+ :options (~events-slot-options-from-data :slots slots))))
+
+;; Slot edit form from data
+(defcomp ~events-slot-edit-form-from-data (&key slot-id list-container put-url cancel-url csrf
+ name-val cost-val start-val end-val desc-val
+ days-data all-checked flexible-checked
+ action-btn cancel-btn)
+ (~events-slot-edit-form
+ :slot-id slot-id :list-container list-container
+ :put-url put-url :cancel-url cancel-url :csrf csrf
+ :name-val name-val :cost-val cost-val :start-val start-val
+ :end-val end-val :desc-val desc-val
+ :days (~events-day-checkboxes-from-data :days-data days-data :all-checked all-checked)
+ :flexible-checked flexible-checked
+ :action-btn action-btn :cancel-btn cancel-btn))
+
+;; Slot add form from data
+(defcomp ~events-slot-add-form-from-data (&key post-url csrf days-data action-btn cancel-btn cancel-url)
+ (~events-slot-add-form
+ :post-url post-url :csrf csrf
+ :days (~events-day-checkboxes-from-data :days-data days-data)
+ :action-btn action-btn :cancel-btn cancel-btn :cancel-url cancel-url))
+
+
;; ---------------------------------------------------------------------------
;; Entry add form (_types/day/_add.html)
;; ---------------------------------------------------------------------------
diff --git a/events/sx/fragments.sx b/events/sx/fragments.sx
index 3352aa0..1de6cd6 100644
--- a/events/sx/fragments.sx
+++ b/events/sx/fragments.sx
@@ -68,3 +68,65 @@
(defcomp ~events-frag-bookings-list (&key items)
(div :class "divide-y divide-stone-100" items))
+
+
+;; ---------------------------------------------------------------------------
+;; From-data defcomps — iteration in sx
+;; ---------------------------------------------------------------------------
+
+;; Container cards: list of widgets, each with entries
+(defcomp ~events-frag-container-cards-from-data (&key widgets)
+ (<> (map (lambda (w)
+ (if (get w "entries")
+ (~events-frag-entries-widget
+ :cards (<> (map (lambda (e)
+ (~events-frag-entry-card
+ :href (get e "href") :name (get e "name")
+ :date-str (get e "date-str") :time-str (get e "time-str")))
+ (get w "entries"))))
+ ""))
+ (or widgets (list)))))
+
+;; Ticket item from data — composes badge + optional spans
+(defcomp ~events-frag-ticket-item-from-data (&key href entry-name date-str calendar-name type-name state)
+ (~events-frag-ticket-item
+ :href href :entry-name entry-name :date-str date-str
+ :calendar-name (when calendar-name (span "\u00b7 " calendar-name))
+ :type-name (when type-name (span "\u00b7 " type-name))
+ :badge (~status-pill :status state)))
+
+;; Tickets panel from data — full panel with list iteration
+(defcomp ~events-frag-tickets-panel-from-data (&key tickets)
+ (~events-frag-tickets-panel
+ :items (if (empty? (or tickets (list)))
+ (~empty-state :message "No tickets yet." :cls "text-sm text-stone-500")
+ (~events-frag-tickets-list
+ :items (<> (map (lambda (t)
+ (~events-frag-ticket-item-from-data
+ :href (get t "href") :entry-name (get t "entry-name")
+ :date-str (get t "date-str") :calendar-name (get t "calendar-name")
+ :type-name (get t "type-name") :state (get t "state")))
+ tickets))))))
+
+;; Booking item from data — composes badge + optional spans
+(defcomp ~events-frag-booking-item-from-data (&key name date-str end-time calendar-name cost-str state)
+ (~events-frag-booking-item
+ :name name
+ :date-str (<> date-str (when end-time (span "\u2013 " end-time)))
+ :calendar-name (when calendar-name (span "\u00b7 " calendar-name))
+ :cost-str (when cost-str (span "\u00b7 \u00a3" cost-str))
+ :badge (~status-pill :status state)))
+
+;; Bookings panel from data — full panel with list iteration
+(defcomp ~events-frag-bookings-panel-from-data (&key bookings)
+ (~events-frag-bookings-panel
+ :items (if (empty? (or bookings (list)))
+ (~empty-state :message "No bookings yet." :cls "text-sm text-stone-500")
+ (~events-frag-bookings-list
+ :items (<> (map (lambda (b)
+ (~events-frag-booking-item-from-data
+ :href (get b "href") :name (get b "name")
+ :date-str (get b "date-str") :end-time (get b "end-time")
+ :calendar-name (get b "calendar-name") :cost-str (get b "cost-str")
+ :state (get b "state")))
+ bookings))))))
diff --git a/events/sx/layouts.sx b/events/sx/layouts.sx
index a97faf9..646e429 100644
--- a/events/sx/layouts.sx
+++ b/events/sx/layouts.sx
@@ -226,6 +226,54 @@
(~clear-oob-div :id "calendars-row")
(~clear-oob-div :id "calendars-header-child")))
+;; ---------------------------------------------------------------------------
+;; OOB clear helpers for renders.py — clear all deeper IDs except kept ones
+;; ---------------------------------------------------------------------------
+
+(defcomp ~events-clear-deeper-post ()
+ "Clear all events IDs deeper than post level."
+ (<>
+ (~clear-oob-div :id "entry-admin-row") (~clear-oob-div :id "entry-admin-header-child")
+ (~clear-oob-div :id "entry-row") (~clear-oob-div :id "entry-header-child")
+ (~clear-oob-div :id "day-admin-row") (~clear-oob-div :id "day-admin-header-child")
+ (~clear-oob-div :id "day-row") (~clear-oob-div :id "day-header-child")
+ (~clear-oob-div :id "calendar-admin-row") (~clear-oob-div :id "calendar-admin-header-child")
+ (~clear-oob-div :id "calendar-row") (~clear-oob-div :id "calendar-header-child")
+ (~clear-oob-div :id "calendars-row") (~clear-oob-div :id "calendars-header-child")
+ (~clear-oob-div :id "post-admin-row") (~clear-oob-div :id "post-admin-header-child")))
+
+(defcomp ~events-clear-deeper-post-admin ()
+ "Clear all events IDs deeper than post-admin level."
+ (<>
+ (~clear-oob-div :id "entry-admin-row") (~clear-oob-div :id "entry-admin-header-child")
+ (~clear-oob-div :id "entry-row") (~clear-oob-div :id "entry-header-child")
+ (~clear-oob-div :id "day-admin-row") (~clear-oob-div :id "day-admin-header-child")
+ (~clear-oob-div :id "day-row") (~clear-oob-div :id "day-header-child")
+ (~clear-oob-div :id "calendar-admin-row") (~clear-oob-div :id "calendar-admin-header-child")
+ (~clear-oob-div :id "calendar-row") (~clear-oob-div :id "calendar-header-child")
+ (~clear-oob-div :id "calendars-row") (~clear-oob-div :id "calendars-header-child")))
+
+(defcomp ~events-clear-deeper-calendar ()
+ "Clear all events IDs deeper than calendar level."
+ (<>
+ (~clear-oob-div :id "entry-admin-row") (~clear-oob-div :id "entry-admin-header-child")
+ (~clear-oob-div :id "entry-row") (~clear-oob-div :id "entry-header-child")
+ (~clear-oob-div :id "day-admin-row") (~clear-oob-div :id "day-admin-header-child")
+ (~clear-oob-div :id "day-row") (~clear-oob-div :id "day-header-child")
+ (~clear-oob-div :id "calendar-admin-row") (~clear-oob-div :id "calendar-admin-header-child")
+ (~clear-oob-div :id "calendars-row") (~clear-oob-div :id "calendars-header-child")
+ (~clear-oob-div :id "post-admin-row") (~clear-oob-div :id "post-admin-header-child")))
+
+(defcomp ~events-clear-deeper-day ()
+ "Clear all events IDs deeper than day level."
+ (<>
+ (~clear-oob-div :id "entry-admin-row") (~clear-oob-div :id "entry-admin-header-child")
+ (~clear-oob-div :id "entry-row") (~clear-oob-div :id "entry-header-child")
+ (~clear-oob-div :id "day-admin-row") (~clear-oob-div :id "day-admin-header-child")
+ (~clear-oob-div :id "calendar-admin-row") (~clear-oob-div :id "calendar-admin-header-child")
+ (~clear-oob-div :id "calendars-row") (~clear-oob-div :id "calendars-header-child")
+ (~clear-oob-div :id "post-admin-row") (~clear-oob-div :id "post-admin-header-child")))
+
;; ---------------------------------------------------------------------------
;; Calendar admin layout: root + post + child(post-admin + cal + cal-admin)
;; ---------------------------------------------------------------------------
diff --git a/events/sx/page.sx b/events/sx/page.sx
index 0eb1367..b163c01 100644
--- a/events/sx/page.sx
+++ b/events/sx/page.sx
@@ -73,6 +73,50 @@
:sx-get add-url :sx-target "#slot-add-container" :sx-swap "innerHTML"
"+ Add slot"))))
+;; ---------------------------------------------------------------------------
+;; Composition defcomps — receive data, compose slot/table trees
+;; ---------------------------------------------------------------------------
+
+;; Days pills from data — replaces Python loop
+(defcomp ~events-days-pills-from-data (&key days)
+ (if (empty? (or days (list)))
+ (~events-slot-no-days)
+ (~events-slot-days-pills
+ :days-inner (<> (map (lambda (d) (~events-slot-day-pill :day d)) days)))))
+
+;; Slot panel from data
+(defcomp ~events-slot-panel-from-data (&key slot-id list-container days
+ flexible time-str cost-str
+ pre-action edit-url description oob)
+ (<>
+ (~events-slot-panel
+ :slot-id slot-id :list-container list-container
+ :days (~events-days-pills-from-data :days days)
+ :flexible flexible :time-str time-str :cost-str cost-str
+ :pre-action pre-action :edit-url edit-url)
+ (when oob
+ (~events-slot-description-oob :description (or description "")))))
+
+;; Slots table from data
+(defcomp ~events-slots-table-from-data (&key list-container slots pre-action add-url
+ tr-cls pill-cls action-btn hx-select csrf-hdr)
+ (~events-slots-table
+ :list-container list-container
+ :rows (if (empty? (or slots (list)))
+ (~events-slots-empty-row)
+ (<> (map (lambda (s)
+ (~events-slots-row
+ :tr-cls tr-cls :slot-href (get s "slot-href")
+ :pill-cls pill-cls :hx-select hx-select
+ :slot-name (get s "slot-name") :description (get s "description")
+ :flexible (get s "flexible")
+ :days (~events-days-pills-from-data :days (get s "days"))
+ :time-str (get s "time-str")
+ :cost-str (get s "cost-str") :action-btn action-btn
+ :del-url (get s "del-url") :csrf-hdr csrf-hdr))
+ (or slots (list)))))
+ :pre-action pre-action :add-url add-url))
+
(defcomp ~events-ticket-type-col (&key label value)
(div :class "flex flex-col"
(div :class "text-xs font-semibold uppercase tracking-wide text-stone-500" label)
@@ -158,102 +202,138 @@
(button :type "button" :class "px-4 py-2 bg-stone-200 text-stone-700 rounded hover:bg-stone-300 text-sm"
:onclick hide-js "Cancel"))))
+;; Data-driven buy form — Python passes pre-resolved data, .sx does layout + iteration
+(defcomp ~events-buy-form (&key entry-id info-sold info-remaining info-basket
+ ticket-types user-ticket-counts-by-type
+ user-ticket-count price-str adjust-url csrf state
+ my-tickets-href)
+ (if (!= state "confirmed")
+ (~events-buy-not-confirmed :entry-id (str entry-id))
+ (let ((eid-s (str entry-id))
+ (target (str "#ticket-buy-" entry-id)))
+ (div :id (str "ticket-buy-" entry-id) :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")
+ ;; Info bar
+ (when (or info-sold info-remaining info-basket)
+ (div :class "flex items-center gap-3 mb-3 text-xs text-stone-500"
+ (when info-sold (span (str info-sold " sold")))
+ (when info-remaining (span (str info-remaining " remaining")))
+ (when info-basket
+ (span :class "text-emerald-600 font-medium"
+ (i :class "fa fa-shopping-cart text-[0.6rem]" :aria-hidden "true")
+ (str " " info-basket " in basket")))))
+ ;; Body — multi-type or default
+ (if (and ticket-types (not (empty? ticket-types)))
+ (div :class "space-y-2"
+ (map (fn (tt)
+ (let ((tt-count (if user-ticket-counts-by-type
+ (get user-ticket-counts-by-type (str (get tt "id")) 0)
+ 0))
+ (tt-id (get tt "id")))
+ (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" (get tt "name"))
+ (div :class "text-xs text-stone-500" (get tt "cost_str")))
+ (~events-adjust-inline :csrf csrf :adjust-url adjust-url :target target
+ :entry-id eid-s :count tt-count :ticket-type-id tt-id
+ :my-tickets-href my-tickets-href))))
+ ticket-types))
+ (<> (div :class "flex items-center justify-between mb-4"
+ (div (span :class "font-medium text-green-600" price-str)
+ (span :class "text-sm text-stone-500 ml-2" "per ticket")))
+ (~events-adjust-inline :csrf csrf :adjust-url adjust-url :target target
+ :entry-id eid-s :count (if user-ticket-count user-ticket-count 0)
+ :ticket-type-id nil :my-tickets-href my-tickets-href)))))))
+
+;; Inline +/- controls (used by both default and per-type)
+(defcomp ~events-adjust-inline (&key csrf adjust-url target entry-id count ticket-type-id my-tickets-href)
+ (if (= count 0)
+ (form :sx-post adjust-url :sx-target target :sx-swap "outerHTML" :class "flex items-center"
+ (input :type "hidden" :name "csrf_token" :value csrf)
+ (input :type "hidden" :name "entry_id" :value entry-id)
+ (when ticket-type-id (input :type "hidden" :name "ticket_type_id" :value (str ticket-type-id)))
+ (input :type "hidden" :name "count" :value "1")
+ (button :type "submit"
+ :class "relative inline-flex items-center justify-center text-sm font-medium text-stone-500 hover:bg-emerald-50 rounded p-1"
+ (i :class "fa fa-cart-plus text-2xl" :aria-hidden "true")))
+ (div :class "flex items-center gap-2"
+ (form :sx-post adjust-url :sx-target target :sx-swap "outerHTML"
+ (input :type "hidden" :name "csrf_token" :value csrf)
+ (input :type "hidden" :name "entry_id" :value entry-id)
+ (when ticket-type-id (input :type "hidden" :name "ticket_type_id" :value (str ticket-type-id)))
+ (input :type "hidden" :name "count" :value (str (- count 1)))
+ (button :type "submit"
+ :class "inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl"
+ "-"))
+ (a :class "relative inline-flex items-center justify-center text-emerald-700" :href my-tickets-href
+ (span :class "relative inline-flex items-center justify-center"
+ (i :class "fa-solid fa-shopping-cart text-2xl" :aria-hidden "true")
+ (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" (str count)))))
+ (form :sx-post adjust-url :sx-target target :sx-swap "outerHTML"
+ (input :type "hidden" :name "csrf_token" :value csrf)
+ (input :type "hidden" :name "entry_id" :value entry-id)
+ (when ticket-type-id (input :type "hidden" :name "ticket_type_id" :value (str ticket-type-id)))
+ (input :type "hidden" :name "count" :value (str (+ count 1)))
+ (button :type "submit"
+ :class "inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl"
+ "+")))))
+
(defcomp ~events-buy-not-confirmed (&key entry-id)
(div :id (str "ticket-buy-" entry-id) :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."))
-(defcomp ~events-buy-info-sold (&key count)
- (span (str count " sold")))
-(defcomp ~events-buy-info-remaining (&key count)
- (span (str count " remaining")))
+(defcomp ~events-buy-result (&key entry-id tickets remaining my-tickets-href)
+ (let ((count (len tickets))
+ (suffix (if (= count 1) "" "s")))
+ (div :id (str "ticket-buy-" entry-id) :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" (str count " ticket" suffix " reserved")))
+ (div :class "space-y-2 mb-4"
+ (map (fn (t)
+ (a :href (get t "href") :class "flex items-center justify-between p-2 rounded-lg bg-white border border-emerald-100 hover:border-emerald-300 transition text-sm"
+ (div :class "flex items-center gap-2"
+ (i :class "fa fa-ticket text-emerald-500" :aria-hidden "true")
+ (span :class "font-mono text-xs text-stone-500" (get t "code_short")))
+ (span :class "text-xs text-emerald-600 font-medium" "View ticket")))
+ tickets))
+ (when (not (nil? remaining))
+ (let ((r-suffix (if (= remaining 1) "" "s")))
+ (p :class "text-xs text-stone-500" (str remaining " ticket" r-suffix " remaining"))))
+ (div :class "mt-3 flex gap-2"
+ (a :href my-tickets-href :class "text-sm text-emerald-700 hover:text-emerald-900 underline"
+ "View all my tickets")))))
-(defcomp ~events-buy-info-basket (&key count)
- (span :class "text-emerald-600 font-medium"
- (i :class "fa fa-shopping-cart text-[0.6rem]" :aria-hidden "true")
- (str " " count " in basket")))
+;; Single response wrappers for POST routes (include OOB cart icon)
+(defcomp ~events-buy-response (&key entry-id tickets remaining my-tickets-href
+ cart-count blog-href cart-href logo)
+ (<>
+ (~events-cart-icon :cart-count cart-count :blog-href blog-href :cart-href cart-href :logo logo)
+ (~events-buy-result :entry-id entry-id :tickets tickets :remaining remaining
+ :my-tickets-href my-tickets-href)))
-(defcomp ~events-buy-info-bar (&key items)
- (div :class "flex items-center gap-3 mb-3 text-xs text-stone-500" items))
+(defcomp ~events-adjust-response (&key cart-count blog-href cart-href logo
+ entry-id info-sold info-remaining info-basket
+ ticket-types user-ticket-counts-by-type
+ user-ticket-count price-str adjust-url csrf state
+ my-tickets-href)
+ (<>
+ (~events-cart-icon :cart-count cart-count :blog-href blog-href :cart-href cart-href :logo logo)
+ (~events-buy-form :entry-id entry-id :info-sold info-sold :info-remaining info-remaining
+ :info-basket info-basket :ticket-types ticket-types
+ :user-ticket-counts-by-type user-ticket-counts-by-type
+ :user-ticket-count user-ticket-count :price-str price-str
+ :adjust-url adjust-url :csrf csrf :state state
+ :my-tickets-href my-tickets-href)))
-(defcomp ~events-buy-type-item (&key type-name cost-str adjust-controls)
- (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" type-name)
- (div :class "text-xs text-stone-500" cost-str))
- adjust-controls))
-
-(defcomp ~events-buy-types-wrapper (&key items)
- (div :class "space-y-2" items))
-
-(defcomp ~events-buy-default (&key price-str adjust-controls)
- (<> (div :class "flex items-center justify-between mb-4"
- (div (span :class "font-medium text-green-600" price-str)
- (span :class "text-sm text-stone-500 ml-2" "per ticket")))
- adjust-controls))
-
-(defcomp ~events-buy-panel (&key entry-id info body)
- (div :id (str "ticket-buy-" entry-id) :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")
- info body))
-
-(defcomp ~events-adjust-form (&key adjust-url target extra-cls csrf entry-id tt count-val btn)
- (form :sx-post adjust-url :sx-target target :sx-swap "outerHTML" :class extra-cls
- (input :type "hidden" :name "csrf_token" :value csrf)
- (input :type "hidden" :name "entry_id" :value entry-id)
- tt
- (input :type "hidden" :name "count" :value count-val)
- btn))
-
-(defcomp ~events-adjust-tt-hidden (&key ticket-type-id)
- (input :type "hidden" :name "ticket_type_id" :value ticket-type-id))
-
-(defcomp ~events-adjust-cart-plus ()
- (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")))
-
-(defcomp ~events-adjust-minus ()
- (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"
- "-"))
-
-(defcomp ~events-adjust-plus ()
- (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"
- "+"))
-
-(defcomp ~events-adjust-cart-icon (&key href count)
- (a :class "relative inline-flex items-center justify-center text-emerald-700" :href href
- (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" count)))))
-
-(defcomp ~events-adjust-controls (&key minus cart-icon plus)
- (div :class "flex items-center gap-2" minus cart-icon plus))
-
-(defcomp ~events-buy-result (&key entry-id count-label tickets remaining my-tickets-href)
- (div :id (str "ticket-buy-" entry-id) :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" count-label))
- (div :class "space-y-2 mb-4" tickets)
- remaining
- (div :class "mt-3 flex gap-2"
- (a :href my-tickets-href :class "text-sm text-emerald-700 hover:text-emerald-900 underline"
- "View all my tickets"))))
-
-(defcomp ~events-buy-result-ticket (&key href code-short)
- (a :href href :class "flex items-center justify-between p-2 rounded-lg bg-white border border-emerald-100 hover:border-emerald-300 transition text-sm"
- (div :class "flex items-center gap-2"
- (i :class "fa fa-ticket text-emerald-500" :aria-hidden "true")
- (span :class "font-mono text-xs text-stone-500" code-short))
- (span :class "text-xs text-emerald-600 font-medium" "View ticket")))
-
-(defcomp ~events-buy-result-remaining (&key text)
- (p :class "text-xs text-stone-500" text))
+;; Unified OOB cart icon — picks logo or badge based on count
+(defcomp ~events-cart-icon (&key cart-count blog-href cart-href logo)
+ (if (= cart-count 0)
+ (~events-cart-icon-logo :blog-href blog-href :logo logo)
+ (~events-cart-icon-badge :cart-href cart-href :count (str cart-count))))
(defcomp ~events-cart-icon-logo (&key blog-href logo)
(div :id "cart-mini" :sx-swap-oob "true"
@@ -384,3 +464,169 @@
(defcomp ~events-entry-nav-post-link (&key href img title)
(a :href href :class "flex items-center gap-2 px-3 py-2 hover:bg-stone-100 rounded transition text-sm border sm:whitespace-nowrap sm:flex-shrink-0"
img (div :class "flex-1 min-w-0" (div :class "font-medium truncate" title))))
+
+
+;; ---------------------------------------------------------------------------
+;; Composition defcomps — nav OOB, posts panel from data
+;; ---------------------------------------------------------------------------
+
+;; Post image helper from data
+(defcomp ~events-post-img-from-data (&key src alt)
+ (if src
+ (~events-post-img :src src :alt alt)
+ (~events-post-img-placeholder)))
+
+;; Entry posts nav OOB from data
+(defcomp ~events-entry-posts-nav-oob-from-data (&key nav-btn posts)
+ (if (empty? (or posts (list)))
+ (~events-entry-posts-nav-oob-empty)
+ (~events-entry-posts-nav-oob
+ :items (<> (map (lambda (p)
+ (~events-entry-nav-post
+ :href (get p "href") :nav-btn nav-btn
+ :img (~events-post-img-from-data :src (get p "img") :alt (get p "title"))
+ :title (get p "title")))
+ posts)))))
+
+;; Entry posts nav (non-OOB) from data — for desktop nav embedding
+(defcomp ~events-entry-posts-nav-inner-from-data (&key posts)
+ (when (not (empty? (or posts (list))))
+ (~events-entry-posts-nav-oob
+ :items (<> (map (lambda (p)
+ (~events-entry-nav-post-link
+ :href (get p "href")
+ :img (~events-post-img-from-data :src (get p "img") :alt (get p "title"))
+ :title (get p "title")))
+ posts)))))
+
+;; Post nav entries+calendars OOB from data
+(defcomp ~events-post-nav-wrapper-from-data (&key nav-btn entries calendars hyperscript)
+ (if (and (empty? (or entries (list))) (empty? (or calendars (list))))
+ (~events-post-nav-oob-empty)
+ (~events-post-nav-wrapper
+ :items (<>
+ (map (lambda (e)
+ (~events-post-nav-entry
+ :href (get e "href") :nav-btn nav-btn
+ :name (get e "name") :time-str (get e "time-str")))
+ (or entries (list)))
+ (map (lambda (c)
+ (~events-post-nav-calendar
+ :href (get c "href") :nav-btn nav-btn :name (get c "name")))
+ (or calendars (list))))
+ :hyperscript hyperscript)))
+
+;; Entry posts panel from data
+(defcomp ~events-entry-posts-panel-from-data (&key entry-id posts search-url)
+ (~events-entry-posts-panel
+ :posts (if (empty? (or posts (list)))
+ (~events-entry-posts-none)
+ (~events-entry-posts-list
+ :items (<> (map (lambda (p)
+ (~events-entry-post-item
+ :img (~events-post-img-from-data :src (get p "img") :alt (get p "title"))
+ :title (get p "title")
+ :del-url (get p "del-url") :entry-id entry-id
+ :csrf-hdr (get p "csrf-hdr")))
+ posts))))
+ :search-url search-url :entry-id entry-id))
+
+;; CRUD list/panel from data — shared by calendars + markets
+(defcomp ~events-crud-list-from-data (&key items empty-msg list-id)
+ (if (empty? (or items (list)))
+ (~empty-state :message empty-msg :cls "text-gray-500 mt-4")
+ (<> (map (lambda (item)
+ (~crud-item
+ :href (get item "href") :name (get item "name") :slug (get item "slug")
+ :del-url (get item "del-url") :csrf-hdr (get item "csrf-hdr")
+ :list-id list-id
+ :confirm-title (get item "confirm-title")
+ :confirm-text (get item "confirm-text")))
+ items))))
+
+(defcomp ~events-crud-panel-from-data (&key can-create create-url csrf errors-id list-id
+ placeholder btn-label items empty-msg)
+ (~crud-panel
+ :form (when can-create
+ (~crud-create-form
+ :create-url create-url :csrf csrf :errors-id errors-id
+ :list-id list-id :placeholder placeholder :btn-label btn-label))
+ :list (~events-crud-list-from-data :items items :empty-msg empty-msg :list-id list-id)
+ :list-id list-id))
+
+;; Post nav admin cog
+(defcomp ~events-post-nav-admin-cog (&key href aclass)
+ (div :class "relative nav-group"
+ (a :href href :class aclass
+ (i :class "fa fa-cog" :aria-hidden "true"))))
+
+;; Post nav from data — calendar links + container nav + admin
+(defcomp ~events-post-nav-from-data (&key calendars container-nav select-colours
+ has-admin admin-href aclass)
+ (<>
+ (map (lambda (c)
+ (~nav-link :href (get c "href") :icon "fa fa-calendar"
+ :label (get c "name") :select-colours select-colours
+ :is-selected (get c "is-selected")))
+ (or calendars (list)))
+ (when container-nav container-nav)
+ (when has-admin
+ (~events-post-nav-admin-cog :href admin-href :aclass aclass))))
+
+;; Calendar nav from data — slots + admin link
+(defcomp ~events-calendar-nav-from-data (&key slots-href admin-href select-colours is-admin)
+ (<>
+ (~nav-link :href slots-href :icon "fa fa-clock"
+ :label "Slots" :select-colours select-colours)
+ (when is-admin
+ (~nav-link :href admin-href :icon "fa fa-cog"
+ :select-colours select-colours))))
+
+;; Calendar admin nav from data
+(defcomp ~events-calendar-admin-nav-from-data (&key links select-colours)
+ (<> (map (lambda (l)
+ (~nav-link :href (get l "href") :label (get l "label")
+ :select-colours select-colours))
+ (or links (list)))))
+
+;; Day nav from data — confirmed entries + admin link
+(defcomp ~events-day-nav-from-data (&key entries is-admin admin-href)
+ (<>
+ (when (not (empty? (or entries (list))))
+ (~events-day-entries-nav
+ :inner (<> (map (lambda (e)
+ (~events-day-entry-link
+ :href (get e "href") :name (get e "name") :time-str (get e "time-str")))
+ entries))))
+ (when is-admin
+ (~nav-link :href admin-href :icon "fa fa-cog"))))
+
+;; Post search results from data
+(defcomp ~events-post-search-results-from-data (&key items page next-url has-more)
+ (<>
+ (map (lambda (item)
+ (~events-post-search-item
+ :post-url (get item "post-url") :entry-id (get item "entry-id")
+ :csrf (get item "csrf") :post-id (get item "post-id")
+ :img (~events-post-img-from-data :src (get item "img") :alt (get item "title"))
+ :title (get item "title")))
+ (or items (list)))
+ (cond
+ (has-more (~events-post-search-sentinel :page page :next-url next-url))
+ ((not (empty? (or items (list)))) (~events-post-search-end))
+ (true ""))))
+
+;; Entry options from data — state-driven button composition
+(defcomp ~events-entry-options-from-data (&key entry-id state buttons)
+ (~events-entry-options
+ :entry-id entry-id
+ :buttons (<> (map (lambda (b)
+ (~events-entry-option-button
+ :url (get b "url") :target (str "#calendar_entry_options_" entry-id)
+ :csrf (get b "csrf") :btn-type (get b "btn-type")
+ :action-btn (get b "action-btn")
+ :confirm-title (get b "confirm-title")
+ :confirm-text (get b "confirm-text")
+ :label (get b "label")
+ :is-btn (get b "is-btn")))
+ (or buttons (list))))))
diff --git a/events/sx/tickets.sx b/events/sx/tickets.sx
index 3da0e31..4809eed 100644
--- a/events/sx/tickets.sx
+++ b/events/sx/tickets.sx
@@ -204,3 +204,152 @@
(h3 :class "text-lg font-semibold" (str "Tickets for: " entry-name))
(span :class "text-sm text-stone-500" count-label))
body))
+
+
+;; ---------------------------------------------------------------------------
+;; Composition defcomps — receive data, compose ticket trees
+;; ---------------------------------------------------------------------------
+
+;; My tickets panel from data
+(defcomp ~events-tickets-panel-from-data (&key list-container tickets)
+ (~events-tickets-panel
+ :list-container list-container
+ :has-tickets (not (empty? (or tickets (list))))
+ :cards (<> (map (lambda (t)
+ (~events-ticket-card
+ :href (get t "href") :entry-name (get t "entry-name")
+ :type-name (get t "type-name") :time-str (get t "time-str")
+ :cal-name (get t "cal-name")
+ :badge (~ticket-state-badge :state (get t "state"))
+ :code-prefix (get t "code-prefix")))
+ (or tickets (list))))))
+
+;; Ticket detail from data — uses lg badge variant
+(defcomp ~events-ticket-detail-from-data (&key list-container back-href header-bg entry-name
+ state type-name code time-date time-range
+ cal-name type-desc checkin-str qr-script)
+ (~events-ticket-detail
+ :list-container list-container :back-href back-href
+ :header-bg header-bg :entry-name entry-name
+ :badge (~ticket-state-badge-lg :state state)
+ :type-name type-name :code code
+ :time-date time-date :time-range time-range
+ :cal-name cal-name :type-desc type-desc
+ :checkin-str checkin-str :qr-script qr-script))
+
+;; Ticket admin row from data — conditional action column
+(defcomp ~events-ticket-admin-row-from-data (&key code code-short entry-name date-str
+ type-name state checkin-url csrf
+ checked-in-time)
+ (~events-ticket-admin-row
+ :code code :code-short code-short
+ :entry-name entry-name
+ :date (when date-str (~events-ticket-admin-date :date-str date-str))
+ :type-name type-name
+ :badge (~ticket-state-badge :state state)
+ :action (cond
+ ((or (= state "confirmed") (= state "reserved"))
+ (~events-ticket-admin-checkin-form
+ :checkin-url checkin-url :code code :csrf csrf))
+ ((= state "checked_in")
+ (~events-ticket-admin-checked-in :time-str (or checked-in-time "")))
+ (true nil))))
+
+;; Ticket admin panel from data
+(defcomp ~events-ticket-admin-panel-from-data (&key list-container lookup-url tickets
+ total confirmed checked-in reserved)
+ (~events-ticket-admin-panel
+ :list-container list-container
+ :stats (<>
+ (~events-ticket-admin-stat :border "border-stone-200" :bg ""
+ :text-cls "text-stone-900" :label-cls "text-stone-500"
+ :value (str (or total 0)) :label "Total")
+ (~events-ticket-admin-stat :border "border-emerald-200" :bg "bg-emerald-50"
+ :text-cls "text-emerald-700" :label-cls "text-emerald-600"
+ :value (str (or confirmed 0)) :label "Confirmed")
+ (~events-ticket-admin-stat :border "border-blue-200" :bg "bg-blue-50"
+ :text-cls "text-blue-700" :label-cls "text-blue-600"
+ :value (str (or checked-in 0)) :label "Checked In")
+ (~events-ticket-admin-stat :border "border-amber-200" :bg "bg-amber-50"
+ :text-cls "text-amber-700" :label-cls "text-amber-600"
+ :value (str (or reserved 0)) :label "Reserved"))
+ :lookup-url lookup-url
+ :has-tickets (not (empty? (or tickets (list))))
+ :rows (<> (map (lambda (t)
+ (~events-ticket-admin-row-from-data
+ :code (get t "code") :code-short (get t "code-short")
+ :entry-name (get t "entry-name") :date-str (get t "date-str")
+ :type-name (get t "type-name") :state (get t "state")
+ :checkin-url (get t "checkin-url") :csrf (get t "csrf")
+ :checked-in-time (get t "checked-in-time")))
+ (or tickets (list))))))
+
+;; Entry tickets admin from data
+(defcomp ~events-entry-tickets-admin-from-data (&key entry-name count-label tickets csrf)
+ (~events-entry-tickets-admin-panel
+ :entry-name entry-name :count-label count-label
+ :body (if (empty? (or tickets (list)))
+ (~events-entry-tickets-admin-empty)
+ (~events-entry-tickets-admin-table
+ :rows (<> (map (lambda (t)
+ (~events-entry-tickets-admin-row
+ :code (get t "code") :code-short (get t "code-short")
+ :type-name (get t "type-name")
+ :badge (~ticket-state-badge :state (get t "state"))
+ :action (cond
+ ((or (= (get t "state") "confirmed") (= (get t "state") "reserved"))
+ (~events-entry-tickets-admin-checkin
+ :checkin-url (get t "checkin-url") :code (get t "code") :csrf csrf))
+ ((= (get t "state") "checked_in")
+ (~events-ticket-admin-checked-in :time-str (or (get t "checked-in-time") "")))
+ (true nil))))
+ (or tickets (list))))))))
+
+;; Checkin success row from data
+(defcomp ~events-checkin-success-row-from-data (&key code code-short entry-name date-str type-name time-str)
+ (~events-checkin-success-row
+ :code code :code-short code-short
+ :entry-name entry-name
+ :date (when date-str (~events-ticket-admin-date :date-str date-str))
+ :type-name type-name
+ :badge (~ticket-state-badge :state "checked_in")
+ :time-str time-str))
+
+;; Ticket types table from data
+(defcomp ~events-ticket-types-table-from-data (&key list-container ticket-types action-btn add-url
+ tr-cls pill-cls hx-select csrf-hdr)
+ (~events-ticket-types-table
+ :list-container list-container
+ :rows (if (empty? (or ticket-types (list)))
+ (~events-ticket-types-empty-row)
+ (<> (map (lambda (tt)
+ (~events-ticket-types-row
+ :tr-cls tr-cls :tt-href (get tt "tt-href")
+ :pill-cls pill-cls :hx-select hx-select
+ :tt-name (get tt "tt-name") :cost-str (get tt "cost-str")
+ :count (get tt "count") :action-btn action-btn
+ :del-url (get tt "del-url") :csrf-hdr csrf-hdr))
+ (or ticket-types (list)))))
+ :action-btn action-btn :add-url add-url))
+
+;; Lookup result from data
+(defcomp ~events-lookup-result-from-data (&key entry-name type-name date-str cal-name
+ state code checked-in-str
+ checkin-url csrf)
+ (~events-lookup-card
+ :info (<>
+ (~events-lookup-info :entry-name entry-name)
+ (when type-name (~events-lookup-type :type-name type-name))
+ (when date-str (~events-lookup-date :date-str date-str))
+ (when cal-name (~events-lookup-cal :cal-name cal-name))
+ (~events-lookup-status
+ :badge (~ticket-state-badge :state state) :code code)
+ (when checked-in-str
+ (~events-lookup-checkin-time :date-str checked-in-str)))
+ :code code
+ :action (cond
+ ((or (= state "confirmed") (= state "reserved"))
+ (~events-lookup-checkin-btn :checkin-url checkin-url :code code :csrf csrf))
+ ((= state "checked_in") (~events-lookup-checked-in))
+ ((= state "cancelled") (~events-lookup-cancelled))
+ (true nil))))
diff --git a/events/sxc/pages/calendar.py b/events/sxc/pages/calendar.py
index 01470e1..193d448 100644
--- a/events/sxc/pages/calendar.py
+++ b/events/sxc/pages/calendar.py
@@ -8,8 +8,8 @@ from shared.sx.helpers import (
from shared.sx.parser import SxExpr
from .utils import (
- _clear_deeper_oob, _ensure_container_nav,
- _entry_state_badge_html, _list_container,
+ _ensure_container_nav,
+ _list_container,
)
@@ -27,45 +27,41 @@ def _post_nav_sx(ctx: dict) -> str:
"""Post desktop nav: calendar links + container nav (markets, etc.)."""
from quart import url_for, g
- calendars = ctx.get("calendars") or []
+ calendars_orm = ctx.get("calendars") or []
select_colours = ctx.get("select_colours", "")
current_cal_slug = getattr(g, "calendar_slug", None)
- parts = []
- for cal in calendars:
+ calendars_data = []
+ for cal in calendars_orm:
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("calendar.get", calendar_slug=cal_slug)
- is_sel = (cal_slug == current_cal_slug)
- parts.append(sx_call("nav-link", href=href, icon="fa fa-calendar",
- label=cal_name, select_colours=select_colours,
- is_selected=is_sel))
- # Container nav fragments (markets, etc.)
- container_nav = ctx.get("container_nav", "")
- if container_nav:
- parts.append(container_nav)
+ calendars_data.append({
+ "href": href, "name": cal_name,
+ "is-selected": True if cal_slug == current_cal_slug else None,
+ })
+
+ container_nav = ctx.get("container_nav", "") or None
- # Admin cog → blog admin for this post (cross-domain, no HTMX)
rights = ctx.get("rights") or {}
has_admin = rights.get("admin") if isinstance(rights, dict) else getattr(rights, "admin", False)
+ admin_href = None
+ aclass = None
if has_admin:
post = ctx.get("post") or {}
slug = post.get("slug", "")
styles = ctx.get("styles") or {}
nav_btn = styles.get("nav_button", "") if isinstance(styles, dict) else getattr(styles, "nav_button", "")
- select_colours = ctx.get("select_colours", "")
admin_href = call_url(ctx, "blog_url", f"/{slug}/admin/")
aclass = f"{nav_btn} {select_colours}".strip() or (
"justify-center cursor-pointer flex flex-row items-center gap-2 "
"rounded bg-stone-200 text-black p-3"
)
- parts.append(
- f''
- )
- return "".join(parts)
+ return sx_call("events-post-nav-from-data",
+ calendars=calendars_data or None, container_nav=container_nav,
+ select_colours=select_colours,
+ has_admin=has_admin or None, admin_href=admin_href, aclass=aclass)
# ---------------------------------------------------------------------------
@@ -119,15 +115,13 @@ def _calendar_nav_sx(ctx: dict) -> str:
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("defpage_slots_listing", calendar_slug=cal_slug)
- parts.append(sx_call("nav-link", href=slots_href, icon="fa fa-clock",
- label="Slots", select_colours=select_colours))
- if is_admin:
- admin_href = url_for("defpage_calendar_admin", calendar_slug=cal_slug)
- parts.append(sx_call("nav-link", href=admin_href, icon="fa fa-cog",
- select_colours=select_colours))
- return "(<> " + " ".join(parts) + ")" if parts else ""
+ admin_href = url_for("defpage_calendar_admin", calendar_slug=cal_slug) if is_admin else None
+
+ return sx_call("events-calendar-nav-from-data",
+ slots_href=slots_href, admin_href=admin_href,
+ select_colours=select_colours,
+ is_admin=is_admin or None)
# ---------------------------------------------------------------------------
@@ -174,27 +168,21 @@ def _day_nav_sx(ctx: dict) -> str:
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(
- "defpage_entry_detail",
- 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 ""
- entry_links.append(sx_call("events-day-entry-link",
- href=href, name=entry.name,
- time_str=f"{start}{end}"))
- inner = "".join(entry_links)
- parts.append(sx_call("events-day-entries-nav", inner=SxExpr(inner)))
+ entries_data = []
+ for entry in confirmed_entries:
+ href = url_for(
+ "defpage_entry_detail",
+ 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 ""
+ entries_data.append({"href": href, "name": entry.name, "time-str": f"{start}{end}"})
+ admin_href = None
if is_admin and day_date:
admin_href = url_for(
"defpage_day_admin",
@@ -203,8 +191,10 @@ def _day_nav_sx(ctx: dict) -> str:
month=day_date.month,
day=day_date.day,
)
- parts.append(sx_call("nav-link", href=admin_href, icon="fa fa-cog"))
- return "".join(parts)
+
+ return sx_call("events-day-nav-from-data",
+ entries=entries_data or None,
+ is_admin=is_admin or None, admin_href=admin_href)
# ---------------------------------------------------------------------------
@@ -245,17 +235,16 @@ def _calendar_admin_header_sx(ctx: dict, *, oob: bool = False) -> str:
cal_slug = getattr(calendar, "slug", "") if calendar else ""
select_colours = ctx.get("select_colours", "")
- nav_parts = []
+ links_data = []
if cal_slug:
for endpoint, label in [
("defpage_slots_listing", "slots"),
("calendar.admin.calendar_description_edit", "description"),
]:
- href = url_for(endpoint, calendar_slug=cal_slug)
- nav_parts.append(sx_call("nav-link", href=href, label=label,
- select_colours=select_colours))
+ links_data.append({"href": url_for(endpoint, calendar_slug=cal_slug), "label": label})
- nav_html = "".join(nav_parts)
+ nav_html = sx_call("events-calendar-admin-nav-from-data",
+ links=links_data or None, select_colours=select_colours) if links_data else ""
return sx_call("menu-row-sx", id="calendar-admin-row", level=4,
link_label="admin", icon="fa fa-cog",
nav=SxExpr(nav_html) if nav_html else None, child_id="calendar-admin-header-child", oob=oob)
@@ -282,55 +271,36 @@ def _markets_header_sx(ctx: dict, *, oob: bool = False) -> str:
def _calendars_main_panel_sx(ctx: dict) -> str:
"""Render the calendars list + create form panel."""
from quart import url_for
+ from shared.utils import route_prefix
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 = sx_call("crud-create-form",
- create_url=create_url, csrf=csrf,
- errors_id="cal-create-errors", list_id="calendars-list",
- placeholder="e.g. Events, Gigs, Meetings", btn_label="Add calendar")
-
- list_html = _calendars_list_sx(ctx, calendars)
- return sx_call("crud-panel",
- form=SxExpr(form_html), list=SxExpr(list_html),
- list_id="calendars-list")
-
-
-def _calendars_list_sx(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 sx_call("empty-state", message="No calendars yet. Create one above.",
- cls="text-gray-500 mt-4")
-
- parts = []
+ calendars = ctx.get("calendars") or []
+ items_data = []
for cal in calendars:
cal_slug = getattr(cal, "slug", "")
cal_name = getattr(cal, "name", "")
- href = prefix + url_for("calendar.get", calendar_slug=cal_slug)
- del_url = url_for("calendar.delete", calendar_slug=cal_slug)
- csrf_hdr = f'{{"X-CSRFToken":"{csrf}"}}'
- parts.append(sx_call("crud-item",
- href=href, name=cal_name, slug=cal_slug,
- del_url=del_url, csrf_hdr=csrf_hdr,
- list_id="calendars-list",
- confirm_title="Delete calendar?",
- confirm_text="Entries will be hidden (soft delete)"))
- return "".join(parts)
+ items_data.append({
+ "href": prefix + url_for("calendar.get", calendar_slug=cal_slug),
+ "name": cal_name, "slug": cal_slug,
+ "del-url": url_for("calendar.delete", calendar_slug=cal_slug),
+ "csrf-hdr": f'{{"X-CSRFToken":"{csrf}"}}',
+ "confirm-title": "Delete calendar?",
+ "confirm-text": "Entries will be hidden (soft delete)",
+ })
+
+ return sx_call("events-crud-panel-from-data",
+ can_create=can_create or None,
+ create_url=url_for("calendars.create_calendar") if can_create else None,
+ csrf=csrf, errors_id="cal-create-errors", list_id="calendars-list",
+ placeholder="e.g. Events, Gigs, Meetings", btn_label="Add calendar",
+ items=items_data or None,
+ empty_msg="No calendars yet. Create one above.")
# ---------------------------------------------------------------------------
@@ -338,7 +308,7 @@ def _calendars_list_sx(ctx: dict, calendars: list) -> str:
# ---------------------------------------------------------------------------
def _calendar_main_panel_html(ctx: dict) -> str:
- """Render the calendar month grid."""
+ """Render the calendar month grid via data extraction + sx defcomp."""
from quart import url_for
from quart import session as qsession
@@ -346,7 +316,6 @@ def _calendar_main_panel_html(ctx: dict) -> str:
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", "")
@@ -368,35 +337,8 @@ def _calendar_main_panel_html(ctx: dict) -> str:
def nav_link(y, m):
return url_for("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(sx_call("events-calendar-nav-arrow",
- pill_cls=pill_cls, href=href, label=label))
-
- nav_arrows.append(sx_call("events-calendar-month-label",
- month_name=month_name, year=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(sx_call("events-calendar-nav-arrow",
- pill_cls=pill_cls, href=href, label=label))
-
- # Weekday headers
- wd_parts = []
- for wd in weekday_names:
- wd_parts.append(sx_call("events-calendar-weekday", name=wd))
- wd_html = "".join(wd_parts)
-
- # Day cells
- cells = []
+ # Day cells data
+ cells_data = []
for week in weeks:
for day_cell in week:
if isinstance(day_cell, dict):
@@ -414,24 +356,18 @@ def _calendar_main_panel_html(ctx: dict) -> str:
if is_today:
cell_cls += " ring-2 ring-blue-500 z-10 relative"
- # Day number link
- day_num_html = ""
- day_short_html = ""
+ cell = {"cell-cls": cell_cls}
if day_date:
- day_href = url_for(
+ cell["day-str"] = day_date.strftime("%a")
+ cell["day-href"] = url_for(
"calendar.day.show_day",
calendar_slug=cal_slug,
year=day_date.year, month=day_date.month, day=day_date.day,
)
- day_short_html = sx_call("events-calendar-day-short",
- day_str=day_date.strftime("%a"))
- day_num_html = sx_call("events-calendar-day-num",
- pill_cls=pill_cls, href=day_href,
- num=str(day_date.day))
+ cell["day-num"] = str(day_date.day)
- # Entry badges for this day
- entry_badges = []
- if day_date:
+ # Entry badges for this day
+ badges = []
for e in month_entries:
if e.start_at and e.start_at.date() == day_date:
is_mine = (
@@ -442,23 +378,23 @@ 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("_", " ")
- entry_badges.append(sx_call("events-calendar-entry-badge",
- bg_cls=bg_cls, name=e.name,
- state_label=state_label))
+ badges.append({
+ "bg-cls": bg_cls, "name": e.name,
+ "state-label": (e.state or "pending").replace("_", " "),
+ })
+ if badges:
+ cell["badges"] = badges
- badges_html = "(<> " + "".join(entry_badges) + ")" if entry_badges else ""
- cells.append(sx_call("events-calendar-cell",
- cell_cls=cell_cls, day_short=SxExpr(day_short_html),
- day_num=SxExpr(day_num_html),
- badges=SxExpr(badges_html) if badges_html else None))
+ cells_data.append(cell)
- cells_html = "(<> " + "".join(cells) + ")"
- arrows_html = "(<> " + "".join(nav_arrows) + ")"
- wd_html = "(<> " + wd_html + ")"
- return sx_call("events-calendar-grid",
- arrows=SxExpr(arrows_html), weekdays=SxExpr(wd_html),
- cells=SxExpr(cells_html))
+ return sx_call("events-calendar-grid-from-data",
+ pill_cls=pill_cls, month_name=month_name, year=str(year),
+ prev_year_href=nav_link(prev_year, month),
+ prev_month_href=nav_link(prev_month_year, prev_month),
+ next_month_href=nav_link(next_month_year, next_month),
+ next_year_href=nav_link(next_year, month),
+ weekday_names=weekday_names or None,
+ cells=cells_data or None)
# ---------------------------------------------------------------------------
@@ -466,7 +402,7 @@ def _calendar_main_panel_html(ctx: dict) -> str:
# ---------------------------------------------------------------------------
def _day_main_panel_html(ctx: dict) -> str:
- """Render the day entries table + add button."""
+ """Render the day entries table via data extraction + sx defcomp."""
from quart import url_for
calendar = ctx.get("calendar")
@@ -477,21 +413,49 @@ def _day_main_panel_html(ctx: dict) -> str:
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:
- row_parts = []
- for entry in day_entries:
- row_parts.append(_day_row_html(ctx, entry))
- rows_html = "".join(row_parts)
- else:
- rows_html = sx_call("events-day-empty-row")
+ rows_data = []
+ for entry in day_entries:
+ entry_href = url_for(
+ "defpage_entry_detail",
+ calendar_slug=cal_slug, day=day, month=month, year=year, entry_id=entry.id,
+ )
+ row = {
+ "href": entry_href, "name": entry.name,
+ "state-id": f"entry-state-{entry.id}",
+ "state": getattr(entry, "state", "pending") or "pending",
+ }
+
+ # Slot/Time
+ slot = getattr(entry, "slot", None)
+ if slot:
+ row["slot-name"] = slot.name
+ row["slot-href"] = url_for("defpage_slot_detail", 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 ""
+ row["slot-time"] = f"({time_start}{time_end})"
+ else:
+ row["start"] = entry.start_at.strftime("%H:%M") if entry.start_at else ""
+ row["end"] = f" \u2192 {entry.end_at.strftime('%H:%M')}" if entry.end_at else ""
+
+ # Cost
+ cost = getattr(entry, "cost", None)
+ row["cost-str"] = f"\u00a3{cost:.2f}" if cost is not None else "\u00a30.00"
+
+ # Tickets
+ tp = getattr(entry, "ticket_price", None)
+ if tp is not None:
+ tc = getattr(entry, "ticket_count", None)
+ row["has-tickets"] = True
+ row["price-str"] = f"\u00a3{tp:.2f}"
+ row["count-str"] = f"{tc} tickets" if tc is not None else "Unlimited"
+
+ rows_data.append(row)
add_url = url_for(
"calendar.day.calendar_entries.add_form",
@@ -499,74 +463,10 @@ def _day_main_panel_html(ctx: dict) -> str:
day=day, month=month, year=year,
)
- return sx_call("events-day-table",
- list_container=list_container, rows=SxExpr(rows_html),
- pre_action=pre_action, add_url=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(
- "defpage_entry_detail",
- calendar_slug=cal_slug, day=day, month=month, year=year, entry_id=entry.id,
- )
-
- # Name
- name_html = sx_call("events-day-row-name",
- href=entry_href, pill_cls=pill_cls, name=entry.name)
-
- # Slot/Time
- slot = getattr(entry, "slot", None)
- if slot:
- slot_href = url_for("defpage_slot_detail", 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 = sx_call("events-day-row-slot",
- href=slot_href, pill_cls=pill_cls, slot_name=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 = sx_call("events-day-row-time", start=start, end=end)
-
- # State
- state = getattr(entry, "state", "pending") or "pending"
- state_badge = _entry_state_badge_html(state)
- state_td = sx_call("events-day-row-state",
- state_id=f"entry-state-{entry.id}", badge=state_badge)
-
- # Cost
- cost = getattr(entry, "cost", None)
- cost_str = f"\u00a3{cost:.2f}" if cost is not None else "\u00a30.00"
- cost_td = sx_call("events-day-row-cost", cost_str=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 = sx_call("events-day-row-tickets",
- price_str=f"\u00a3{tp:.2f}", count_str=tc_str)
- else:
- tickets_td = sx_call("events-day-row-no-tickets")
-
- actions_td = sx_call("events-day-row-actions")
-
- return sx_call("events-day-row",
- tr_cls=tr_cls, name=name_html, slot=slot_html,
- state=state_td, cost=cost_td,
- tickets=tickets_td, actions=actions_td)
+ return sx_call("events-day-table-from-data",
+ list_container=list_container, pre_action=pre_action,
+ add_url=add_url, tr_cls=tr_cls, pill_cls=pill_cls,
+ rows=rows_data or None)
# ---------------------------------------------------------------------------
@@ -614,7 +514,7 @@ def _calendar_description_display_html(calendar, edit_url: str) -> str:
# ---------------------------------------------------------------------------
def _markets_main_panel_html(ctx: dict) -> str:
- """Render markets list + create form panel."""
+ """Render markets list + create form panel via data extraction + sx defcomp."""
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)
@@ -623,48 +523,29 @@ 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 []
-
- form_html = ""
- if can_create:
- create_url = url_for("markets.create_market")
- form_html = sx_call("crud-create-form",
- create_url=create_url, csrf=csrf,
- errors_id="market-create-errors", list_id="markets-list",
- placeholder="e.g. Farm Shop, Bakery", btn_label="Add market")
-
- list_html = _markets_list_html(ctx, markets)
- return sx_call("crud-panel",
- form=SxExpr(form_html), list=SxExpr(list_html),
- list_id="markets-list")
-
-
-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 sx_call("empty-state", message="No markets yet. Create one above.",
- cls="text-gray-500 mt-4")
-
- parts = []
+ items_data = []
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(sx_call("crud-item",
- href=market_href, name=m_name,
- slug=m_slug, del_url=del_url,
- csrf_hdr=csrf_hdr,
- list_id="markets-list",
- confirm_title="Delete market?",
- confirm_text="Products will be hidden (soft delete)"))
- return "".join(parts)
+ items_data.append({
+ "href": call_url(ctx, "market_url", f"/{slug}/{m_slug}/"),
+ "name": m_name, "slug": m_slug,
+ "del-url": url_for("markets.delete_market", market_slug=m_slug),
+ "csrf-hdr": f'{{"X-CSRFToken":"{csrf}"}}',
+ "confirm-title": "Delete market?",
+ "confirm-text": "Products will be hidden (soft delete)",
+ })
+
+ return sx_call("events-crud-panel-from-data",
+ can_create=can_create or None,
+ create_url=url_for("markets.create_market") if can_create else None,
+ csrf=csrf, errors_id="market-create-errors", list_id="markets-list",
+ placeholder="e.g. Farm Shop, Bakery", btn_label="Add market",
+ items=items_data or None,
+ empty_msg="No markets yet. Create one above.")
# ---------------------------------------------------------------------------
diff --git a/events/sxc/pages/entries.py b/events/sxc/pages/entries.py
index 1a1acd0..efdaf44 100644
--- a/events/sxc/pages/entries.py
+++ b/events/sxc/pages/entries.py
@@ -1,26 +1,23 @@
"""Entry panels, cards, forms, edit/add."""
from __future__ import annotations
-from markupsafe import escape
-
from shared.sx.helpers import sx_call
from shared.sx.parser import SxExpr
from .utils import (
- _entry_state_badge_html, _ticket_state_badge_html,
+ _entry_state_badge_html,
_list_container, _view_toggle_html,
)
# ---------------------------------------------------------------------------
-# All events / page summary entry cards
+# All events / page summary entry cards — data extraction
# ---------------------------------------------------------------------------
-def _entry_card_html(entry, page_info: dict, pending_tickets: dict,
+def _entry_card_data(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."""
- from .tickets import _ticket_widget_html
+ post: dict | None = None) -> dict:
+ """Extract data for a single entry card (list or tile)."""
pi = page_info.get(getattr(entry, "calendar_container_id", 0), {})
if is_page_scoped and post:
page_slug = pi.get("slug", post.get("slug", ""))
@@ -33,145 +30,103 @@ def _entry_card_html(entry, page_info: dict, pending_tickets: dict,
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)
- if entry_href:
- title_html = sx_call("events-entry-title-linked",
- href=entry_href, name=entry.name)
- else:
- title_html = sx_call("events-entry-title-plain", name=entry.name)
-
- # Badges
- badges_html = ""
+ # Page badge (only show if different from current page title)
+ page_badge_href = ""
+ page_badge_title = ""
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 += sx_call("events-entry-page-badge",
- href=page_href, title=page_title)
- cal_name = getattr(entry, "calendar_name", "")
- if cal_name:
- badges_html += sx_call("events-entry-cal-badge", name=cal_name)
+ page_badge_href = events_url_fn(f"/{page_slug}/")
+ page_badge_title = page_title
- # Time line
- time_parts = ""
- if day_href and not is_page_scoped:
- time_parts += sx_call("events-entry-time-linked",
- href=day_href,
- date_str=entry.start_at.strftime("%a %-d %b"))
- elif not is_page_scoped:
- time_parts += sx_call("events-entry-time-plain",
- date_str=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")}'
+ cal_name = getattr(entry, "calendar_name", "") or ""
+
+ # Time parts
+ date_str = entry.start_at.strftime("%a %-d %b") if entry.start_at else ""
+ start_time = entry.start_at.strftime("%H:%M") if entry.start_at else ""
+ end_time = entry.end_at.strftime("%H:%M") if entry.end_at else ""
+
+ # Tile time string (combined)
+ time_str_parts = []
+ if date_str:
+ time_str_parts.append(date_str)
+ if start_time:
+ time_str_parts.append(start_time)
+ time_str = " \u00b7 ".join(time_str_parts)
+ if end_time:
+ time_str += f" \u2013 {end_time}"
cost = getattr(entry, "cost", None)
- cost_html = sx_call("events-entry-cost",
- cost=f"\u00a3{cost:.2f}") if cost else ""
+ cost_str = f"\u00a3{cost:.2f}" if cost else None
- # Ticket widget
+ # Ticket widget data
tp = getattr(entry, "ticket_price", None)
- widget_html = ""
- if tp is not None:
+ has_ticket = tp is not None
+ ticket_data = None
+ if has_ticket:
qty = pending_tickets.get(entry.id, 0)
- widget_html = sx_call("events-entry-widget-wrapper",
- widget=_ticket_widget_html(entry, qty, ticket_url, ctx={}))
+ ticket_data = {
+ "entry-id": str(entry.id),
+ "price": f"\u00a3{tp:.2f}",
+ "qty": qty,
+ "ticket-url": ticket_url,
+ "csrf": _get_csrf(),
+ }
- return sx_call("events-entry-card",
- title=title_html, badges=SxExpr(badges_html),
- time_parts=SxExpr(time_parts), cost=SxExpr(cost_html),
- widget=SxExpr(widget_html))
+ return {
+ "entry-href": entry_href or None,
+ "name": entry.name,
+ "day-href": day_href or None,
+ "page-badge-href": page_badge_href or None,
+ "page-badge-title": page_badge_title or None,
+ "cal-name": cal_name or None,
+ "date-str": date_str,
+ "start-time": start_time,
+ "end-time": end_time or None,
+ "time-str": time_str,
+ "is-page-scoped": is_page_scoped or None,
+ "cost": cost_str,
+ "has-ticket": has_ticket or None,
+ "ticket-data": ticket_data,
+ }
-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."""
- from .tickets import _ticket_widget_html
- 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")
+def _get_csrf() -> str:
+ """Get CSRF token (lazy import)."""
+ try:
+ from flask_wtf.csrf import generate_csrf
+ return generate_csrf()
+ except Exception:
+ return ""
- 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
- if entry_href:
- title_html = sx_call("events-entry-title-tile-linked",
- href=entry_href, name=entry.name)
- else:
- title_html = sx_call("events-entry-title-tile-plain", name=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 += sx_call("events-entry-page-badge",
- href=page_href, title=page_title)
- cal_name = getattr(entry, "calendar_name", "")
- if cal_name:
- badges_html += sx_call("events-entry-cal-badge", name=cal_name)
-
- # Time
- time_html = ""
- if day_href:
- time_html += (sx_call("events-entry-time-linked",
- href=day_href,
- date_str=entry.start_at.strftime("%a %-d %b"))).replace(" · ", "")
- 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 = sx_call("events-entry-cost",
- cost=f"\u00a3{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 = sx_call("events-entry-tile-widget-wrapper",
- widget=_ticket_widget_html(entry, qty, ticket_url, ctx={}))
-
- return sx_call("events-entry-card-tile",
- title=title_html, badges=SxExpr(badges_html),
- time=SxExpr(time_html), cost=SxExpr(cost_html),
- widget=SxExpr(widget_html))
+def _entry_cards_data(entries, page_info, pending_tickets, ticket_url,
+ events_url_fn, view, *, is_page_scoped=False, post=None) -> list:
+ """Extract data list for entry cards with date separators."""
+ items = []
+ last_date = None
+ for entry in entries:
+ if view != "tile":
+ entry_date = entry.start_at.strftime("%A %-d %B %Y") if entry.start_at else ""
+ if entry_date != last_date:
+ items.append({"is-separator": True, "date-str": entry_date})
+ last_date = entry_date
+ items.append(_entry_card_data(
+ entry, page_info, pending_tickets, ticket_url, events_url_fn,
+ is_page_scoped=is_page_scoped, post=post,
+ ))
+ return items
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(sx_call("events-date-separator",
- date_str=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(sx_call("sentinel-simple",
- id=f"sentinel-{page}", next_url=next_url))
- return "".join(parts)
+ """Render entry cards via sx defcomp with data extraction."""
+ items = _entry_cards_data(
+ entries, page_info, pending_tickets, ticket_url, events_url_fn,
+ view, is_page_scoped=is_page_scoped, post=post,
+ )
+ return sx_call("events-entry-cards-from-data",
+ items=items, view=view, page=page,
+ has_more=has_more, next_url=next_url)
# ---------------------------------------------------------------------------
@@ -183,23 +138,15 @@ def _events_main_panel_html(ctx: dict, entries, has_more, pending_tickets, page_
*, is_page_scoped=False, post=None) -> str:
"""Render the events main panel with view toggle + cards."""
toggle = _view_toggle_html(ctx, view)
-
+ items = None
if entries:
- cards = _entry_cards_html(
+ items = _entry_cards_data(
entries, page_info, pending_tickets, ticket_url, events_url_fn,
- view, page, has_more, next_url,
- is_page_scoped=is_page_scoped, post=post,
+ view, 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 = sx_call("events-grid", grid_cls=grid_cls, cards=SxExpr(cards))
- else:
- body = sx_call("empty-state", icon="fa fa-calendar-xmark",
- message="No upcoming events",
- cls="px-3 py-12 text-center text-stone-400")
-
- return sx_call("events-main-panel-body",
- toggle=toggle, body=body)
+ return sx_call("events-main-panel-from-data",
+ toggle=toggle, items=items, view=view, page=page,
+ has_more=has_more, next_url=next_url)
# ---------------------------------------------------------------------------
@@ -373,27 +320,16 @@ def _entry_nav_html(ctx: dict) -> str:
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
+ # Associated Posts scrolling menu (strip OOB attr for inline embedding)
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 = sx_call("events-post-img", src=feat, alt=title)
- else:
- img_html = sx_call("events-post-img-placeholder")
- post_links += sx_call("events-entry-nav-post-link",
- href=href, img=img_html, title=title)
- parts.append((sx_call("events-entry-posts-nav-oob",
- items=SxExpr(post_links))).replace(' :hx-swap-oob "true"', ''))
+ posts_data = _entry_posts_nav_data(entry_posts, blog_url_fn)
+ nav_html = sx_call("events-entry-posts-nav-inner-from-data", posts=posts_data or None)
+ if nav_html:
+ parts.append(nav_html.replace(' :hx-swap-oob "true"', ''))
# Admin link
if is_admin:
@@ -432,7 +368,7 @@ def _entry_title_html(entry) -> str:
def _entry_options_html(entry, calendar, day, month, year) -> str:
- """Render confirm/decline/provisional buttons based on entry state."""
+ """Render confirm/decline/provisional buttons via data extraction + sx defcomp."""
from quart import url_for, g
from shared.browser.app.csrf import generate_csrf_token
csrf = generate_csrf_token()
@@ -443,39 +379,30 @@ def _entry_options_html(entry, calendar, day, month, year) -> str:
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"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 sx_call("events-entry-option-button",
- url=url, target=target, csrf=csrf, btn_type=btn_type,
- action_btn=action_btn, confirm_title=confirm_title,
- confirm_text=confirm_text, label=label,
- is_btn=trigger_type == "button")
+ def _btn_data(action_name, label, confirm_title, confirm_text, *, trigger_type="submit"):
+ return {
+ "url": url_for(f"calendar.day.calendar_entries.calendar_entry.{action_name}",
+ calendar_slug=cal_slug, day=day, month=month, year=year, entry_id=eid),
+ "csrf": csrf, "btn-type": "button" if trigger_type == "button" else "submit",
+ "action-btn": action_btn, "confirm-title": confirm_title,
+ "confirm-text": confirm_text, "label": label,
+ "is-btn": True if trigger_type == "button" else None,
+ }
- buttons_html = ""
+ buttons = []
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?",
- )
+ buttons.append(_btn_data("confirm_entry", "confirm",
+ "Confirm entry?", "Are you sure you want to confirm this entry?"))
+ buttons.append(_btn_data("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",
- )
+ buttons.append(_btn_data("provisional_entry", "provisional",
+ "Provisional entry?", "Are you sure you want to provisional this entry?",
+ trigger_type="button"))
- return sx_call("events-entry-options",
- entry_id=str(eid), buttons=SxExpr(buttons_html))
+ return sx_call("events-entry-options-from-data",
+ entry_id=str(eid), buttons=buttons or None)
# ---------------------------------------------------------------------------
@@ -525,7 +452,7 @@ def render_entry_tickets_config(entry, calendar, day, month, year) -> str:
# ---------------------------------------------------------------------------
def render_entry_posts_panel(entry_posts, entry, calendar, day, month, year) -> str:
- """Render associated posts list with remove buttons and search input."""
+ """Render associated posts list via data extraction + sx defcomp."""
from quart import url_for
from shared.browser.app.csrf import generate_csrf_token
csrf = generate_csrf_token()
@@ -533,38 +460,46 @@ def render_entry_posts_panel(entry_posts, entry, calendar, day, month, year) ->
cal_slug = getattr(calendar, "slug", "")
eid = entry.id
eid_s = str(eid)
+ csrf_hdr = f'{{"X-CSRFToken": "{csrf}"}}'
- posts_html = ""
+ posts_data = []
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 = (sx_call("events-post-img", src=feat, alt=ep_title)
- if feat else sx_call("events-post-img-placeholder"))
-
- del_url = url_for(
- "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 += sx_call("events-entry-post-item",
- img=img_html, title=ep_title,
- del_url=del_url, entry_id=eid_s,
- csrf_hdr=f'{{"X-CSRFToken": "{csrf}"}}')
- posts_html = sx_call("events-entry-posts-list", items=SxExpr(items))
- else:
- posts_html = sx_call("events-entry-posts-none")
+ posts_data.append({
+ "title": getattr(ep, "title", ""),
+ "img": getattr(ep, "feature_image", None),
+ "del-url": url_for(
+ "calendar.day.calendar_entries.calendar_entry.remove_post",
+ calendar_slug=cal_slug, day=day, month=month, year=year,
+ entry_id=eid, post_id=getattr(ep, "id", 0),
+ ),
+ "csrf-hdr": csrf_hdr,
+ })
search_url = url_for(
"calendar.day.calendar_entries.calendar_entry.search_posts",
calendar_slug=cal_slug, day=day, month=month, year=year, entry_id=eid,
)
- return sx_call("events-entry-posts-panel",
- posts=posts_html, search_url=search_url,
- entry_id=eid_s)
+ return sx_call("events-entry-posts-panel-from-data",
+ entry_id=eid_s, posts=posts_data or None,
+ search_url=search_url)
+
+
+# ---------------------------------------------------------------------------
+# Entry posts nav data helper (shared by nav OOB + entry nav)
+# ---------------------------------------------------------------------------
+
+def _entry_posts_nav_data(entry_posts, blog_url_fn) -> list:
+ """Extract post nav data from ORM entry posts."""
+ if not entry_posts:
+ return []
+ return [
+ {"href": blog_url_fn(f"/{getattr(ep, 'slug', '')}/") if blog_url_fn else f"/{getattr(ep, 'slug', '')}/",
+ "title": getattr(ep, "title", ""),
+ "img": getattr(ep, "feature_image", None)}
+ for ep in entry_posts
+ ]
# ---------------------------------------------------------------------------
@@ -572,28 +507,15 @@ def render_entry_posts_panel(entry_posts, entry, calendar, day, month, year) ->
# ---------------------------------------------------------------------------
def render_entry_posts_nav_oob(entry_posts) -> str:
- """Render OOB nav for entry posts (scrolling menu)."""
+ """Render OOB nav for entry posts via data extraction + sx defcomp."""
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 sx_call("events-entry-posts-nav-oob-empty")
-
- 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 = (sx_call("events-post-img", src=feat, alt=title)
- if feat else sx_call("events-post-img-placeholder"))
- items += sx_call("events-entry-nav-post",
- href=href, nav_btn=nav_btn,
- img=img_html, title=title)
-
- return sx_call("events-entry-posts-nav-oob", items=SxExpr(items))
+ posts_data = _entry_posts_nav_data(entry_posts, blog_url_fn)
+ return sx_call("events-entry-posts-nav-oob-from-data",
+ nav_btn=nav_btn, posts=posts_data or None)
# ---------------------------------------------------------------------------
@@ -601,31 +523,28 @@ def render_entry_posts_nav_oob(entry_posts) -> str:
# ---------------------------------------------------------------------------
def render_day_entries_nav_oob(confirmed_entries, calendar, day_date) -> str:
- """Render OOB nav for confirmed entries in a day."""
+ """Render OOB nav for confirmed entries via data extraction + sx defcomp."""
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 sx_call("events-day-entries-nav-oob-empty")
+ entries_data = []
+ if confirmed_entries:
+ for entry in confirmed_entries:
+ 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 ""
+ entries_data.append({
+ "href": url_for("defpage_entry_detail", calendar_slug=cal_slug,
+ year=day_date.year, month=day_date.month, day=day_date.day,
+ entry_id=entry.id),
+ "name": entry.name,
+ "time-str": start + end,
+ })
- items = ""
- for entry in confirmed_entries:
- href = url_for(
- "defpage_entry_detail",
- 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 += sx_call("events-day-nav-entry",
- href=href, nav_btn=nav_btn,
- name=entry.name, time_str=start + end)
-
- return sx_call("events-day-entries-nav-oob", items=SxExpr(items))
+ return sx_call("events-day-entries-nav-oob-from-data",
+ nav_btn=nav_btn, entries=entries_data or None)
# ---------------------------------------------------------------------------
@@ -633,7 +552,7 @@ def render_day_entries_nav_oob(confirmed_entries, calendar, day_date) -> str:
# ---------------------------------------------------------------------------
def render_post_nav_entries_oob(associated_entries, calendars, post) -> str:
- """Render OOB nav for associated entries and calendars of a post."""
+ """Render OOB nav for associated entries and calendars via data + sx defcomp."""
from quart import g
from shared.infrastructure.urls import events_url
@@ -641,14 +560,9 @@ def render_post_nav_entries_oob(associated_entries, calendars, post) -> str:
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 sx_call("events-post-nav-oob-empty")
-
slug = post.get("slug", "") if isinstance(post, dict) else getattr(post, "slug", "")
- items = ""
+ entries_data = []
if has_entries:
for entry in associated_entries.entries:
entry_path = (
@@ -656,27 +570,31 @@ def render_post_nav_entries_oob(associated_entries, calendars, post) -> str:
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 += sx_call("events-post-nav-entry",
- href=href, nav_btn=nav_btn,
- name=entry.name, time_str=time_str + end_str)
+ entries_data.append({
+ "href": events_url(entry_path),
+ "name": entry.name,
+ "time-str": time_str + end_str,
+ })
+ calendars_data = []
if calendars:
for cal in calendars:
cs = getattr(cal, "slug", "")
- local_href = events_url(f"/{slug}/{cs}/")
- items += sx_call("events-post-nav-calendar",
- href=local_href, nav_btn=nav_btn, name=cal.name)
+ calendars_data.append({
+ "href": events_url(f"/{slug}/{cs}/"),
+ "name": 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 sx_call("events-post-nav-wrapper",
- items=SxExpr(items), hyperscript=hs)
+ return sx_call("events-post-nav-wrapper-from-data",
+ nav_btn=nav_btn, entries=entries_data or None,
+ calendars=calendars_data or None, hyperscript=hs)
# ---------------------------------------------------------------------------
@@ -800,42 +718,36 @@ def _entry_admin_main_panel_html(ctx: dict) -> str:
def render_post_search_results(search_posts, search_query, page, total_pages,
entry, calendar, day, month, year) -> str:
- """Render post search results (replaces _types/entry/_post_search_results.html)."""
+ """Render post search results via data extraction + sx defcomp."""
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
+ post_url = url_for("calendar.day.calendar_entries.calendar_entry.add_post",
+ calendar_slug=cal_slug, day=day, month=month, year=year,
+ entry_id=eid)
- parts = []
+ items_data = []
for sp in search_posts:
- post_url = url_for("calendar.day.calendar_entries.calendar_entry.add_post",
- calendar_slug=cal_slug, day=day, month=month, year=year,
- entry_id=eid)
- feat = getattr(sp, "feature_image", None)
- title = getattr(sp, "title", "")
- if feat:
- img_html = sx_call("events-post-img", src=feat, alt=title)
- else:
- img_html = sx_call("events-post-img-placeholder")
+ items_data.append({
+ "post-url": post_url, "entry-id": str(eid),
+ "csrf": csrf, "post-id": str(sp.id),
+ "img": getattr(sp, "feature_image", None),
+ "title": getattr(sp, "title", ""),
+ })
- parts.append(sx_call("events-post-search-item",
- post_url=post_url, entry_id=str(eid), csrf=csrf,
- post_id=str(sp.id), img=img_html, title=title))
-
- result = "".join(parts)
-
- if page < int(total_pages):
+ has_more = page < int(total_pages)
+ next_url = None
+ if has_more:
next_url = url_for("calendar.day.calendar_entries.calendar_entry.search_posts",
calendar_slug=cal_slug, day=day, month=month, year=year,
entry_id=eid, q=search_query, page=page + 1)
- result += sx_call("events-post-search-sentinel",
- page=str(page), next_url=next_url)
- elif search_posts:
- result += sx_call("events-post-search-end")
- return result
+ return sx_call("events-post-search-results-from-data",
+ items=items_data or None, page=str(page),
+ next_url=next_url, has_more=has_more or None)
# ---------------------------------------------------------------------------
@@ -846,7 +758,7 @@ def render_entry_edit_form(entry, calendar, day, month, year, day_slots) -> str:
"""Render entry edit form (replaces _types/entry/_edit.html)."""
from quart import url_for, g
from shared.browser.app.csrf import generate_csrf_token
- from .slots import _slot_options_html, _SLOT_PICKER_JS
+ from .slots import _slot_options_data, _SLOT_PICKER_JS
csrf = generate_csrf_token()
styles = getattr(g, "styles", None) or {}
@@ -862,12 +774,9 @@ def render_entry_edit_form(entry, calendar, day, month, year, day_slots) -> str:
calendar_slug=cal_slug, day=day, month=month, year=year, entry_id=eid)
# Slot picker
- if day_slots:
- options_html = _slot_options_html(day_slots, selected_slot_id=getattr(entry, "slot_id", None))
- slot_picker_html = sx_call("events-slot-picker",
- id=f"entry-slot-{eid}", options=SxExpr(options_html))
- else:
- slot_picker_html = sx_call("events-no-slots")
+ slots_data = _slot_options_data(day_slots, selected_slot_id=getattr(entry, "slot_id", None)) if day_slots else []
+ slot_picker_html = sx_call("events-slot-picker-from-data",
+ id=f"entry-slot-{eid}", slots=slots_data or None)
# Values
start_val = entry.start_at.strftime("%H:%M") if entry.start_at else ""
@@ -897,7 +806,7 @@ def render_entry_add_form(calendar, day, month, year, day_slots) -> str:
"""Render entry add form (replaces _types/day/_add.html)."""
from quart import url_for, g
from shared.browser.app.csrf import generate_csrf_token
- from .slots import _slot_options_html, _SLOT_PICKER_JS
+ from .slots import _slot_options_data, _SLOT_PICKER_JS
csrf = generate_csrf_token()
styles = getattr(g, "styles", None) or {}
@@ -911,12 +820,9 @@ def render_entry_add_form(calendar, day, month, year, day_slots) -> str:
calendar_slug=cal_slug, day=day, month=month, year=year)
# Slot picker
- if day_slots:
- options_html = _slot_options_html(day_slots)
- slot_picker_html = sx_call("events-slot-picker",
- id="entry-slot-new", options=SxExpr(options_html))
- else:
- slot_picker_html = sx_call("events-no-slots")
+ slots_data = _slot_options_data(day_slots) if day_slots else []
+ slot_picker_html = sx_call("events-slot-picker-from-data",
+ id="entry-slot-new", slots=slots_data or None)
html = sx_call("events-entry-add-form",
post_url=post_url, csrf=csrf,
@@ -944,34 +850,33 @@ def render_entry_add_button(calendar, day, month, year) -> str:
# ---------------------------------------------------------------------------
def render_fragment_container_cards(batch, post_ids, slug_map) -> str:
- """Render container cards entries (replaces fragments/container_cards_entries.html)."""
+ """Render container cards entries via data extraction + sx defcomp."""
from shared.infrastructure.urls import events_url
- parts = []
+ widgets_data = []
for post_id in post_ids:
- parts.append(f"")
widget_entries = batch.get(post_id, [])
- if widget_entries:
- cards_html = ""
- for entry in widget_entries:
- _post_slug = slug_map.get(post_id, "")
- _entry_path = (
- f"/{_post_slug}/{entry.calendar_slug}/"
- f"{entry.start_at.year}/{entry.start_at.month}/"
- f"{entry.start_at.day}/entries/{entry.id}/"
- )
- time_str = entry.start_at.strftime("%H:%M")
- if entry.end_at:
- time_str += f" \u2013 {entry.end_at.strftime('%H:%M')}"
- cards_html += sx_call("events-frag-entry-card",
- href=events_url(_entry_path),
- name=entry.name,
- date_str=entry.start_at.strftime("%a, %b %d"),
- time_str=time_str)
- parts.append(sx_call("events-frag-entries-widget", cards=SxExpr(cards_html)))
- parts.append(f"")
+ entries_data = []
+ for entry in widget_entries:
+ _post_slug = slug_map.get(post_id, "")
+ _entry_path = (
+ f"/{_post_slug}/{entry.calendar_slug}/"
+ f"{entry.start_at.year}/{entry.start_at.month}/"
+ f"{entry.start_at.day}/entries/{entry.id}/"
+ )
+ time_str = entry.start_at.strftime("%H:%M")
+ if entry.end_at:
+ time_str += f" \u2013 {entry.end_at.strftime('%H:%M')}"
+ entries_data.append({
+ "href": events_url(_entry_path),
+ "name": entry.name,
+ "date-str": entry.start_at.strftime("%a, %b %d"),
+ "time-str": time_str,
+ })
+ widgets_data.append({"entries": entries_data or None})
- return "\n".join(parts)
+ return sx_call("events-frag-container-cards-from-data",
+ widgets=widgets_data or None)
# ---------------------------------------------------------------------------
@@ -979,32 +884,23 @@ def render_fragment_container_cards(batch, post_ids, slug_map) -> str:
# ---------------------------------------------------------------------------
def render_fragment_account_tickets(tickets) -> str:
- """Render account page tickets (replaces fragments/account_page_tickets.html)."""
+ """Render account page tickets via data extraction + sx defcomp."""
from shared.infrastructure.urls import events_url
+ tickets_data = []
if tickets:
- items_html = ""
for ticket in tickets:
- href = events_url(f"/tickets/{ticket.code}/")
- date_str = ticket.entry_start_at.strftime("%d %b %Y, %H:%M")
- cal_name = ""
- if getattr(ticket, "calendar_name", None):
- cal_name = f'· {escape(ticket.calendar_name)}'
- type_name = ""
- if getattr(ticket, "ticket_type_name", None):
- type_name = f'· {escape(ticket.ticket_type_name)}'
- badge_html = sx_call("status-pill",
- status=getattr(ticket, "state", ""))
- items_html += sx_call("events-frag-ticket-item",
- href=href, entry_name=ticket.entry_name,
- date_str=date_str, calendar_name=cal_name,
- type_name=type_name, badge=badge_html)
- body = sx_call("events-frag-tickets-list", items=SxExpr(items_html))
- else:
- body = sx_call("empty-state", message="No tickets yet.",
- cls="text-sm text-stone-500")
+ tickets_data.append({
+ "href": events_url(f"/tickets/{ticket.code}/"),
+ "entry-name": ticket.entry_name,
+ "date-str": ticket.entry_start_at.strftime("%d %b %Y, %H:%M"),
+ "calendar-name": getattr(ticket, "calendar_name", None) or None,
+ "type-name": getattr(ticket, "ticket_type_name", None) or None,
+ "state": getattr(ticket, "state", ""),
+ })
- return sx_call("events-frag-tickets-panel", items=body)
+ return sx_call("events-frag-tickets-panel-from-data",
+ tickets=tickets_data or None)
# ---------------------------------------------------------------------------
@@ -1012,31 +908,18 @@ def render_fragment_account_tickets(tickets) -> str:
# ---------------------------------------------------------------------------
def render_fragment_account_bookings(bookings) -> str:
- """Render account page bookings (replaces fragments/account_page_bookings.html)."""
+ """Render account page bookings via data extraction + sx defcomp."""
+ bookings_data = []
if bookings:
- items_html = ""
for booking in bookings:
- date_str = booking.start_at.strftime("%d %b %Y, %H:%M")
- if getattr(booking, "end_at", None):
- date_str_extra = f'– {escape(booking.end_at.strftime("%H:%M"))}'
- else:
- date_str_extra = ""
- cal_name = ""
- if getattr(booking, "calendar_name", None):
- cal_name = f'· {escape(booking.calendar_name)}'
- cost_str = ""
- if getattr(booking, "cost", None):
- cost_str = f'· £{escape(str(booking.cost))}'
- badge_html = sx_call("status-pill",
- status=getattr(booking, "state", ""))
- items_html += sx_call("events-frag-booking-item",
- name=booking.name,
- date_str=date_str + date_str_extra,
- calendar_name=cal_name, cost_str=cost_str,
- badge=badge_html)
- body = sx_call("events-frag-bookings-list", items=SxExpr(items_html))
- else:
- body = sx_call("empty-state", message="No bookings yet.",
- cls="text-sm text-stone-500")
+ bookings_data.append({
+ "name": booking.name,
+ "date-str": booking.start_at.strftime("%d %b %Y, %H:%M"),
+ "end-time": booking.end_at.strftime("%H:%M") if getattr(booking, "end_at", None) else None,
+ "calendar-name": getattr(booking, "calendar_name", None) or None,
+ "cost-str": str(booking.cost) if getattr(booking, "cost", None) else None,
+ "state": getattr(booking, "state", ""),
+ })
- return sx_call("events-frag-bookings-panel", items=body)
+ return sx_call("events-frag-bookings-panel-from-data",
+ bookings=bookings_data or None)
diff --git a/events/sxc/pages/renders.py b/events/sxc/pages/renders.py
index f478172..f433fcd 100644
--- a/events/sxc/pages/renders.py
+++ b/events/sxc/pages/renders.py
@@ -2,12 +2,12 @@
from __future__ import annotations
from shared.sx.helpers import (
- render_to_sx_with_env,
+ render_to_sx_with_env, sx_call,
post_admin_header_sx, oob_header_sx,
header_child_sx, full_page_sx, oob_page_sx,
)
-from .utils import _clear_deeper_oob, _ensure_container_nav
+from .utils import _ensure_container_nav
from .calendar import (
_post_header_sx, _calendars_header_sx,
_calendar_header_sx, _day_header_sx,
@@ -129,7 +129,7 @@ async def render_page_summary_oob(ctx: dict, entries, has_more, pending_tickets,
)
oobs = await _post_header_sx(ctx, oob=True)
- oobs += _clear_deeper_oob("post-row", "post-header-child")
+ oobs += sx_call("events-clear-deeper-post")
return await oob_page_sx(oobs=oobs, content=content)
@@ -172,8 +172,7 @@ async def render_calendars_oob(ctx: dict) -> str:
ctx = await _ensure_container_nav(ctx)
slug = (ctx.get("post") or {}).get("slug", "")
oobs = await post_admin_header_sx(ctx, slug, oob=True, selected="calendars")
- oobs += _clear_deeper_oob("post-row", "post-header-child",
- "post-admin-row", "post-admin-header-child")
+ oobs += sx_call("events-clear-deeper-post-admin")
return await oob_page_sx(oobs=oobs, content=content)
@@ -196,8 +195,7 @@ async def render_calendar_oob(ctx: dict) -> str:
oobs = await _post_header_sx(ctx, oob=True)
oobs += await oob_header_sx("post-header-child", "calendar-header-child",
_calendar_header_sx(ctx))
- oobs += _clear_deeper_oob("post-row", "post-header-child",
- "calendar-row", "calendar-header-child")
+ oobs += sx_call("events-clear-deeper-calendar")
return await oob_page_sx(oobs=oobs, content=content)
@@ -221,9 +219,7 @@ async def render_day_oob(ctx: dict) -> str:
oobs = _calendar_header_sx(ctx, oob=True)
oobs += await oob_header_sx("calendar-header-child", "day-header-child",
_day_header_sx(ctx))
- oobs += _clear_deeper_oob("post-row", "post-header-child",
- "calendar-row", "calendar-header-child",
- "day-row", "day-header-child")
+ oobs += sx_call("events-clear-deeper-day")
return await oob_page_sx(oobs=oobs, content=content)
diff --git a/events/sxc/pages/slots.py b/events/sxc/pages/slots.py
index a0b2ded..4916590 100644
--- a/events/sxc/pages/slots.py
+++ b/events/sxc/pages/slots.py
@@ -3,7 +3,6 @@ from __future__ import annotations
from shared.sx.helpers import sx_call
-from shared.sx.parser import SxExpr
# ===========================================================================
@@ -111,9 +110,9 @@ _SLOT_PICKER_JS = """\
# Slot options (shared by entry edit + add forms)
# ---------------------------------------------------------------------------
-def _slot_options_html(day_slots, selected_slot_id=None) -> str:
- """Build slot