;; 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 ~shared:misc/rich-text (&key (html :as string)) (raw! html)) (defcomp ~shared:misc/error-inline (&key (message :as string)) (div :class "text-red-600 text-sm" message)) (defcomp ~shared:misc/notification-badge (&key (count :as number)) (span :class "bg-red-500 text-white text-xs rounded-full px-1.5 py-0.5" count)) (defcomp ~shared:misc/cache-cleared (&key (time-str :as string)) (span :class "text-green-600 font-bold" "Cache cleared at " time-str)) (defcomp ~shared:misc/error-list (&key (items :as list?)) (ul :class "list-disc pl-5 space-y-1 text-sm text-red-600" (when items items))) (defcomp ~shared:misc/error-list-item (&key (message :as string)) (li message)) (defcomp ~shared:misc/fragment-error (&key (service :as string)) (p :class "text-sm text-red-600" "Service " (b service) " is unavailable.")) (defcomp ~shared:misc/htmx-sentinel (&key (id :as string) (hx-get :as string) (hx-trigger :as string) (hx-swap :as string) (class :as string?) extra-attrs) (div :id id :sx-get hx-get :sx-trigger hx-trigger :sx-swap hx-swap :class class)) (defcomp ~shared:misc/nav-group-link (&key (href :as string) (hx-select :as string?) (nav-class :as string?) (label :as string)) (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 ~shared:misc/sentinel-mobile (&key (id :as string) (next-url :as string) (hyperscript :as string?)) (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 ~shared:misc/sentinel-desktop (&key (id :as string) (next-url :as string) (hyperscript :as string?)) (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 ~shared:misc/sentinel-simple (&key (id :as string) (next-url :as string)) (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 ~shared:misc/end-of-results (&key (cls :as string?)) (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 ~shared:misc/empty-state (&key (icon :as string?) (message :as string) (cls :as string?) &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 ~shared:misc/badge (&key (label :as string) (cls :as string?)) (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 ~shared:misc/delete-btn (&key (url :as string) (trigger-target :as string) (title :as string?) (text :as string?) (confirm-text :as string?) (cancel-text :as string?) (sx-headers :as string?) (cls :as string?)) (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 ~shared:misc/price (&key (special-price :as string?) (regular-price :as string?)) (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 ~shared:misc/img-or-placeholder (&key (src :as string?) (alt :as string?) (size-cls :as string?) (placeholder-icon :as string?) (placeholder-text :as string?)) (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 ~shared:misc/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 ~shared:misc/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 ~shared:misc/view-toggle (&key (list-href :as string) (tile-href :as string) (hx-select :as string?) (list-cls :as string?) (tile-cls :as string?) (storage-key :as string?) 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 (~shared:misc/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 (~shared:misc/tile-svg))))) ;; --------------------------------------------------------------------------- ;; Shared CRUD admin panel — for calendars, markets, etc. ;; --------------------------------------------------------------------------- (defcomp ~shared:misc/crud-create-form (&key (create-url :as string) (csrf :as string) (errors-id :as string?) (list-id :as string?) (placeholder :as string?) (label :as string?) (btn-label :as string?)) (<> (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 ~shared:misc/crud-panel (&key form list (list-id :as string?)) (section :class "p-4" form (div :id (or list-id "crud-list") :class "mt-6" list))) (defcomp ~shared:misc/crud-item (&key (href :as string) (name :as string) (slug :as string) (del-url :as string) (csrf-hdr :as string) (list-id :as string?) (confirm-title :as string?) (confirm-text :as string?)) (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 ~shared:misc/sumup-settings-form (&key (update-url :as string) (csrf :as string?) (merchant-code :as string?) (placeholder :as string?) (input-cls :as string?) (sumup-configured :as boolean?) (checkout-prefix :as string?) (panel-id :as string?) (sx-select :as string?)) (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 ~shared:misc/avatar (&key (src :as string?) (cls :as string?) (initial :as string?)) (if src (img :src src :alt "" :class cls) (div :class cls initial))) ;; --------------------------------------------------------------------------- ;; Shared scroll-nav wrapper — horizontal scrollable nav with arrows ;; --------------------------------------------------------------------------- (defcomp ~shared:misc/scroll-nav-wrapper (&key (wrapper-id :as string) (container-id :as string) (arrow-cls :as string?) (left-hs :as string?) (scroll-hs :as string?) (right-hs :as string?) items (oob :as boolean?)) (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"))))