From 51ebf347bae61a8ed47921c3bf37d722620ef11b Mon Sep 17 00:00:00 2001 From: giles Date: Thu, 5 Mar 2026 08:17:09 +0000 Subject: [PATCH] Move events/market/blog composition from Python to .sx defcomps (Phase 9) Continues the pattern of eliminating Python sx_call tree-building in favour of data-driven .sx defcomps. POST/PUT/DELETE routes now pass plain data (dicts, lists, scalars) and let .sx handle iteration, conditionals, and layout via map/let/when/if. Single response components wrap OOB swaps. Co-Authored-By: Claude Opus 4.6 --- blog/bp/post/admin/routes.py | 169 +++------- blog/sx/admin.sx | 105 ++++++ events/sx/calendar.sx | 30 ++ events/sx/day.sx | 39 +++ events/sx/entries.sx | 197 +++++++++++ events/sx/forms.sx | 58 ++++ events/sx/fragments.sx | 62 ++++ events/sx/layouts.sx | 48 +++ events/sx/page.sx | 420 ++++++++++++++++++----- events/sx/tickets.sx | 149 +++++++++ events/sxc/pages/calendar.py | 415 +++++++++-------------- events/sxc/pages/entries.py | 623 ++++++++++++++--------------------- events/sxc/pages/renders.py | 16 +- events/sxc/pages/slots.py | 185 ++++------- events/sxc/pages/tickets.py | 495 +++++++++++----------------- events/sxc/pages/utils.py | 58 +--- market/sx/cart.sx | 14 + market/sx/filters.sx | 15 + market/sx/layouts.sx | 29 +- market/sxc/pages/cards.py | 6 - market/sxc/pages/filters.py | 28 +- market/sxc/pages/renders.py | 82 ++--- market/sxc/pages/utils.py | 21 +- 23 files changed, 1841 insertions(+), 1423 deletions(-) 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'' - - 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'
' - f'
{day_date.day}
{entries_html}
' - ) - - 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