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 <noreply@anthropic.com>
This commit is contained in:
2026-03-05 08:17:09 +00:00
parent 877e776977
commit 51ebf347ba
23 changed files with 1841 additions and 1423 deletions

View File

@@ -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))))))