All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 6m0s
- Extract shared components (empty-state, delete-btn, sentinel, crud-*, view-toggle, img-or-placeholder, avatar, sumup-settings-form, auth forms, order tables/detail/checkout) - Migrate all Python sx_call() callers to use shared components directly - Remove 55+ thin wrapper defcomps from domain .sx files - Remove trivial passthrough wrappers (blog-header-label, market-card-text, etc) - Unify duplicate auth flows (account + federation) into shared/sx/templates/auth.sx - Unify duplicate order views (cart + orders) into shared/sx/templates/orders.sx - Disable static file caching in dev (SEND_FILE_MAX_AGE_DEFAULT=0) - Add SX response validation and debug headers Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
259 lines
15 KiB
Plaintext
259 lines
15 KiB
Plaintext
;; Miscellaneous shared components
|
|
|
|
;; The single place where raw! lives — for CMS content (Ghost post body,
|
|
;; product descriptions, etc.) that arrives as pre-rendered HTML.
|
|
(defcomp ~rich-text (&key html)
|
|
(raw! html))
|
|
|
|
(defcomp ~error-inline (&key message)
|
|
(div :class "text-red-600 text-sm" message))
|
|
|
|
(defcomp ~notification-badge (&key count)
|
|
(span :class "bg-red-500 text-white text-xs rounded-full px-1.5 py-0.5" count))
|
|
|
|
(defcomp ~cache-cleared (&key time-str)
|
|
(span :class "text-green-600 font-bold" "Cache cleared at " time-str))
|
|
|
|
(defcomp ~error-list (&key items)
|
|
(ul :class "list-disc pl-5 space-y-1 text-sm text-red-600"
|
|
(when items items)))
|
|
|
|
(defcomp ~error-list-item (&key message)
|
|
(li message))
|
|
|
|
(defcomp ~fragment-error (&key service)
|
|
(p :class "text-sm text-red-600" "Service " (b service) " is unavailable."))
|
|
|
|
(defcomp ~htmx-sentinel (&key id hx-get hx-trigger hx-swap class extra-attrs)
|
|
(div :id id :sx-get hx-get :sx-trigger hx-trigger :sx-swap hx-swap :class class))
|
|
|
|
(defcomp ~nav-group-link (&key href hx-select nav-class label)
|
|
(div :class "relative nav-group"
|
|
(a :href href :sx-get href :sx-target "#main-panel"
|
|
:sx-select hx-select :sx-swap "outerHTML"
|
|
:sx-push-url "true" :class nav-class
|
|
label)))
|
|
|
|
;; ---------------------------------------------------------------------------
|
|
;; Shared sentinel components — infinite scroll triggers
|
|
;; ---------------------------------------------------------------------------
|
|
|
|
(defcomp ~sentinel-mobile (&key id next-url hyperscript)
|
|
(div :id id :class "block md:hidden h-[60vh] opacity-0 pointer-events-none js-mobile-sentinel"
|
|
:sx-get next-url :sx-trigger "intersect once delay:250ms, sentinelmobile:retry"
|
|
:sx-swap "outerHTML" :_ hyperscript
|
|
:role "status" :aria-live "polite" :aria-hidden "true"
|
|
(div :class "js-loading hidden flex justify-center py-8"
|
|
(div :class "animate-spin h-8 w-8 border-4 border-stone-300 border-t-stone-600 rounded-full"))
|
|
(div :class "js-neterr hidden text-center py-8 text-stone-400"
|
|
(i :class "fa fa-exclamation-triangle text-2xl")
|
|
(p :class "mt-2" "Loading failed \u2014 retrying\u2026"))))
|
|
|
|
(defcomp ~sentinel-desktop (&key id next-url hyperscript)
|
|
(div :id id :class "hidden md:block h-4 opacity-0 pointer-events-none"
|
|
:sx-get next-url :sx-trigger "intersect once delay:250ms, sentinel:retry"
|
|
:sx-swap "outerHTML" :_ hyperscript
|
|
:role "status" :aria-live "polite" :aria-hidden "true"
|
|
(div :class "js-loading hidden flex justify-center py-2"
|
|
(div :class "animate-spin h-6 w-6 border-2 border-stone-300 border-t-stone-600 rounded-full"))
|
|
(div :class "js-neterr hidden text-center py-2 text-stone-400 text-sm" "Retry\u2026")))
|
|
|
|
(defcomp ~sentinel-simple (&key id next-url)
|
|
(div :id id :class "h-4 opacity-0 pointer-events-none"
|
|
:sx-get next-url :sx-trigger "intersect once delay:250ms" :sx-swap "outerHTML"
|
|
:role "status" :aria-hidden "true"
|
|
(div :class "text-center text-xs text-stone-400" "loading...")))
|
|
|
|
(defcomp ~end-of-results (&key cls)
|
|
(div :class (or cls "col-span-full mt-4 text-center text-xs text-stone-400") "End of results"))
|
|
|
|
;; ---------------------------------------------------------------------------
|
|
;; Shared empty state — icon + message + optional action
|
|
;; ---------------------------------------------------------------------------
|
|
|
|
(defcomp ~empty-state (&key icon message cls &rest children)
|
|
(div :class (or cls "p-8 text-center text-stone-400")
|
|
(when icon (div (i :class (str icon " text-4xl mb-2") :aria-hidden "true")))
|
|
(p message)
|
|
children))
|
|
|
|
;; ---------------------------------------------------------------------------
|
|
;; Shared badge — inline pill with configurable colours
|
|
;; ---------------------------------------------------------------------------
|
|
|
|
(defcomp ~badge (&key label cls)
|
|
(span :class (str "inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium " (or cls "bg-stone-100 text-stone-700"))
|
|
label))
|
|
|
|
;; ---------------------------------------------------------------------------
|
|
;; Shared delete button with confirm dialog
|
|
;; ---------------------------------------------------------------------------
|
|
|
|
(defcomp ~delete-btn (&key url trigger-target title text confirm-text cancel-text
|
|
sx-headers cls)
|
|
(button :type "button"
|
|
:data-confirm "" :data-confirm-title (or title "Delete?")
|
|
:data-confirm-text (or text "Are you sure?")
|
|
:data-confirm-icon "warning"
|
|
:data-confirm-confirm-text (or confirm-text "Yes, delete")
|
|
:data-confirm-cancel-text (or cancel-text "Cancel")
|
|
:data-confirm-event "confirmed"
|
|
:sx-delete url :sx-trigger "confirmed"
|
|
:sx-target trigger-target :sx-swap "outerHTML"
|
|
:sx-headers sx-headers
|
|
:class (or cls "px-3 py-1 text-sm bg-red-200 hover:bg-red-300 rounded text-red-800")
|
|
(i :class "fa fa-trash") " Delete"))
|
|
|
|
;; ---------------------------------------------------------------------------
|
|
;; Shared price display — special + regular with strikethrough
|
|
;; ---------------------------------------------------------------------------
|
|
|
|
(defcomp ~price (&key special-price regular-price)
|
|
(div :class "mt-1 flex items-baseline gap-2 justify-center"
|
|
(when special-price (div :class "text-lg font-semibold text-emerald-700" special-price))
|
|
(when (and special-price regular-price) (div :class "text-sm line-through text-stone-500" regular-price))
|
|
(when (and (not special-price) regular-price) (div :class "mt-1 text-lg font-semibold" regular-price))))
|
|
|
|
;; ---------------------------------------------------------------------------
|
|
;; Shared image-or-placeholder
|
|
;; ---------------------------------------------------------------------------
|
|
|
|
(defcomp ~img-or-placeholder (&key src alt size-cls placeholder-icon placeholder-text)
|
|
(if src
|
|
(img :src src :alt (or alt "") :class (or size-cls "w-12 h-12 rounded-full object-cover flex-shrink-0"))
|
|
(div :class (str (or size-cls "w-12 h-12 rounded-full") " bg-stone-200 flex items-center justify-center flex-shrink-0")
|
|
(if placeholder-icon
|
|
(i :class (str placeholder-icon " text-stone-400") :aria-hidden "true")
|
|
(when placeholder-text placeholder-text)))))
|
|
|
|
;; ---------------------------------------------------------------------------
|
|
;; Shared view toggle — list/tile view switcher
|
|
;; ---------------------------------------------------------------------------
|
|
|
|
(defcomp ~list-svg ()
|
|
(svg :xmlns "http://www.w3.org/2000/svg" :class "h-5 w-5" :fill "none" :viewBox "0 0 24 24"
|
|
:stroke "currentColor" :stroke-width "2"
|
|
(path :stroke-linecap "round" :stroke-linejoin "round" :d "M4 6h16M4 12h16M4 18h16")))
|
|
|
|
(defcomp ~tile-svg ()
|
|
(svg :xmlns "http://www.w3.org/2000/svg" :class "h-5 w-5" :fill "none" :viewBox "0 0 24 24"
|
|
:stroke "currentColor" :stroke-width "2"
|
|
(path :stroke-linecap "round" :stroke-linejoin "round"
|
|
:d "M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z")))
|
|
|
|
(defcomp ~view-toggle (&key list-href tile-href hx-select list-cls tile-cls
|
|
storage-key list-svg tile-svg)
|
|
(div :class "hidden md:flex justify-end px-3 pt-3 gap-1"
|
|
(a :href list-href :sx-get list-href :sx-target "#main-panel" :sx-select hx-select
|
|
:sx-swap "outerHTML" :sx-push-url "true" :class (str "p-1.5 rounded " list-cls) :title "List view"
|
|
:_ (str "on click js localStorage.removeItem('" (or storage-key "view") "') end")
|
|
(or list-svg (~list-svg)))
|
|
(a :href tile-href :sx-get tile-href :sx-target "#main-panel" :sx-select hx-select
|
|
:sx-swap "outerHTML" :sx-push-url "true" :class (str "p-1.5 rounded " tile-cls) :title "Tile view"
|
|
:_ (str "on click js localStorage.setItem('" (or storage-key "view") "','tile') end")
|
|
(or tile-svg (~tile-svg)))))
|
|
|
|
;; ---------------------------------------------------------------------------
|
|
;; Shared CRUD admin panel — for calendars, markets, etc.
|
|
;; ---------------------------------------------------------------------------
|
|
|
|
(defcomp ~crud-create-form (&key create-url csrf errors-id list-id placeholder label btn-label)
|
|
(<>
|
|
(div :id (or errors-id "crud-create-errors") :class "mt-2 text-sm text-red-600")
|
|
(form :class "mt-4 flex gap-2 items-end" :sx-post create-url
|
|
:sx-target (str "#" (or list-id "crud-list")) :sx-select (str "#" (or list-id "crud-list")) :sx-swap "outerHTML"
|
|
:sx-on:beforeRequest (str "document.querySelector('#" (or errors-id "crud-create-errors") "').textContent='';")
|
|
:sx-on:responseError (str "document.querySelector('#" (or errors-id "crud-create-errors") "').textContent='Error'; if(event.detail.response){event.detail.response.clone().text().then(function(t){event.target.closest('form').querySelector('[id$=errors]').innerHTML=t})}")
|
|
(input :type "hidden" :name "csrf_token" :value csrf)
|
|
(div :class "flex-1"
|
|
(label :class "block text-sm text-gray-600" (or label "Name"))
|
|
(input :name "name" :type "text" :required true :class "w-full border rounded px-3 py-2"
|
|
:placeholder (or placeholder "Name")))
|
|
(button :type "submit" :class "border rounded px-3 py-2" (or btn-label "Add")))))
|
|
|
|
(defcomp ~crud-panel (&key form list list-id)
|
|
(section :class "p-4"
|
|
form
|
|
(div :id (or list-id "crud-list") :class "mt-6" list)))
|
|
|
|
(defcomp ~crud-item (&key href name slug del-url csrf-hdr list-id
|
|
confirm-title confirm-text)
|
|
(div :class "mt-6 border rounded-lg p-4"
|
|
(div :class "flex items-center justify-between gap-3"
|
|
(a :class "flex items-baseline gap-3" :href href
|
|
:sx-get href :sx-target "#main-panel" :sx-select "#main-panel" :sx-swap "outerHTML" :sx-push-url "true"
|
|
(h3 :class "font-semibold" name)
|
|
(h4 :class "text-gray-500" (str "/" slug "/")))
|
|
(button :class "text-sm border rounded px-3 py-1 hover:bg-red-50 hover:border-red-400"
|
|
:data-confirm true :data-confirm-title (or confirm-title "Delete?")
|
|
:data-confirm-text (or confirm-text "This will be soft deleted")
|
|
:data-confirm-icon "warning" :data-confirm-confirm-text "Yes, delete it"
|
|
:data-confirm-cancel-text "Cancel" :data-confirm-event "confirmed"
|
|
:sx-delete del-url :sx-trigger "confirmed"
|
|
:sx-target (str "#" (or list-id "crud-list")) :sx-select (str "#" (or list-id "crud-list")) :sx-swap "outerHTML"
|
|
:sx-headers csrf-hdr
|
|
(i :class "fa-solid fa-trash")))))
|
|
|
|
;; ---------------------------------------------------------------------------
|
|
;; Shared SumUp settings form — payment credentials (merchant code, API key,
|
|
;; checkout prefix) used by blog, events, and cart admin panels.
|
|
;; ---------------------------------------------------------------------------
|
|
|
|
(defcomp ~sumup-settings-form (&key update-url csrf merchant-code placeholder
|
|
input-cls sumup-configured checkout-prefix
|
|
panel-id sx-select)
|
|
(div :id (or panel-id "payments-panel") :class "space-y-4 p-4 bg-white rounded-lg border border-stone-200"
|
|
(h3 :class "text-lg font-semibold text-stone-800"
|
|
(i :class "fa fa-credit-card text-purple-600 mr-1") " SumUp Payment")
|
|
(p :class "text-xs text-stone-400 mt-1 mb-3"
|
|
"Configure per-page SumUp credentials. Leave blank to use the global merchant account.")
|
|
(form :sx-put update-url
|
|
:sx-target (str "#" (or panel-id "payments-panel"))
|
|
:sx-swap "outerHTML"
|
|
:sx-select sx-select
|
|
:class "space-y-3"
|
|
(when csrf (input :type "hidden" :name "csrf_token" :value csrf))
|
|
(div (label :class "block text-xs font-medium text-stone-600 mb-1" "Merchant Code")
|
|
(input :type "text" :name "merchant_code" :value merchant-code :placeholder "e.g. ME4J6100"
|
|
:class (or input-cls "w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500")))
|
|
(div (label :class "block text-xs font-medium text-stone-600 mb-1" "API Key")
|
|
(input :type "password" :name "api_key" :value "" :placeholder placeholder
|
|
:class (or input-cls "w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500"))
|
|
(when sumup-configured (p :class "text-xs text-stone-400 mt-0.5" "Key is set. Leave blank to keep current key.")))
|
|
(div (label :class "block text-xs font-medium text-stone-600 mb-1" "Checkout Reference Prefix")
|
|
(input :type "text" :name "checkout_prefix" :value checkout-prefix :placeholder "e.g. ROSE-"
|
|
:class (or input-cls "w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500")))
|
|
(button :type "submit"
|
|
:class "px-4 py-1.5 text-sm font-medium text-white bg-purple-600 rounded hover:bg-purple-700 focus:ring-2 focus:ring-purple-500"
|
|
"Save SumUp Settings")
|
|
(when sumup-configured (span :class "ml-2 text-xs text-green-600"
|
|
(i :class "fa fa-check-circle") " Connected")))))
|
|
|
|
;; ---------------------------------------------------------------------------
|
|
;; Shared avatar — image or initial-letter placeholder
|
|
;; ---------------------------------------------------------------------------
|
|
|
|
(defcomp ~avatar (&key src cls initial)
|
|
(if src
|
|
(img :src src :alt "" :class cls)
|
|
(div :class cls initial)))
|
|
|
|
;; ---------------------------------------------------------------------------
|
|
;; Shared scroll-nav wrapper — horizontal scrollable nav with arrows
|
|
;; ---------------------------------------------------------------------------
|
|
|
|
(defcomp ~scroll-nav-wrapper (&key wrapper-id container-id arrow-cls left-hs scroll-hs right-hs items oob)
|
|
(div :class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"
|
|
:id wrapper-id :sx-swap-oob (if oob "outerHTML" nil)
|
|
(button :class (str (or arrow-cls "nav-arrow") " hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded")
|
|
:aria-label "Scroll left"
|
|
:_ left-hs (i :class "fa fa-chevron-left"))
|
|
(div :id container-id
|
|
:class "overflow-y-auto sm:overflow-x-auto sm:overflow-y-visible scrollbar-hide max-h-[50vh] sm:max-h-none"
|
|
:style "scroll-behavior: smooth;" :_ scroll-hs
|
|
(div :class "flex flex-col sm:flex-row gap-1" items))
|
|
(style ".scrollbar-hide::-webkit-scrollbar { display: none; } .scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }")
|
|
(button :class (str (or arrow-cls "nav-arrow") " hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded")
|
|
:aria-label "Scroll right"
|
|
:_ right-hs (i :class "fa fa-chevron-right"))))
|