Rename all 1,169 components to path-based names with namespace support
Component names now reflect filesystem location using / as path separator and : as namespace separator for shared components: ~sx-header → ~layouts/header ~layout-app-body → ~shared:layout/app-body ~blog-admin-dashboard → ~admin/dashboard 209 files, 4,941 replacements across all services. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -62,7 +62,7 @@ def _sx_error_page(errnum: str, message: str, image: str | None = None) -> str:
|
||||
from shared.sx.page import render_page
|
||||
|
||||
return render_page(
|
||||
'(~error-page :title title :message message :image image :asset-url "/static")',
|
||||
'(~shared:pages/error-page :title title :message message :image image :asset-url "/static")',
|
||||
title=f"{errnum} Error",
|
||||
message=message,
|
||||
image=image,
|
||||
|
||||
@@ -170,7 +170,7 @@ def compute_all_deps(env: dict[str, Any]) -> None:
|
||||
def scan_components_from_sx(source: str) -> set[str]:
|
||||
"""Extract component names referenced in SX source text.
|
||||
|
||||
Returns names with ~ prefix, e.g. {"~card", "~nav-link"}.
|
||||
Returns names with ~ prefix, e.g. {"~card", "~shared:layout/nav-link"}.
|
||||
"""
|
||||
if _use_ref():
|
||||
from .ref.sx_ref import scan_components_from_source as _ref_sc
|
||||
|
||||
@@ -90,7 +90,7 @@ def mobile_menu_sx(*sections: str) -> SxExpr:
|
||||
|
||||
|
||||
async def mobile_root_nav_sx(ctx: dict) -> str:
|
||||
"""Root-level mobile nav via ~mobile-root-nav component."""
|
||||
"""Root-level mobile nav via ~shared:layout/mobile-root-nav component."""
|
||||
nav_tree = ctx.get("nav_tree") or ""
|
||||
auth_menu = ctx.get("auth_menu") or ""
|
||||
if not nav_tree and not auth_menu:
|
||||
@@ -263,7 +263,7 @@ async def oob_header_sx(parent_id: str, child_id: str, row_sx: str) -> str:
|
||||
"""Wrap a header row sx in an OOB swap.
|
||||
|
||||
child_id is accepted for call-site compatibility but no longer used —
|
||||
the child placeholder is created by ~menu-row-sx itself.
|
||||
the child placeholder is created by ~shared:layout/menu-row-sx itself.
|
||||
"""
|
||||
return await _render_to_sx("oob-header-sx",
|
||||
parent_id=parent_id,
|
||||
@@ -636,7 +636,7 @@ def sx_response(source: str, status: int = 200,
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sx wire-format full page shell
|
||||
# ---------------------------------------------------------------------------
|
||||
# The page shell is defined as ~sx-page-shell in shared/sx/templates/shell.sx
|
||||
# The page shell is defined as ~shared:shell/sx-page-shell in shared/sx/templates/shell.sx
|
||||
# and rendered via render_to_html. No HTML string templates in Python.
|
||||
|
||||
|
||||
@@ -780,7 +780,7 @@ async def sx_page(ctx: dict, page_sx: str, *,
|
||||
renders everything client-side. CSS rules are scanned from the sx
|
||||
source and component defs, then injected as a <style> block.
|
||||
|
||||
The shell is rendered from the ~sx-page-shell SX component
|
||||
The shell is rendered from the ~shared:shell/sx-page-shell SX component
|
||||
(shared/sx/templates/shell.sx).
|
||||
"""
|
||||
from .jinja_bridge import components_for_page, css_classes_for_page
|
||||
|
||||
@@ -6,7 +6,7 @@ can coexist during incremental migration:
|
||||
|
||||
**Jinja → s-expression** (use s-expression components inside Jinja templates)::
|
||||
|
||||
{{ sx('(~link-card :slug "apple" :title "Apple")') | safe }}
|
||||
{{ sx('(~shared:fragments/link-card :slug "apple" :title "Apple")') | safe }}
|
||||
|
||||
**S-expression → Jinja** (embed Jinja output inside s-expressions)::
|
||||
|
||||
@@ -220,7 +220,7 @@ def register_components(sx_source: str) -> None:
|
||||
Typically called at app startup::
|
||||
|
||||
register_components('''
|
||||
(defcomp ~link-card (&key link title image icon)
|
||||
(defcomp ~shared:fragments/link-card (&key link title image icon)
|
||||
(a :href link :class "block rounded ..."
|
||||
(div :class "flex ..."
|
||||
(if image
|
||||
@@ -269,7 +269,7 @@ def sx(source: str, **kwargs: Any) -> str:
|
||||
Keyword arguments are merged into the evaluation environment,
|
||||
so Jinja context variables can be passed through::
|
||||
|
||||
{{ sx('(~link-card :title title :slug slug)',
|
||||
{{ sx('(~shared:fragments/link-card :title title :slug slug)',
|
||||
title=post.title, slug=post.slug) | safe }}
|
||||
|
||||
This is a synchronous function — suitable for Jinja globals.
|
||||
|
||||
@@ -15,7 +15,7 @@ Usage::
|
||||
|
||||
# Error pages (no context needed)
|
||||
html = render_page(
|
||||
'(~error-page :title "Not Found" :message "NOT FOUND" :image img :asset-url aurl)',
|
||||
'(~shared:pages/error-page :title "Not Found" :message "NOT FOUND" :image img :asset-url aurl)',
|
||||
image="/static/errors/404.gif",
|
||||
asset_url="/static",
|
||||
)
|
||||
|
||||
@@ -179,9 +179,9 @@ async def _eval_slot(expr: Any, env: dict, ctx: Any) -> str:
|
||||
|
||||
|
||||
def _replace_suspense_sexp(sx: str, stream_id: str, replacement: str) -> str:
|
||||
"""Replace a rendered ~suspense div in SX source with replacement content.
|
||||
"""Replace a rendered ~shared:pages/suspense div in SX source with replacement content.
|
||||
|
||||
After _eval_slot, ~suspense expands to:
|
||||
After _eval_slot, ~shared:pages/suspense expands to:
|
||||
(div :id "sx-suspense-{id}" :data-suspense "{id}" :style "display:contents" ...)
|
||||
This finds the balanced s-expression containing :data-suspense "{id}" and
|
||||
replaces it with the given replacement string.
|
||||
@@ -277,7 +277,7 @@ async def execute_page(
|
||||
if page_def.shell_expr is not None:
|
||||
shell_sx = await _eval_slot(page_def.shell_expr, env, ctx)
|
||||
# Replace each rendered suspense div with resolved content.
|
||||
# _eval_slot expands ~suspense into:
|
||||
# _eval_slot expands ~shared:pages/suspense into:
|
||||
# (div :id "sx-suspense-X" :data-suspense "X" :style "display:contents" ...)
|
||||
# We find the balanced s-expr containing :data-suspense "X" and replace it.
|
||||
for stream_id, chunk_sx in chunks:
|
||||
@@ -534,16 +534,16 @@ async def execute_page_streaming(
|
||||
# Render to HTML so [data-suspense] elements are real DOM immediately.
|
||||
# No dependency on sx-browser.js boot timing for the initial shell.
|
||||
|
||||
suspense_header_sx = f'(~suspense :id "stream-headers" :fallback {header_fallback})'
|
||||
suspense_header_sx = f'(~shared:pages/suspense :id "stream-headers" :fallback {header_fallback})'
|
||||
|
||||
# When :shell is provided, it renders directly as the content slot
|
||||
# (it contains its own ~suspense for the data-dependent part).
|
||||
# (it contains its own ~shared:pages/suspense for the data-dependent part).
|
||||
# Otherwise, wrap the entire :content in a single suspense.
|
||||
if page_def.shell_expr is not None:
|
||||
shell_content_sx = await _eval_slot(page_def.shell_expr, env, ctx)
|
||||
suspense_content_sx = shell_content_sx
|
||||
else:
|
||||
suspense_content_sx = f'(~suspense :id "stream-content" :fallback {fallback_sx})'
|
||||
suspense_content_sx = f'(~shared:pages/suspense :id "stream-content" :fallback {fallback_sx})'
|
||||
|
||||
initial_page_html = await _helpers_render_to_html("app-body",
|
||||
header_rows=SxExpr(suspense_header_sx),
|
||||
@@ -551,7 +551,7 @@ async def execute_page_streaming(
|
||||
)
|
||||
|
||||
# Include layout component refs + page content so the scan picks up
|
||||
# their transitive deps (e.g. ~cart-mini, ~auth-menu in headers).
|
||||
# their transitive deps (e.g. ~shared:fragments/cart-mini, ~auth-menu in headers).
|
||||
layout_refs = ""
|
||||
if layout is not None and hasattr(layout, "component_names"):
|
||||
layout_refs = " ".join(f"({n})" for n in layout.component_names)
|
||||
@@ -561,14 +561,14 @@ async def execute_page_streaming(
|
||||
shell_ref = ""
|
||||
if page_def.shell_expr is not None:
|
||||
shell_ref = sx_serialize(page_def.shell_expr)
|
||||
page_sx_for_scan = f'(<> {layout_refs} {content_ref} {shell_ref} (~app-body :header-rows {suspense_header_sx} :content {suspense_content_sx}))'
|
||||
page_sx_for_scan = f'(<> {layout_refs} {content_ref} {shell_ref} (~shared:layout/app-body :header-rows {suspense_header_sx} :content {suspense_content_sx}))'
|
||||
shell, tail = sx_page_streaming_parts(
|
||||
tctx, initial_page_html, page_sx=page_sx_for_scan,
|
||||
)
|
||||
|
||||
# Capture component env + extras scanner while we still have context.
|
||||
# Resolved SX may reference components not in the initial scan
|
||||
# (e.g. ~cart-mini from IO-generated header content).
|
||||
# (e.g. ~shared:fragments/cart-mini from IO-generated header content).
|
||||
from .jinja_bridge import components_for_page as _comp_scan
|
||||
from quart import current_app as _ca
|
||||
_service = _ca.name
|
||||
|
||||
@@ -6,26 +6,26 @@
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
;; Auth section nav items (newsletters link + account_nav slot)
|
||||
(defcomp ~auth-nav-items (&key (account-url :as string?) (select-colours :as string?) account-nav)
|
||||
(defcomp ~shared:auth/nav-items (&key (account-url :as string?) (select-colours :as string?) account-nav)
|
||||
(<>
|
||||
(~nav-link :href (str (or account-url "") "/newsletters/")
|
||||
(~shared:layout/nav-link :href (str (or account-url "") "/newsletters/")
|
||||
:label "newsletters"
|
||||
:select-colours (or select-colours ""))
|
||||
(when account-nav account-nav)))
|
||||
|
||||
;; Auth header row — wraps ~menu-row-sx for account section
|
||||
(defcomp ~auth-header-row (&key (account-url :as string?) (select-colours :as string?) account-nav (oob :as boolean?))
|
||||
(~menu-row-sx :id "auth-row" :level 1 :colour "sky"
|
||||
;; Auth header row — wraps ~shared:layout/menu-row-sx for account section
|
||||
(defcomp ~shared:auth/header-row (&key (account-url :as string?) (select-colours :as string?) account-nav (oob :as boolean?))
|
||||
(~shared:layout/menu-row-sx :id "auth-row" :level 1 :colour "sky"
|
||||
:link-href (str (or account-url "") "/")
|
||||
:link-label "account" :icon "fa-solid fa-user"
|
||||
:nav (~auth-nav-items :account-url account-url
|
||||
:nav (~shared:auth/nav-items :account-url account-url
|
||||
:select-colours select-colours
|
||||
:account-nav account-nav)
|
||||
:child-id "auth-header-child" :oob oob))
|
||||
|
||||
;; Auth header row without nav (for cart service)
|
||||
(defcomp ~auth-header-row-simple (&key (account-url :as string?) (oob :as boolean?))
|
||||
(~menu-row-sx :id "auth-row" :level 1 :colour "sky"
|
||||
(defcomp ~shared:auth/header-row-simple (&key (account-url :as string?) (oob :as boolean?))
|
||||
(~shared:layout/menu-row-sx :id "auth-row" :level 1 :colour "sky"
|
||||
:link-href (str (or account-url "") "/")
|
||||
:link-label "account" :icon "fa-solid fa-user"
|
||||
:child-id "auth-header-child" :oob oob))
|
||||
@@ -34,26 +34,26 @@
|
||||
;; Expands inline (defmacro) so IO calls resolve in _aser mode.
|
||||
(defmacro ~auth-header-row-auto (oob)
|
||||
(quasiquote
|
||||
(~auth-header-row :account-url (app-url "account" "")
|
||||
(~shared:auth/header-row :account-url (app-url "account" "")
|
||||
:select-colours (select-colours)
|
||||
:account-nav (account-nav-ctx)
|
||||
:oob (unquote oob))))
|
||||
|
||||
(defmacro ~auth-header-row-simple-auto (oob)
|
||||
(quasiquote
|
||||
(~auth-header-row-simple :account-url (app-url "account" "")
|
||||
(~shared:auth/header-row-simple :account-url (app-url "account" "")
|
||||
:oob (unquote oob))))
|
||||
|
||||
;; Auto-fetching auth nav items — for mobile menus
|
||||
(defmacro ~auth-nav-items-auto ()
|
||||
(quasiquote
|
||||
(~auth-nav-items :account-url (app-url "account" "")
|
||||
(~shared:auth/nav-items :account-url (app-url "account" "")
|
||||
:select-colours (select-colours)
|
||||
:account-nav (account-nav-ctx))))
|
||||
|
||||
;; Orders header row
|
||||
(defcomp ~orders-header-row (&key (list-url :as string))
|
||||
(~menu-row-sx :id "orders-row" :level 2 :colour "sky"
|
||||
(defcomp ~shared:auth/orders-header-row (&key (list-url :as string))
|
||||
(~shared:layout/menu-row-sx :id "orders-row" :level 2 :colour "sky"
|
||||
:link-href list-url :link-label "Orders" :icon "fa fa-gbp"
|
||||
:child-id "orders-header-child"))
|
||||
|
||||
@@ -61,12 +61,12 @@
|
||||
;; Auth forms — login flow, check email
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~auth-error-banner (&key (error :as string?))
|
||||
(defcomp ~shared:auth/error-banner (&key (error :as string?))
|
||||
(when error
|
||||
(div :class "bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4"
|
||||
error)))
|
||||
|
||||
(defcomp ~auth-login-form (&key error (action :as string) (csrf-token :as string) (email :as string?))
|
||||
(defcomp ~shared:auth/login-form (&key error (action :as string) (csrf-token :as string) (email :as string?))
|
||||
(div :class "py-8 max-w-md mx-auto"
|
||||
(h1 :class "text-2xl font-bold mb-6" "Sign in")
|
||||
error
|
||||
@@ -80,12 +80,12 @@
|
||||
:class "w-full bg-stone-800 text-white py-2 px-4 rounded hover:bg-stone-700 transition"
|
||||
"Send magic link"))))
|
||||
|
||||
(defcomp ~auth-check-email-error (&key (error :as string?))
|
||||
(defcomp ~shared:auth/check-email-error (&key (error :as string?))
|
||||
(when error
|
||||
(div :class "bg-yellow-50 border border-yellow-200 text-yellow-700 p-3 rounded mt-4"
|
||||
error)))
|
||||
|
||||
(defcomp ~auth-check-email (&key (email :as string) error)
|
||||
(defcomp ~shared:auth/check-email (&key (email :as string) error)
|
||||
(div :class "py-8 max-w-md mx-auto text-center"
|
||||
(h1 :class "text-2xl font-bold mb-4" "Check your email")
|
||||
(p :class "text-stone-600 mb-2" "We sent a sign-in link to " (strong email) ".")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
(defcomp ~post-card (&key (title :as string) (slug :as string) (href :as string) (feature-image :as string?)
|
||||
(defcomp ~shared:cards/post-card (&key (title :as string) (slug :as string) (href :as string) (feature-image :as string?)
|
||||
(excerpt :as string?) (status :as string?) (published-at :as string?) (updated-at :as string?)
|
||||
(publish-requested :as boolean?) (hx-select :as string?) like widgets at-bar)
|
||||
(article :class "border-b pb-6 last:border-b-0 relative"
|
||||
@@ -31,13 +31,13 @@
|
||||
(when widgets widgets)
|
||||
(when at-bar at-bar)))
|
||||
|
||||
(defcomp ~order-summary-card (&key (order-id :as string) (created-at :as string?) (description :as string?)
|
||||
(defcomp ~shared:cards/order-summary-card (&key (order-id :as string) (created-at :as string?) (description :as string?)
|
||||
(status :as string?) (currency :as string?) (total-amount :as string?))
|
||||
(div :class "rounded-2xl border border-stone-200 bg-white/80 p-4 sm:p-6 space-y-2 text-xs sm:text-sm text-stone-800"
|
||||
(p (span :class "font-medium" "Order ID:") " " (span :class "font-mono" (str "#" order-id)))
|
||||
(p (span :class "font-medium" "Created:") " " (or created-at "\u2014"))
|
||||
(p (span :class "font-medium" "Description:") " " (or description "\u2013"))
|
||||
(p (span :class "font-medium" "Status:") " " (~status-pill :status (or status "pending") :size "[11px]"))
|
||||
(p (span :class "font-medium" "Status:") " " (~shared:controls/status-pill :status (or status "pending") :size "[11px]"))
|
||||
(p (span :class "font-medium" "Currency:") " " (or currency "GBP"))
|
||||
(p (span :class "font-medium" "Total:") " "
|
||||
(if total-amount
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
(defcomp ~search-mobile (&key (current-local-href :as string) (search :as string?) (search-count :as number?)
|
||||
(defcomp ~shared:controls/search-mobile (&key (current-local-href :as string) (search :as string?) (search-count :as number?)
|
||||
(hx-select :as string?) (search-headers-mobile :as string?))
|
||||
(div :id "search-mobile-wrapper"
|
||||
:class "flex flex-row gap-2 items-center flex-1 min-w-0 pr-2"
|
||||
@@ -21,7 +21,7 @@
|
||||
:class (if (not search-count) "text-xl text-red-500" "")
|
||||
(when search (str search-count)))))
|
||||
|
||||
(defcomp ~search-desktop (&key (current-local-href :as string) (search :as string?) (search-count :as number?)
|
||||
(defcomp ~shared:controls/search-desktop (&key (current-local-href :as string) (search :as string?) (search-count :as number?)
|
||||
(hx-select :as string?) (search-headers-desktop :as string?))
|
||||
(div :id "search-desktop-wrapper"
|
||||
:class "flex flex-row gap-2 items-center"
|
||||
@@ -44,7 +44,7 @@
|
||||
:class (if (not search-count) "text-xl text-red-500" "")
|
||||
(when search (str search-count)))))
|
||||
|
||||
(defcomp ~mobile-filter (&key filter-summary action-buttons filter-details)
|
||||
(defcomp ~shared:controls/mobile-filter (&key filter-summary action-buttons filter-details)
|
||||
(details :class "group/filter p-2 md:hidden" :data-toggle-group "mobile-panels"
|
||||
(summary :class "bg-white/90"
|
||||
(div :class "flex flex-row items-start"
|
||||
@@ -64,7 +64,7 @@
|
||||
(div :id "filter-details-mobile" :style "display:contents"
|
||||
(when filter-details filter-details))))
|
||||
|
||||
(defcomp ~infinite-scroll (&key (url :as string) (page :as number) (total-pages :as number)
|
||||
(defcomp ~shared:controls/infinite-scroll (&key (url :as string) (page :as number) (total-pages :as number)
|
||||
(id-prefix :as string) (colspan :as number))
|
||||
(if (< page total-pages)
|
||||
(tr :id (str id-prefix "-sentinel-" page)
|
||||
@@ -85,7 +85,7 @@
|
||||
(tr (td :colspan colspan :class "px-3 py-4 text-center text-xs text-stone-400"
|
||||
"End of results"))))
|
||||
|
||||
(defcomp ~status-pill (&key (status :as string?) (size :as string?))
|
||||
(defcomp ~shared:controls/status-pill (&key (status :as string?) (size :as string?))
|
||||
(let* ((s (or status "pending"))
|
||||
(lower (lower s))
|
||||
(sz (or size "xs"))
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
(defcomp ~link-card (&key (link :as string) (title :as string) (image :as string?) (icon :as string?)
|
||||
(defcomp ~shared:fragments/link-card (&key (link :as string) (title :as string) (image :as string?) (icon :as string?)
|
||||
(subtitle :as string?) (detail :as string?) (data-app :as string?))
|
||||
(a :href link
|
||||
:class "block rounded border border-stone-200 bg-white hover:bg-stone-50 transition-colors no-underline"
|
||||
@@ -17,7 +17,7 @@
|
||||
(when detail
|
||||
(div :class "text-xs text-stone-400 mt-1" detail))))))
|
||||
|
||||
(defcomp ~cart-mini (&key (cart-count :as number) (blog-url :as string) (cart-url :as string) (oob :as string?))
|
||||
(defcomp ~shared:fragments/cart-mini (&key (cart-count :as number) (blog-url :as string) (cart-url :as string) (oob :as string?))
|
||||
(div :id "cart-mini"
|
||||
:sx-swap-oob oob
|
||||
(if (= cart-count 0)
|
||||
@@ -34,7 +34,7 @@
|
||||
(span :class "absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 inline-flex items-center justify-center rounded-full bg-emerald-600 text-white text-sm w-5 h-5"
|
||||
cart-count)))))
|
||||
|
||||
(defcomp ~auth-menu (&key (user-email :as string?) (account-url :as string))
|
||||
(defcomp ~shared:fragments/auth-menu (&key (user-email :as string?) (account-url :as string))
|
||||
(<>
|
||||
(span :id "auth-menu-desktop" :class "hidden md:inline-flex"
|
||||
(if user-email
|
||||
@@ -66,7 +66,7 @@
|
||||
(i :class "fa-solid fa-key")
|
||||
(span "sign in or register"))))))
|
||||
|
||||
(defcomp ~account-nav-item (&key (href :as string) (label :as string))
|
||||
(defcomp ~shared:fragments/account-nav-item (&key (href :as string) (label :as string))
|
||||
(div :class "relative nav-group"
|
||||
(a :href href
|
||||
:class "justify-center cursor-pointer flex flex-row items-center gap-2 rounded bg-stone-200 text-black p-3"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
(defcomp ~app-body (&key header-rows filter aside menu content)
|
||||
(defcomp ~shared:layout/app-body (&key header-rows filter aside menu content)
|
||||
(div :class "max-w-screen-2xl mx-auto py-1 px-1"
|
||||
(when header-rows
|
||||
(div :class "w-full"
|
||||
@@ -24,7 +24,7 @@
|
||||
(when content content)
|
||||
(div :class "pb-8")))))))
|
||||
|
||||
(defcomp ~oob-sx (&key oobs filter aside menu content)
|
||||
(defcomp ~shared:layout/oob-sx (&key oobs filter aside menu content)
|
||||
(<>
|
||||
(when oobs oobs)
|
||||
(div :id "filter" :sx-swap-oob "outerHTML"
|
||||
@@ -38,7 +38,7 @@
|
||||
:class "flex-1 md:h-full md:min-h-0 overflow-y-auto overscroll-contain js-grid-viewport"
|
||||
(when content content))))
|
||||
|
||||
(defcomp ~hamburger ()
|
||||
(defcomp ~shared:layout/hamburger ()
|
||||
(div :class "md:hidden bg-stone-200 rounded"
|
||||
(svg :class "h-12 w-12 transition-transform group-open/root:hidden block self-start"
|
||||
:viewBox "0 0 24 24" :fill "none" :stroke "currentColor"
|
||||
@@ -48,17 +48,17 @@
|
||||
:class "w-12 h-12 rotate-180 transition-transform group-open/root:block hidden self-start"
|
||||
(path :d "M6 9l6 6 6-6" :fill "currentColor"))))
|
||||
|
||||
(defcomp ~post-label (&key (feature-image :as string?) (title :as string))
|
||||
(defcomp ~shared:layout/post-label (&key (feature-image :as string?) (title :as string))
|
||||
(<> (when feature-image
|
||||
(img :src feature-image :class "h-8 w-8 rounded-full object-cover border border-stone-300 flex-shrink-0"))
|
||||
(span title)))
|
||||
|
||||
(defcomp ~page-cart-badge (&key (href :as string) (count :as string))
|
||||
(defcomp ~shared:layout/page-cart-badge (&key (href :as string) (count :as string))
|
||||
(a :href href :class "relative inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-full border border-emerald-300 bg-emerald-50 text-emerald-800 hover:bg-emerald-100 transition"
|
||||
(i :class "fa fa-shopping-cart" :aria-hidden "true")
|
||||
(span count)))
|
||||
|
||||
(defcomp ~header-row-sx (&key cart-mini (blog-url :as string?) (site-title :as string?)
|
||||
(defcomp ~shared:layout/header-row-sx (&key cart-mini (blog-url :as string?) (site-title :as string?)
|
||||
(app-label :as string?) nav-tree auth-menu nav-panel
|
||||
(settings-url :as string?) (is-admin :as boolean?) (oob :as boolean?))
|
||||
(<>
|
||||
@@ -79,13 +79,13 @@
|
||||
(when (and is-admin settings-url)
|
||||
(a :href settings-url :class "justify-center cursor-pointer flex flex-row items-center gap-2 rounded bg-stone-200 text-black p-3"
|
||||
(i :class "fa fa-cog" :aria-hidden "true"))))
|
||||
(~hamburger)))
|
||||
(~shared:layout/hamburger)))
|
||||
(div :class "block md:hidden text-md font-bold"
|
||||
(when auth-menu auth-menu))))
|
||||
|
||||
; @css bg-sky-400 bg-sky-300 bg-sky-200 bg-sky-100 bg-violet-400 bg-violet-300 bg-violet-200 bg-violet-100
|
||||
; @css aria-selected:bg-violet-200 aria-selected:text-violet-900 aria-selected:bg-stone-500 aria-selected:text-white
|
||||
(defcomp ~menu-row-sx (&key (id :as string) (level :as number?) (colour :as string?)
|
||||
(defcomp ~shared:layout/menu-row-sx (&key (id :as string) (level :as number?) (colour :as string?)
|
||||
(link-href :as string) (link-label :as string?) link-label-content
|
||||
(icon :as string?) (selected :as string?) (hx-select :as string?)
|
||||
nav (child-id :as string?) child (oob :as boolean?) (external :as boolean?))
|
||||
@@ -117,11 +117,11 @@
|
||||
(div :id child-id :class "flex flex-col w-full items-center"
|
||||
(when child child))))))
|
||||
|
||||
(defcomp ~oob-header-sx (&key (parent-id :as string) row)
|
||||
(defcomp ~shared:layout/oob-header-sx (&key (parent-id :as string) row)
|
||||
(div :id parent-id :sx-swap-oob "outerHTML" :class "flex flex-col w-full items-center"
|
||||
row))
|
||||
|
||||
(defcomp ~header-child-sx (&key (id :as string?) inner)
|
||||
(defcomp ~shared:layout/header-child-sx (&key (id :as string?) inner)
|
||||
(div :id (or id "root-header-child") :class "flex flex-col w-full items-center" inner))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
@@ -129,7 +129,7 @@
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
;; Labelled section: colour bar header + vertical nav items
|
||||
(defcomp ~mobile-menu-section (&key (label :as string) (href :as string?) (colour :as string?)
|
||||
(defcomp ~shared:layout/mobile-menu-section (&key (label :as string) (href :as string?) (colour :as string?)
|
||||
(level :as number?) items)
|
||||
(let* ((c (or colour "sky"))
|
||||
(lv (or level 1))
|
||||
@@ -143,7 +143,7 @@
|
||||
items))))
|
||||
|
||||
;; Root-level mobile nav: site nav items + auth links
|
||||
(defcomp ~mobile-root-nav (&key nav-tree auth-menu)
|
||||
(defcomp ~shared:layout/mobile-root-nav (&key nav-tree auth-menu)
|
||||
(<>
|
||||
(when nav-tree
|
||||
(div :class "flex flex-col gap-2 p-3 text-sm" nav-tree))
|
||||
@@ -156,27 +156,27 @@
|
||||
;; nested component calls in _aser are serialized without expansion.
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~root-header (&key cart-mini (blog-url :as string?) (site-title :as string?)
|
||||
(defcomp ~shared:layout/root-header (&key cart-mini (blog-url :as string?) (site-title :as string?)
|
||||
(app-label :as string?) nav-tree auth-menu nav-panel
|
||||
(settings-url :as string?) (is-admin :as boolean?) (oob :as boolean?))
|
||||
(~header-row-sx :cart-mini cart-mini :blog-url blog-url :site-title site-title
|
||||
(~shared:layout/header-row-sx :cart-mini cart-mini :blog-url blog-url :site-title site-title
|
||||
:app-label app-label :nav-tree nav-tree :auth-menu auth-menu
|
||||
:nav-panel nav-panel :settings-url settings-url :is-admin is-admin
|
||||
:oob oob))
|
||||
|
||||
(defcomp ~root-mobile (&key nav-tree auth-menu)
|
||||
(~mobile-root-nav :nav-tree nav-tree :auth-menu auth-menu))
|
||||
(defcomp ~shared:layout/root-mobile (&key nav-tree auth-menu)
|
||||
(~shared:layout/mobile-root-nav :nav-tree nav-tree :auth-menu auth-menu))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Auto-fetching header/mobile macros — use IO primitives to self-populate.
|
||||
;; These expand inline so IO calls resolve in _aser mode within layout bodies.
|
||||
;; Replaces the 10-parameter ~root-header boilerplate in layout defcomps.
|
||||
;; Replaces the 10-parameter ~shared:layout/root-header boilerplate in layout defcomps.
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defmacro ~root-header-auto (oob)
|
||||
(quasiquote
|
||||
(let ((__rhctx (root-header-ctx)))
|
||||
(~header-row-sx :cart-mini (get __rhctx "cart-mini")
|
||||
(~shared:layout/header-row-sx :cart-mini (get __rhctx "cart-mini")
|
||||
:blog-url (get __rhctx "blog-url")
|
||||
:site-title (get __rhctx "site-title")
|
||||
:app-label (get __rhctx "app-label")
|
||||
@@ -190,7 +190,7 @@
|
||||
(defmacro ~root-mobile-auto ()
|
||||
(quasiquote
|
||||
(let ((__rhctx (root-header-ctx)))
|
||||
(~mobile-root-nav :nav-tree (get __rhctx "nav-tree")
|
||||
(~shared:layout/mobile-root-nav :nav-tree (get __rhctx "nav-tree")
|
||||
:auth-menu (get __rhctx "auth-menu")))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
@@ -198,31 +198,31 @@
|
||||
;; These use ~root-header-auto / ~root-mobile-auto macros (IO primitives).
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~layout-root-full ()
|
||||
(defcomp ~shared:layout/root-full ()
|
||||
(~root-header-auto))
|
||||
|
||||
(defcomp ~layout-root-oob ()
|
||||
(~oob-header-sx :parent-id "root-header-child"
|
||||
(defcomp ~shared:layout/root-oob ()
|
||||
(~shared:layout/oob-header-sx :parent-id "root-header-child"
|
||||
:row (~root-header-auto true)))
|
||||
|
||||
(defcomp ~layout-root-mobile ()
|
||||
(defcomp ~shared:layout/root-mobile ()
|
||||
(~root-mobile-auto))
|
||||
|
||||
;; Post layout — root + post header
|
||||
(defcomp ~layout-post-full ()
|
||||
(defcomp ~shared:layout/post-full ()
|
||||
(<> (~root-header-auto)
|
||||
(~header-child-sx :inner (~post-header-auto))))
|
||||
(~shared:layout/header-child-sx :inner (~post-header-auto))))
|
||||
|
||||
(defcomp ~layout-post-oob ()
|
||||
(defcomp ~shared:layout/post-oob ()
|
||||
(<> (~post-header-auto true)
|
||||
(~oob-header-sx :parent-id "post-header-child" :row "")))
|
||||
(~shared:layout/oob-header-sx :parent-id "post-header-child" :row "")))
|
||||
|
||||
(defcomp ~layout-post-mobile ()
|
||||
(defcomp ~shared:layout/post-mobile ()
|
||||
(let ((__phctx (post-header-ctx))
|
||||
(__rhctx (root-header-ctx)))
|
||||
(<>
|
||||
(when (get __phctx "slug")
|
||||
(~mobile-menu-section
|
||||
(~shared:layout/mobile-menu-section
|
||||
:label (slice (get __phctx "title") 0 40)
|
||||
:href (get __phctx "link-href")
|
||||
:level 1
|
||||
@@ -230,35 +230,35 @@
|
||||
(~root-mobile-auto))))
|
||||
|
||||
;; Post-admin layout — root + post header with nested admin row
|
||||
(defcomp ~layout-post-admin-full (&key (selected :as string?))
|
||||
(defcomp ~shared:layout/post-admin-full (&key (selected :as string?))
|
||||
(let ((__admin-hdr (~post-admin-header-auto nil selected)))
|
||||
(<> (~root-header-auto)
|
||||
(~header-child-sx
|
||||
(~shared:layout/header-child-sx
|
||||
:inner (~post-header-auto nil)))))
|
||||
|
||||
(defcomp ~layout-post-admin-oob (&key (selected :as string?))
|
||||
(defcomp ~shared:layout/post-admin-oob (&key (selected :as string?))
|
||||
(<> (~post-header-auto true)
|
||||
(~oob-header-sx :parent-id "post-header-child"
|
||||
(~shared:layout/oob-header-sx :parent-id "post-header-child"
|
||||
:row (~post-admin-header-auto nil selected))))
|
||||
|
||||
(defcomp ~layout-post-admin-mobile (&key (selected :as string?))
|
||||
(defcomp ~shared:layout/post-admin-mobile (&key (selected :as string?))
|
||||
(let ((__phctx (post-header-ctx)))
|
||||
(<>
|
||||
(when (get __phctx "slug")
|
||||
(~mobile-menu-section
|
||||
(~shared:layout/mobile-menu-section
|
||||
:label "admin"
|
||||
:href (get __phctx "admin-href")
|
||||
:level 2
|
||||
:items (~post-admin-nav-auto selected)))
|
||||
(when (get __phctx "slug")
|
||||
(~mobile-menu-section
|
||||
(~shared:layout/mobile-menu-section
|
||||
:label (slice (get __phctx "title") 0 40)
|
||||
:href (get __phctx "link-href")
|
||||
:level 1
|
||||
:items (~post-nav-auto)))
|
||||
(~root-mobile-auto))))
|
||||
|
||||
(defcomp ~error-content (&key (errnum :as string) (message :as string) (image :as string?))
|
||||
(defcomp ~shared:layout/error-content (&key (errnum :as string) (message :as string) (image :as string?))
|
||||
(div :class "text-center p-8 max-w-lg mx-auto"
|
||||
(div :class "font-bold text-2xl md:text-4xl text-red-500 mb-4" errnum)
|
||||
(div :class "text-stone-600 mb-4" message)
|
||||
@@ -266,7 +266,7 @@
|
||||
(div :class "flex justify-center"
|
||||
(img :src image :width "300" :height "300")))))
|
||||
|
||||
(defcomp ~clear-oob-div (&key (id :as string))
|
||||
(defcomp ~shared:layout/clear-oob-div (&key (id :as string))
|
||||
(div :id id :sx-swap-oob "outerHTML"))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
@@ -280,12 +280,12 @@
|
||||
(when (get __phctx "slug")
|
||||
(<>
|
||||
(when (> (get __phctx "page-cart-count") 0)
|
||||
(~page-cart-badge :href (get __phctx "cart-href")
|
||||
(~shared:layout/page-cart-badge :href (get __phctx "cart-href")
|
||||
:count (str (get __phctx "page-cart-count"))))
|
||||
(when (get __phctx "container-nav")
|
||||
(~container-nav-wrapper :content (get __phctx "container-nav")))
|
||||
(~shared:layout/container-nav-wrapper :content (get __phctx "container-nav")))
|
||||
(when (get __phctx "is-admin")
|
||||
(~admin-cog-button :href (get __phctx "admin-href")
|
||||
(~shared:layout/admin-cog-button :href (get __phctx "admin-href")
|
||||
:is-admin-page (get __phctx "is-admin-page"))))))))
|
||||
|
||||
(defmacro ~post-header-auto (oob)
|
||||
@@ -293,9 +293,9 @@
|
||||
(quasiquote
|
||||
(let ((__phctx (post-header-ctx)))
|
||||
(when (get __phctx "slug")
|
||||
(~menu-row-sx :id "post-row" :level 1
|
||||
(~shared:layout/menu-row-sx :id "post-row" :level 1
|
||||
:link-href (get __phctx "link-href")
|
||||
:link-label-content (~post-label
|
||||
:link-label-content (~shared:layout/post-label
|
||||
:feature-image (get __phctx "feature-image")
|
||||
:title (get __phctx "title"))
|
||||
:nav (~post-nav-auto)
|
||||
@@ -310,28 +310,28 @@
|
||||
(let ((__slug (get __phctx "slug"))
|
||||
(__sc (get __phctx "select-colours")))
|
||||
(<>
|
||||
(~nav-link :href (app-url "events" (str "/" __slug "/admin/"))
|
||||
(~shared:layout/nav-link :href (app-url "events" (str "/" __slug "/admin/"))
|
||||
:label "calendars" :select-colours __sc
|
||||
:is-selected (when (= (unquote selected) "calendars") "true"))
|
||||
(~nav-link :href (app-url "market" (str "/" __slug "/admin/"))
|
||||
(~shared:layout/nav-link :href (app-url "market" (str "/" __slug "/admin/"))
|
||||
:label "markets" :select-colours __sc
|
||||
:is-selected (when (= (unquote selected) "markets") "true"))
|
||||
(~nav-link :href (app-url "cart" (str "/" __slug "/admin/payments/"))
|
||||
(~shared:layout/nav-link :href (app-url "cart" (str "/" __slug "/admin/payments/"))
|
||||
:label "payments" :select-colours __sc
|
||||
:is-selected (when (= (unquote selected) "payments") "true"))
|
||||
(~nav-link :href (app-url "blog" (str "/" __slug "/admin/entries/"))
|
||||
(~shared:layout/nav-link :href (app-url "blog" (str "/" __slug "/admin/entries/"))
|
||||
:label "entries" :select-colours __sc
|
||||
:is-selected (when (= (unquote selected) "entries") "true"))
|
||||
(~nav-link :href (app-url "blog" (str "/" __slug "/admin/data/"))
|
||||
(~shared:layout/nav-link :href (app-url "blog" (str "/" __slug "/admin/data/"))
|
||||
:label "data" :select-colours __sc
|
||||
:is-selected (when (= (unquote selected) "data") "true"))
|
||||
(~nav-link :href (app-url "blog" (str "/" __slug "/admin/preview/"))
|
||||
(~shared:layout/nav-link :href (app-url "blog" (str "/" __slug "/admin/preview/"))
|
||||
:label "preview" :select-colours __sc
|
||||
:is-selected (when (= (unquote selected) "preview") "true"))
|
||||
(~nav-link :href (app-url "blog" (str "/" __slug "/admin/edit/"))
|
||||
(~shared:layout/nav-link :href (app-url "blog" (str "/" __slug "/admin/edit/"))
|
||||
:label "edit" :select-colours __sc
|
||||
:is-selected (when (= (unquote selected) "edit") "true"))
|
||||
(~nav-link :href (app-url "blog" (str "/" __slug "/admin/settings/"))
|
||||
(~shared:layout/nav-link :href (app-url "blog" (str "/" __slug "/admin/settings/"))
|
||||
:label "settings" :select-colours __sc
|
||||
:is-selected (when (= (unquote selected) "settings") "true"))))))))
|
||||
|
||||
@@ -340,9 +340,9 @@
|
||||
(quasiquote
|
||||
(let ((__phctx (post-header-ctx)))
|
||||
(when (get __phctx "slug")
|
||||
(~menu-row-sx :id "post-admin-row" :level 2
|
||||
(~shared:layout/menu-row-sx :id "post-admin-row" :level 2
|
||||
:link-href (get __phctx "admin-href")
|
||||
:link-label-content (~post-admin-label
|
||||
:link-label-content (~shared:layout/post-admin-label
|
||||
:selected (unquote selected))
|
||||
:nav (~post-admin-nav-auto (unquote selected))
|
||||
:child-id "post-admin-header-child"
|
||||
@@ -352,27 +352,27 @@
|
||||
;; Shared nav helpers — used by post_header_sx / post_admin_header_sx
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~container-nav-wrapper (&key content)
|
||||
(defcomp ~shared:layout/container-nav-wrapper (&key content)
|
||||
(div :id "entries-calendars-nav-wrapper"
|
||||
:class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"
|
||||
content))
|
||||
|
||||
; @css justify-center cursor-pointer flex flex-row items-center gap-2 rounded bg-stone-200 text-black p-3 !bg-stone-500 !text-white
|
||||
(defcomp ~admin-cog-button (&key (href :as string) (is-admin-page :as boolean?))
|
||||
(defcomp ~shared:layout/admin-cog-button (&key (href :as string) (is-admin-page :as boolean?))
|
||||
(div :class "relative nav-group"
|
||||
(a :href href
|
||||
:class (str "justify-center cursor-pointer flex flex-row items-center gap-2 rounded bg-stone-200 text-black p-3 "
|
||||
(if is-admin-page "!bg-stone-500 !text-white" ""))
|
||||
(i :class "fa fa-cog" :aria-hidden "true"))))
|
||||
|
||||
(defcomp ~post-admin-label (&key (selected :as string?))
|
||||
(defcomp ~shared:layout/post-admin-label (&key (selected :as string?))
|
||||
(<>
|
||||
(i :class "fa fa-shield-halved" :aria-hidden "true")
|
||||
" admin"
|
||||
(when selected
|
||||
(span :class "text-white" selected))))
|
||||
|
||||
(defcomp ~nav-link (&key (href :as string) (hx-select :as string?) (label :as string?) (icon :as string?)
|
||||
(defcomp ~shared:layout/nav-link (&key (href :as string) (hx-select :as string?) (label :as string?) (icon :as string?)
|
||||
(aclass :as string?) (select-colours :as string?) (is-selected :as string?))
|
||||
(div :class "relative nav-group"
|
||||
(a :href href
|
||||
|
||||
@@ -2,33 +2,33 @@
|
||||
|
||||
;; 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 :as string))
|
||||
(defcomp ~shared:misc/rich-text (&key (html :as string))
|
||||
(raw! html))
|
||||
|
||||
(defcomp ~error-inline (&key (message :as string))
|
||||
(defcomp ~shared:misc/error-inline (&key (message :as string))
|
||||
(div :class "text-red-600 text-sm" message))
|
||||
|
||||
(defcomp ~notification-badge (&key (count :as number))
|
||||
(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 ~cache-cleared (&key (time-str :as string))
|
||||
(defcomp ~shared:misc/cache-cleared (&key (time-str :as string))
|
||||
(span :class "text-green-600 font-bold" "Cache cleared at " time-str))
|
||||
|
||||
(defcomp ~error-list (&key (items :as list?))
|
||||
(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 ~error-list-item (&key (message :as string))
|
||||
(defcomp ~shared:misc/error-list-item (&key (message :as string))
|
||||
(li message))
|
||||
|
||||
(defcomp ~fragment-error (&key (service :as string))
|
||||
(defcomp ~shared:misc/fragment-error (&key (service :as string))
|
||||
(p :class "text-sm text-red-600" "Service " (b service) " is unavailable."))
|
||||
|
||||
(defcomp ~htmx-sentinel (&key (id :as string) (hx-get :as string) (hx-trigger :as string)
|
||||
(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 ~nav-group-link (&key (href :as string) (hx-select :as string?) (nav-class :as string?) (label :as string))
|
||||
(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"
|
||||
@@ -39,7 +39,7 @@
|
||||
;; Shared sentinel components — infinite scroll triggers
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~sentinel-mobile (&key (id :as string) (next-url :as string) (hyperscript :as string?))
|
||||
(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
|
||||
@@ -50,7 +50,7 @@
|
||||
(i :class "fa fa-exclamation-triangle text-2xl")
|
||||
(p :class "mt-2" "Loading failed \u2014 retrying\u2026"))))
|
||||
|
||||
(defcomp ~sentinel-desktop (&key (id :as string) (next-url :as string) (hyperscript :as string?))
|
||||
(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
|
||||
@@ -59,20 +59,20 @@
|
||||
(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 :as string) (next-url :as string))
|
||||
(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 ~end-of-results (&key (cls :as string?))
|
||||
(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 ~empty-state (&key (icon :as string?) (message :as string) (cls :as string?) &rest children)
|
||||
(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)
|
||||
@@ -82,7 +82,7 @@
|
||||
;; Shared badge — inline pill with configurable colours
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~badge (&key (label :as string) (cls :as string?))
|
||||
(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))
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
;; Shared delete button with confirm dialog
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~delete-btn (&key (url :as string) (trigger-target :as string) (title :as string?)
|
||||
(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"
|
||||
@@ -110,7 +110,7 @@
|
||||
;; Shared price display — special + regular with strikethrough
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~price (&key (special-price :as string?) (regular-price :as string?))
|
||||
(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))
|
||||
@@ -120,7 +120,7 @@
|
||||
;; Shared image-or-placeholder
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~img-or-placeholder (&key (src :as string?) (alt :as string?) (size-cls :as string?)
|
||||
(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"))
|
||||
@@ -133,35 +133,35 @@
|
||||
;; Shared view toggle — list/tile view switcher
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~list-svg ()
|
||||
(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 ~tile-svg ()
|
||||
(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 ~view-toggle (&key (list-href :as string) (tile-href :as string) (hx-select :as string?)
|
||||
(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 (~list-svg)))
|
||||
(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 (~tile-svg)))))
|
||||
(or tile-svg (~shared:misc/tile-svg)))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Shared CRUD admin panel — for calendars, markets, etc.
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~crud-create-form (&key (create-url :as string) (csrf :as string) (errors-id :as string?)
|
||||
(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?))
|
||||
(<>
|
||||
@@ -177,12 +177,12 @@
|
||||
: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 :as string?))
|
||||
(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 ~crud-item (&key (href :as string) (name :as string) (slug :as string) (del-url :as string)
|
||||
(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"
|
||||
@@ -206,7 +206,7 @@
|
||||
;; checkout prefix) used by blog, events, and cart admin panels.
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~sumup-settings-form (&key (update-url :as string) (csrf :as string?) (merchant-code :as string?)
|
||||
(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?))
|
||||
@@ -241,7 +241,7 @@
|
||||
;; Shared avatar — image or initial-letter placeholder
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~avatar (&key (src :as string?) (cls :as string?) (initial :as string?))
|
||||
(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)))
|
||||
@@ -250,7 +250,7 @@
|
||||
;; Shared scroll-nav wrapper — horizontal scrollable nav with arrows
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~scroll-nav-wrapper (&key (wrapper-id :as string) (container-id :as string) (arrow-cls :as string?)
|
||||
(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"
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
;; Blog navigation components
|
||||
|
||||
(defcomp ~blog-nav-empty (&key wrapper-id)
|
||||
(defcomp ~shared:nav/blog-nav-empty (&key wrapper-id)
|
||||
(div :id wrapper-id :sx-swap-oob "outerHTML"))
|
||||
|
||||
(defcomp ~blog-nav-item-link (&key href hx-get selected nav-cls img label)
|
||||
(defcomp ~shared:nav/blog-nav-item-link (&key href hx-get selected nav-cls img label)
|
||||
(div (a :href href :sx-get hx-get :sx-target "#main-panel"
|
||||
:sx-swap "outerHTML" :sx-push-url "true"
|
||||
:aria-selected selected :class nav-cls
|
||||
img (span label))))
|
||||
|
||||
(defcomp ~blog-nav-item-plain (&key href selected nav-cls img label)
|
||||
(defcomp ~shared:nav/blog-nav-item-plain (&key href selected nav-cls img label)
|
||||
(div (a :href href :aria-selected selected :class nav-cls
|
||||
img (span label))))
|
||||
|
||||
;; Nav entries
|
||||
|
||||
(defcomp ~blog-nav-entries-empty ()
|
||||
(defcomp ~shared:nav/blog-nav-entries-empty ()
|
||||
(div :id "entries-calendars-nav-wrapper" :sx-swap-oob "true"))
|
||||
|
||||
(defcomp ~blog-nav-calendar-item (&key href nav-cls name)
|
||||
(defcomp ~shared:nav/blog-nav-calendar-item (&key href nav-cls name)
|
||||
(a :href href :class nav-cls
|
||||
(i :class "fa fa-calendar" :aria-hidden "true")
|
||||
(div name)))
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
(defcomp ~calendar-entry-nav (&key (href :as string) (name :as string) (date-str :as string) (nav-class :as string?))
|
||||
(defcomp ~shared:navigation/calendar-entry-nav (&key (href :as string) (name :as string) (date-str :as string) (nav-class :as string?))
|
||||
(a :href href :class nav-class
|
||||
(div :class "w-8 h-8 rounded bg-stone-200 flex-shrink-0")
|
||||
(div :class "flex-1 min-w-0"
|
||||
(div :class "font-medium truncate" name)
|
||||
(div :class "text-xs text-stone-600 truncate" date-str))))
|
||||
|
||||
(defcomp ~calendar-link-nav (&key (href :as string) (name :as string) (nav-class :as string?)
|
||||
(defcomp ~shared:navigation/calendar-link-nav (&key (href :as string) (name :as string) (nav-class :as string?)
|
||||
(is-selected :as string?) (select-colours :as string?))
|
||||
(a :href href
|
||||
:sx-get href
|
||||
@@ -18,13 +18,13 @@
|
||||
(i :class "fa fa-calendar" :aria-hidden "true")
|
||||
(span name)))
|
||||
|
||||
(defcomp ~market-link-nav (&key (href :as string) (name :as string) (nav-class :as string?)
|
||||
(defcomp ~shared:navigation/market-link-nav (&key (href :as string) (name :as string) (nav-class :as string?)
|
||||
(select-colours :as string?))
|
||||
(a :href href :class (str (or nav-class "") " " (or select-colours ""))
|
||||
(i :class "fa fa-shopping-bag" :aria-hidden "true")
|
||||
(span name)))
|
||||
|
||||
(defcomp ~relation-nav (&key (href :as string) (name :as string) (icon :as string?)
|
||||
(defcomp ~shared:navigation/relation-nav (&key (href :as string) (name :as string) (icon :as string?)
|
||||
(nav-class :as string?) (relation-type :as string?))
|
||||
(a :href href :class (or nav-class "flex items-center gap-3 rounded-lg py-2 px-3 text-sm text-stone-700 hover:bg-stone-100 transition-colors")
|
||||
(when icon
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
;; Order table rows
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~order-row-desktop (&key (oid :as string) (created :as string) (desc :as string) (total :as string)
|
||||
(defcomp ~shared:orders/row-desktop (&key (oid :as string) (created :as string) (desc :as string) (total :as string)
|
||||
(pill :as string) (status :as string) (url :as string))
|
||||
(tr :class "hidden sm:table-row border-t border-stone-100 hover:bg-stone-50/60"
|
||||
(td :class "px-3 py-2 align-top" (span :class "font-mono text-[11px] sm:text-xs" oid))
|
||||
@@ -17,7 +17,7 @@
|
||||
(td :class "px-3 py-0.5 align-top text-right"
|
||||
(a :href url :class "inline-flex items-center px-3 py-1.5 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition" "View"))))
|
||||
|
||||
(defcomp ~order-row-mobile (&key (oid :as string) (created :as string) (total :as string)
|
||||
(defcomp ~shared:orders/row-mobile (&key (oid :as string) (created :as string) (total :as string)
|
||||
(pill :as string) (status :as string) (url :as string))
|
||||
(tr :class "sm:hidden border-t border-stone-100"
|
||||
(td :colspan "5" :class "px-3 py-3"
|
||||
@@ -30,16 +30,16 @@
|
||||
(div :class "font-medium text-stone-800" total)
|
||||
(a :href url :class "inline-flex items-center px-2 py-1 text-[11px] rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition shrink-0" "View"))))))
|
||||
|
||||
(defcomp ~order-end-row ()
|
||||
(defcomp ~shared:orders/end-row ()
|
||||
(tr (td :colspan "5" :class "px-3 py-4 text-center text-xs text-stone-400"
|
||||
(~end-of-results :cls "text-center text-xs text-stone-400"))))
|
||||
(~shared:misc/end-of-results :cls "text-center text-xs text-stone-400"))))
|
||||
|
||||
(defcomp ~order-empty-state ()
|
||||
(defcomp ~shared:orders/empty-state ()
|
||||
(div :class "max-w-full px-3 py-3 space-y-3"
|
||||
(div :class "rounded-2xl border border-dashed border-stone-300 bg-white/80 p-4 sm:p-6 text-sm text-stone-700"
|
||||
"No orders yet.")))
|
||||
|
||||
(defcomp ~order-table (&key rows)
|
||||
(defcomp ~shared:orders/table (&key rows)
|
||||
(div :class "max-w-full px-3 py-3 space-y-3"
|
||||
(div :class "overflow-x-auto rounded-2xl border border-stone-200 bg-white/80"
|
||||
(table :class "min-w-full text-xs sm:text-sm"
|
||||
@@ -53,7 +53,7 @@
|
||||
(th :class "px-3 py-2 text-left font-medium" "")))
|
||||
(tbody rows)))))
|
||||
|
||||
(defcomp ~order-list-header (&key search-mobile)
|
||||
(defcomp ~shared:orders/list-header (&key search-mobile)
|
||||
(header :class "mb-6 sm:mb-8 flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4"
|
||||
(div :class "space-y-1"
|
||||
(p :class "text-xs sm:text-sm text-stone-600" "Recent orders placed via the checkout."))
|
||||
@@ -63,13 +63,13 @@
|
||||
;; Order detail panels
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~order-item-image (&key (src :as string) (alt :as string))
|
||||
(defcomp ~shared:orders/item-image (&key (src :as string) (alt :as string))
|
||||
(img :src src :alt alt :class "w-full h-full object-contain object-center" :loading "lazy" :decoding "async"))
|
||||
|
||||
(defcomp ~order-item-no-image ()
|
||||
(defcomp ~shared:orders/item-no-image ()
|
||||
(div :class "w-full h-full flex items-center justify-center text-[9px] text-stone-400" "No image"))
|
||||
|
||||
(defcomp ~order-item-row (&key (href :as string) img (title :as string) (pid :as string)
|
||||
(defcomp ~shared:orders/item-row (&key (href :as string) img (title :as string) (pid :as string)
|
||||
(qty :as string) (price :as string))
|
||||
(li (a :class "w-full py-2 flex gap-3" :href href
|
||||
(div :class "w-12 h-12 sm:w-14 sm:h-14 rounded-md bg-stone-100 flex-shrink-0 overflow-hidden" img)
|
||||
@@ -81,12 +81,12 @@
|
||||
(p qty)
|
||||
(p price))))))
|
||||
|
||||
(defcomp ~order-items-panel (&key items)
|
||||
(defcomp ~shared:orders/items-panel (&key items)
|
||||
(div :class "rounded-2xl border border-stone-200 bg-white/80 p-4 sm:p-6"
|
||||
(h2 :class "text-sm sm:text-base font-semibold mb-3" "Items")
|
||||
(ul :class "divide-y divide-stone-100 text-xs sm:text-sm" items)))
|
||||
|
||||
(defcomp ~order-calendar-entry (&key (name :as string) (pill :as string) (status :as string)
|
||||
(defcomp ~shared:orders/calendar-entry (&key (name :as string) (pill :as string) (status :as string)
|
||||
(date-str :as string) (cost :as string))
|
||||
(li :class "px-4 py-3 flex items-start justify-between text-sm"
|
||||
(div (div :class "font-medium flex items-center gap-2"
|
||||
@@ -94,19 +94,19 @@
|
||||
(div :class "text-xs text-stone-500" date-str))
|
||||
(div :class "ml-4 font-medium" cost)))
|
||||
|
||||
(defcomp ~order-calendar-section (&key items)
|
||||
(defcomp ~shared:orders/calendar-section (&key items)
|
||||
(section :class "mt-6 space-y-3"
|
||||
(h2 :class "text-base sm:text-lg font-semibold" "Calendar bookings in this order")
|
||||
(ul :class "divide-y divide-stone-200 rounded-2xl border border-stone-200 bg-white/80" items)))
|
||||
|
||||
(defcomp ~order-detail-panel (&key summary items calendar)
|
||||
(defcomp ~shared:orders/detail-panel (&key summary items calendar)
|
||||
(div :class "max-w-full px-3 py-3 space-y-4" summary items calendar))
|
||||
|
||||
(defcomp ~order-pay-btn (&key (url :as string))
|
||||
(defcomp ~shared:orders/pay-btn (&key (url :as string))
|
||||
(a :href url :class "inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-emerald-600 bg-emerald-600 text-white hover:bg-emerald-700 transition"
|
||||
(i :class "fa fa-credit-card mr-2" :aria-hidden "true") "Open payment page"))
|
||||
|
||||
(defcomp ~order-detail-filter (&key (info :as string) (list-url :as string) (recheck-url :as string)
|
||||
(defcomp ~shared:orders/detail-filter (&key (info :as string) (list-url :as string) (recheck-url :as string)
|
||||
(csrf :as string) pay)
|
||||
(header :class "mb-6 sm:mb-8 flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4"
|
||||
(div :class "space-y-1"
|
||||
@@ -120,43 +120,43 @@
|
||||
(i :class "fa-solid fa-rotate mr-2" :aria-hidden "true") "Re-check status"))
|
||||
pay)))
|
||||
|
||||
(defcomp ~order-detail-header-stack (&key auth orders order)
|
||||
(~header-child-sx :inner
|
||||
(<> auth (~header-child-sx :id "auth-header-child" :inner
|
||||
(<> orders (~header-child-sx :id "orders-header-child" :inner order))))))
|
||||
(defcomp ~shared:orders/detail-header-stack (&key auth orders order)
|
||||
(~shared:layout/header-child-sx :inner
|
||||
(<> auth (~shared:layout/header-child-sx :id "auth-header-child" :inner
|
||||
(<> orders (~shared:layout/header-child-sx :id "orders-header-child" :inner order))))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Data-driven order rows (replaces Python loop)
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~order-rows-from-data (&key (orders :as list?) (page :as number) (total-pages :as number)
|
||||
(defcomp ~shared:orders/rows-from-data (&key (orders :as list?) (page :as number) (total-pages :as number)
|
||||
(next-url :as string?))
|
||||
(<>
|
||||
(map (lambda (o)
|
||||
(<>
|
||||
(~order-row-desktop :oid (get o "oid") :created (get o "created")
|
||||
(~shared:orders/row-desktop :oid (get o "oid") :created (get o "created")
|
||||
:desc (get o "desc") :total (get o "total")
|
||||
:pill (get o "pill_desktop") :status (get o "status") :url (get o "url"))
|
||||
(~order-row-mobile :oid (get o "oid") :created (get o "created")
|
||||
(~shared:orders/row-mobile :oid (get o "oid") :created (get o "created")
|
||||
:total (get o "total") :pill (get o "pill_mobile")
|
||||
:status (get o "status") :url (get o "url"))))
|
||||
(or orders (list)))
|
||||
(if next-url
|
||||
(~infinite-scroll :url next-url :page page :total-pages total-pages
|
||||
(~shared:controls/infinite-scroll :url next-url :page page :total-pages total-pages
|
||||
:id-prefix "orders" :colspan 5)
|
||||
(~order-end-row))))
|
||||
(~shared:orders/end-row))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Data-driven order items (replaces Python loop)
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~order-items-from-data (&key (items :as list?))
|
||||
(~order-items-panel
|
||||
(defcomp ~shared:orders/items-from-data (&key (items :as list?))
|
||||
(~shared:orders/items-panel
|
||||
:items (<> (map (lambda (item)
|
||||
(let* ((img (if (get item "product_image")
|
||||
(~order-item-image :src (get item "product_image") :alt (or (get item "product_title") "Product image"))
|
||||
(~order-item-no-image))))
|
||||
(~order-item-row
|
||||
(~shared:orders/item-image :src (get item "product_image") :alt (or (get item "product_title") "Product image"))
|
||||
(~shared:orders/item-no-image))))
|
||||
(~shared:orders/item-row
|
||||
:href (get item "href") :img img
|
||||
:title (or (get item "product_title") "Unknown product")
|
||||
:pid (str "Product ID: " (get item "product_id"))
|
||||
@@ -168,10 +168,10 @@
|
||||
;; Data-driven calendar entries (replaces Python loop)
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~order-calendar-from-data (&key (entries :as list?))
|
||||
(~order-calendar-section
|
||||
(defcomp ~shared:orders/calendar-from-data (&key (entries :as list?))
|
||||
(~shared:orders/calendar-section
|
||||
:items (<> (map (lambda (e)
|
||||
(~order-calendar-entry
|
||||
(~shared:orders/calendar-entry
|
||||
:name (get e "name") :pill (get e "pill")
|
||||
:status (get e "status") :date-str (get e "date_str")
|
||||
:cost (get e "cost")))
|
||||
@@ -186,7 +186,7 @@
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
;; Status pill class mapping
|
||||
(defcomp ~order-status-pill-cls (&key (status :as string?))
|
||||
(defcomp ~shared:orders/status-pill-cls (&key (status :as string?))
|
||||
(let* ((sl (lower (or status ""))))
|
||||
(cond
|
||||
((= sl "paid") "border-emerald-300 bg-emerald-50 text-emerald-700")
|
||||
@@ -194,46 +194,46 @@
|
||||
(true "border-stone-300 bg-stone-50 text-stone-700"))))
|
||||
|
||||
;; Single order row pair (desktop + mobile) — takes serialized order data dict
|
||||
(defcomp ~order-row-pair (&key (order :as dict) (detail-url-prefix :as string))
|
||||
(defcomp ~shared:orders/row-pair (&key (order :as dict) (detail-url-prefix :as string))
|
||||
(let* ((status (or (get order "status") "pending"))
|
||||
(pill-base (~order-status-pill-cls :status status))
|
||||
(pill-base (~shared:orders/status-pill-cls :status status))
|
||||
(oid (str "#" (get order "id")))
|
||||
(created (or (get order "created_at_formatted") "\u2014"))
|
||||
(desc (or (get order "description") ""))
|
||||
(total (str (or (get order "currency") "GBP") " " (or (get order "total_formatted") "0.00")))
|
||||
(url (str detail-url-prefix (get order "id") "/")))
|
||||
(<>
|
||||
(~order-row-desktop
|
||||
(~shared:orders/row-desktop
|
||||
:oid oid :created created :desc desc :total total
|
||||
:pill (str "inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] sm:text-xs " pill-base)
|
||||
:status status :url url)
|
||||
(~order-row-mobile
|
||||
(~shared:orders/row-mobile
|
||||
:oid oid :created created :total total
|
||||
:pill (str "inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] " pill-base)
|
||||
:status status :url url))))
|
||||
|
||||
;; Assembled orders list content
|
||||
(defcomp ~orders-list-content (&key (orders :as list) (page :as number) (total-pages :as number)
|
||||
(defcomp ~shared:orders/list-content (&key (orders :as list) (page :as number) (total-pages :as number)
|
||||
(rows-url :as string) (detail-url-prefix :as string))
|
||||
(if (empty? orders)
|
||||
(~order-empty-state)
|
||||
(~order-table
|
||||
(~shared:orders/empty-state)
|
||||
(~shared:orders/table
|
||||
:rows (<>
|
||||
(map (lambda (order)
|
||||
(~order-row-pair :order order :detail-url-prefix detail-url-prefix))
|
||||
(~shared:orders/row-pair :order order :detail-url-prefix detail-url-prefix))
|
||||
orders)
|
||||
(if (< page total-pages)
|
||||
(~infinite-scroll
|
||||
(~shared:controls/infinite-scroll
|
||||
:url (str rows-url "?page=" (inc page))
|
||||
:page page :total-pages total-pages
|
||||
:id-prefix "orders" :colspan 5)
|
||||
(~order-end-row))))))
|
||||
(~shared:orders/end-row))))))
|
||||
|
||||
;; Assembled order detail content — replaces Python _order_main_sx
|
||||
(defcomp ~order-detail-content (&key (order :as dict) (calendar-entries :as list?))
|
||||
(defcomp ~shared:orders/detail-content (&key (order :as dict) (calendar-entries :as list?))
|
||||
(let* ((items (get order "items")))
|
||||
(~order-detail-panel
|
||||
:summary (~order-summary-card
|
||||
(~shared:orders/detail-panel
|
||||
:summary (~shared:cards/order-summary-card
|
||||
:order-id (get order "id")
|
||||
:created-at (get order "created_at_formatted")
|
||||
:description (get order "description")
|
||||
@@ -241,21 +241,21 @@
|
||||
:currency (get order "currency")
|
||||
:total-amount (get order "total_formatted"))
|
||||
:items (when (not (empty? (or items (list))))
|
||||
(~order-items-panel
|
||||
(~shared:orders/items-panel
|
||||
:items (map (lambda (item)
|
||||
(~order-item-row
|
||||
(~shared:orders/item-row
|
||||
:href (get item "product_url")
|
||||
:img (if (get item "product_image")
|
||||
(~order-item-image :src (get item "product_image")
|
||||
(~shared:orders/item-image :src (get item "product_image")
|
||||
:alt (or (get item "product_title") "Product image"))
|
||||
(~order-item-no-image))
|
||||
(~shared:orders/item-no-image))
|
||||
:title (or (get item "product_title") "Unknown product")
|
||||
:pid (str "Product ID: " (get item "product_id"))
|
||||
:qty (str "Qty: " (get item "quantity"))
|
||||
:price (str (or (get item "currency") (get order "currency") "GBP") " " (or (get item "unit_price_formatted") "0.00"))))
|
||||
items)))
|
||||
:calendar (when (not (empty? (or calendar-entries (list))))
|
||||
(~order-calendar-section
|
||||
(~shared:orders/calendar-section
|
||||
:items (map (lambda (e)
|
||||
(let* ((st (or (get e "state") ""))
|
||||
(pill (cond
|
||||
@@ -263,7 +263,7 @@
|
||||
((= st "provisional") "bg-amber-100 text-amber-800")
|
||||
((= st "ordered") "bg-blue-100 text-blue-800")
|
||||
(true "bg-stone-100 text-stone-700"))))
|
||||
(~order-calendar-entry
|
||||
(~shared:orders/calendar-entry
|
||||
:name (get e "name")
|
||||
:pill (str "inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium " pill)
|
||||
:status (upper (slice st 0 1))
|
||||
@@ -272,33 +272,33 @@
|
||||
calendar-entries))))))
|
||||
|
||||
;; Assembled order detail filter — replaces Python _order_filter_sx
|
||||
(defcomp ~order-detail-filter-content (&key (order :as dict) (list-url :as string) (recheck-url :as string)
|
||||
(defcomp ~shared:orders/detail-filter-content (&key (order :as dict) (list-url :as string) (recheck-url :as string)
|
||||
(pay-url :as string) (csrf :as string))
|
||||
(let* ((status (or (get order "status") "pending"))
|
||||
(created (or (get order "created_at_formatted") "\u2014")))
|
||||
(~order-detail-filter
|
||||
(~shared:orders/detail-filter
|
||||
:info (str "Placed " created " \u00b7 Status: " status)
|
||||
:list-url list-url
|
||||
:recheck-url recheck-url
|
||||
:csrf csrf
|
||||
:pay (when (!= status "paid")
|
||||
(~order-pay-btn :url pay-url)))))
|
||||
(~shared:orders/pay-btn :url pay-url)))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Checkout return components
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~checkout-return-header (&key (status :as string))
|
||||
(defcomp ~shared:orders/checkout-return-header (&key (status :as string))
|
||||
(header :class "mb-6 sm:mb-8"
|
||||
(h1 :class "text-xl sm:text-2xl md:text-3xl font-semibold tracking-tight" "Payment complete")
|
||||
(p :class "text-xs sm:text-sm text-stone-600"
|
||||
(str "Your checkout session is " status "."))))
|
||||
|
||||
(defcomp ~checkout-return-missing ()
|
||||
(defcomp ~shared:orders/checkout-return-missing ()
|
||||
(div :class "max-w-full px-3 py-3 space-y-4"
|
||||
(p :class "text-sm text-stone-600" "Order not found.")))
|
||||
|
||||
(defcomp ~checkout-return-ticket (&key (name :as string) (pill :as string) (state :as string)
|
||||
(defcomp ~shared:orders/checkout-return-ticket (&key (name :as string) (pill :as string) (state :as string)
|
||||
(type-name :as string?) (date-str :as string) (code :as string?)
|
||||
(price :as string))
|
||||
(li :class "px-4 py-3 flex items-start justify-between text-sm"
|
||||
@@ -310,23 +310,23 @@
|
||||
(when code (div :class "font-mono text-xs text-stone-400" code)))
|
||||
(div :class "ml-4 font-medium" price)))
|
||||
|
||||
(defcomp ~checkout-return-tickets (&key items)
|
||||
(defcomp ~shared:orders/checkout-return-tickets (&key items)
|
||||
(section :class "mt-6 space-y-3"
|
||||
(h2 :class "text-base sm:text-lg font-semibold" "Tickets")
|
||||
(ul :class "divide-y divide-stone-200 rounded-2xl border border-stone-200 bg-white/80" items)))
|
||||
|
||||
(defcomp ~checkout-return-failed (&key (order-id :as string?))
|
||||
(defcomp ~shared:orders/checkout-return-failed (&key (order-id :as string?))
|
||||
(div :class "rounded-lg border border-rose-200 bg-rose-50 p-4 text-sm text-rose-900"
|
||||
(p :class "font-medium" "Payment failed")
|
||||
(p "Please try again or contact support."
|
||||
(when order-id (span " Order #" (str order-id))))))
|
||||
|
||||
(defcomp ~checkout-return-paid ()
|
||||
(defcomp ~shared:orders/checkout-return-paid ()
|
||||
(div :class "rounded-lg border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-900"
|
||||
(p :class "font-medium" "Payment successful!")
|
||||
(p "Your order has been confirmed.")))
|
||||
|
||||
(defcomp ~checkout-return-content (&key summary items calendar tickets status-message)
|
||||
(defcomp ~shared:orders/checkout-return-content (&key summary items calendar tickets status-message)
|
||||
(div :class "max-w-full px-3 py-3 space-y-4"
|
||||
status-message summary items calendar tickets))
|
||||
|
||||
@@ -334,15 +334,15 @@
|
||||
;; Checkout error screens
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~checkout-error-header ()
|
||||
(defcomp ~shared:orders/checkout-error-header ()
|
||||
(header :class "mb-6 sm:mb-8"
|
||||
(h1 :class "text-xl sm:text-2xl md:text-3xl font-semibold tracking-tight" "Checkout error")
|
||||
(p :class "text-xs sm:text-sm text-stone-600" "We tried to start your payment with SumUp but hit a problem.")))
|
||||
|
||||
(defcomp ~checkout-error-order-id (&key (oid :as string))
|
||||
(defcomp ~shared:orders/checkout-error-order-id (&key (oid :as string))
|
||||
(p :class "text-xs text-rose-800/80" "Order ID: " (span :class "font-mono" oid)))
|
||||
|
||||
(defcomp ~checkout-error-content (&key (msg :as string) order (back-url :as string))
|
||||
(defcomp ~shared:orders/checkout-error-content (&key (msg :as string) order (back-url :as string))
|
||||
(div :class "max-w-full px-3 py-3 space-y-4"
|
||||
(div :class "rounded-2xl border border-rose-200 bg-rose-50/80 p-4 sm:p-6 text-sm text-rose-900 space-y-2"
|
||||
(p :class "font-medium" "Something went wrong.")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
(defcomp ~base-shell (&key (title :as string) (asset-url :as string) &rest children)
|
||||
(defcomp ~shared:pages/base-shell (&key (title :as string) (asset-url :as string) &rest children)
|
||||
(<>
|
||||
(raw! "<!doctype html>")
|
||||
(html :lang "en"
|
||||
@@ -23,14 +23,14 @@
|
||||
;; <script>__sxResolve("id", "(resolved sx ...)")</script>
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~suspense (&key (id :as string) fallback &rest children)
|
||||
(defcomp ~shared:pages/suspense (&key (id :as string) fallback &rest children)
|
||||
(div :id (str "sx-suspense-" id)
|
||||
:data-suspense id
|
||||
:style "display:contents"
|
||||
(if (not (empty? children)) children fallback)))
|
||||
|
||||
(defcomp ~error-page (&key (title :as string) (message :as string) (image :as string?) (asset-url :as string))
|
||||
(~base-shell :title title :asset-url asset-url
|
||||
(defcomp ~shared:pages/error-page (&key (title :as string) (message :as string) (image :as string?) (asset-url :as string))
|
||||
(~shared:pages/base-shell :title title :asset-url asset-url
|
||||
(div :class "text-center p-8 max-w-lg mx-auto"
|
||||
(div :class "font-bold text-2xl md:text-4xl text-red-500 mb-4"
|
||||
(div message))
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
(defcomp ~relation-attach (&key (create-url :as string) (label :as string?) (icon :as string?))
|
||||
(defcomp ~shared:relations/attach (&key (create-url :as string) (label :as string?) (icon :as string?))
|
||||
(a :href create-url
|
||||
:sx-get create-url
|
||||
:sx-target "#main-panel"
|
||||
@@ -8,7 +8,7 @@
|
||||
(when icon (i :class icon))
|
||||
(span (or label "Add"))))
|
||||
|
||||
(defcomp ~relation-detach (&key (detach-url :as string) (name :as string?))
|
||||
(defcomp ~shared:relations/detach (&key (detach-url :as string) (name :as string?))
|
||||
(button :sx-delete detach-url
|
||||
:sx-confirm (str "Remove " (or name "this item") "?")
|
||||
:class "text-red-500 hover:text-red-700 text-sm p-1 rounded hover:bg-red-50 transition-colors"
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
;; - Pre-rendered meta HTML from callers
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~sx-page-shell (&key (title :as string) (meta-html :as string?) (csrf :as string)
|
||||
(defcomp ~shared:shell/sx-page-shell (&key (title :as string) (meta-html :as string?) (csrf :as string)
|
||||
(sx-css :as string?) (sx-css-classes :as string?)
|
||||
(component-hash :as string?) (component-defs :as string?)
|
||||
(pages-sx :as string?) (page-sx :as string?)
|
||||
|
||||
@@ -14,13 +14,13 @@ def _load_components():
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ~cart-mini
|
||||
# ~shared:fragments/cart-mini
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCartMini:
|
||||
def test_empty_cart_shows_logo(self):
|
||||
html = sx(
|
||||
'(~cart-mini :cart-count cart-count :blog-url blog-url :cart-url cart-url)',
|
||||
'(~shared:fragments/cart-mini :cart-count cart-count :blog-url blog-url :cart-url cart-url)',
|
||||
**{"cart-count": 0, "blog-url": "https://blog.example.com/", "cart-url": "https://cart.example.com/"},
|
||||
)
|
||||
assert 'id="cart-mini"' in html
|
||||
@@ -30,7 +30,7 @@ class TestCartMini:
|
||||
|
||||
def test_nonempty_cart_shows_badge(self):
|
||||
html = sx(
|
||||
'(~cart-mini :cart-count cart-count :blog-url blog-url :cart-url cart-url)',
|
||||
'(~shared:fragments/cart-mini :cart-count cart-count :blog-url blog-url :cart-url cart-url)',
|
||||
**{"cart-count": 3, "blog-url": "https://blog.example.com/", "cart-url": "https://cart.example.com/"},
|
||||
)
|
||||
assert 'id="cart-mini"' in html
|
||||
@@ -41,13 +41,13 @@ class TestCartMini:
|
||||
|
||||
def test_oob_attribute(self):
|
||||
html = sx(
|
||||
'(~cart-mini :cart-count 0 :blog-url "" :cart-url "" :oob "true")',
|
||||
'(~shared:fragments/cart-mini :cart-count 0 :blog-url "" :cart-url "" :oob "true")',
|
||||
)
|
||||
assert 'sx-swap-oob="true"' in html
|
||||
|
||||
def test_no_oob_when_nil(self):
|
||||
html = sx(
|
||||
'(~cart-mini :cart-count 0 :blog-url "" :cart-url "")',
|
||||
'(~shared:fragments/cart-mini :cart-count 0 :blog-url "" :cart-url "")',
|
||||
)
|
||||
assert "sx-swap-oob" not in html
|
||||
|
||||
@@ -94,13 +94,13 @@ class TestAuthMenu:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ~account-nav-item
|
||||
# ~shared:fragments/account-nav-item
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestAccountNavItem:
|
||||
def test_renders_link(self):
|
||||
html = sx(
|
||||
'(~account-nav-item :href "/orders/" :label "orders")',
|
||||
'(~shared:fragments/account-nav-item :href "/orders/" :label "orders")',
|
||||
)
|
||||
assert 'href="/orders/"' in html
|
||||
assert ">orders<" in html
|
||||
@@ -109,19 +109,19 @@ class TestAccountNavItem:
|
||||
|
||||
def test_custom_label(self):
|
||||
html = sx(
|
||||
'(~account-nav-item :href "/cart/orders/" :label "my orders")',
|
||||
'(~shared:fragments/account-nav-item :href "/cart/orders/" :label "my orders")',
|
||||
)
|
||||
assert ">my orders<" in html
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ~calendar-entry-nav
|
||||
# ~shared:navigation/calendar-entry-nav
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCalendarEntryNav:
|
||||
def test_renders_entry(self):
|
||||
html = sx(
|
||||
'(~calendar-entry-nav :href "/events/entry/1/" :name "Workshop" :date-str "Jan 15, 2026 at 14:00" :nav-class "btn")',
|
||||
'(~shared:navigation/calendar-entry-nav :href "/events/entry/1/" :name "Workshop" :date-str "Jan 15, 2026 at 14:00" :nav-class "btn")',
|
||||
**{"date-str": "Jan 15, 2026 at 14:00", "nav-class": "btn"},
|
||||
)
|
||||
assert 'href="/events/entry/1/"' in html
|
||||
@@ -130,13 +130,13 @@ class TestCalendarEntryNav:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ~calendar-link-nav
|
||||
# ~shared:navigation/calendar-link-nav
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCalendarLinkNav:
|
||||
def test_renders_calendar_link(self):
|
||||
html = sx(
|
||||
'(~calendar-link-nav :href "/events/cal/" :name "Art Events" :nav-class "btn")',
|
||||
'(~shared:navigation/calendar-link-nav :href "/events/cal/" :name "Art Events" :nav-class "btn")',
|
||||
**{"nav-class": "btn"},
|
||||
)
|
||||
assert 'href="/events/cal/"' in html
|
||||
@@ -145,13 +145,13 @@ class TestCalendarLinkNav:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ~market-link-nav
|
||||
# ~shared:navigation/market-link-nav
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMarketLinkNav:
|
||||
def test_renders_market_link(self):
|
||||
html = sx(
|
||||
'(~market-link-nav :href "/market/farm/" :name "Farm Shop" :nav-class "btn")',
|
||||
'(~shared:navigation/market-link-nav :href "/market/farm/" :name "Farm Shop" :nav-class "btn")',
|
||||
**{"nav-class": "btn"},
|
||||
)
|
||||
assert 'href="/market/farm/"' in html
|
||||
@@ -160,13 +160,13 @@ class TestMarketLinkNav:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ~post-card
|
||||
# ~shared:cards/post-card
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestPostCard:
|
||||
def test_basic_card(self):
|
||||
html = sx(
|
||||
'(~post-card :title "Hello World" :slug "hello" :href "/hello/"'
|
||||
'(~shared:cards/post-card :title "Hello World" :slug "hello" :href "/hello/"'
|
||||
' :feature-image "/img/hello.jpg" :excerpt "A test post"'
|
||||
' :status "published" :published-at "15 Jan 2026"'
|
||||
' :hx-select "#main-panel")',
|
||||
@@ -184,7 +184,7 @@ class TestPostCard:
|
||||
|
||||
def test_draft_status(self):
|
||||
html = sx(
|
||||
'(~post-card :title "Draft" :slug "draft" :href "/draft/"'
|
||||
'(~shared:cards/post-card :title "Draft" :slug "draft" :href "/draft/"'
|
||||
' :status "draft" :updated-at "15 Jan 2026"'
|
||||
' :hx-select "#main-panel")',
|
||||
**{"hx-select": "#main-panel", "updated-at": "15 Jan 2026"},
|
||||
@@ -195,7 +195,7 @@ class TestPostCard:
|
||||
|
||||
def test_draft_with_publish_requested(self):
|
||||
html = sx(
|
||||
'(~post-card :title "Pending" :slug "pending" :href "/pending/"'
|
||||
'(~shared:cards/post-card :title "Pending" :slug "pending" :href "/pending/"'
|
||||
' :status "draft" :publish-requested true'
|
||||
' :hx-select "#main-panel")',
|
||||
**{"hx-select": "#main-panel", "publish-requested": True},
|
||||
@@ -205,7 +205,7 @@ class TestPostCard:
|
||||
|
||||
def test_no_image(self):
|
||||
html = sx(
|
||||
'(~post-card :title "No Img" :slug "no-img" :href "/no-img/"'
|
||||
'(~shared:cards/post-card :title "No Img" :slug "no-img" :href "/no-img/"'
|
||||
' :status "published" :hx-select "#main-panel")',
|
||||
**{"hx-select": "#main-panel"},
|
||||
)
|
||||
@@ -214,7 +214,7 @@ class TestPostCard:
|
||||
def test_widgets_and_at_bar(self):
|
||||
"""Widgets and at-bar are sx kwarg slots rendered by the client."""
|
||||
html = sx(
|
||||
'(~post-card :title "T" :slug "s" :href "/"'
|
||||
'(~shared:cards/post-card :title "T" :slug "s" :href "/"'
|
||||
' :status "published" :hx-select "#mp")',
|
||||
**{"hx-select": "#mp"},
|
||||
)
|
||||
@@ -224,13 +224,13 @@ class TestPostCard:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ~base-shell and ~error-page
|
||||
# ~shared:pages/base-shell and ~shared:pages/error-page
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBaseShell:
|
||||
def test_renders_full_page(self):
|
||||
html = sx(
|
||||
'(~base-shell :title "Test" :asset-url "/static" (p "Hello"))',
|
||||
'(~shared:pages/base-shell :title "Test" :asset-url "/static" (p "Hello"))',
|
||||
**{"asset-url": "/static"},
|
||||
)
|
||||
assert "<!doctype html>" in html
|
||||
@@ -243,7 +243,7 @@ class TestBaseShell:
|
||||
class TestErrorPage:
|
||||
def test_404_page(self):
|
||||
html = sx(
|
||||
'(~error-page :title "404 Error" :message "NOT FOUND" :image "/static/errors/404.gif" :asset-url "/static")',
|
||||
'(~shared:pages/error-page :title "404 Error" :message "NOT FOUND" :image "/static/errors/404.gif" :asset-url "/static")',
|
||||
**{"asset-url": "/static"},
|
||||
)
|
||||
assert "<!doctype html>" in html
|
||||
@@ -253,7 +253,7 @@ class TestErrorPage:
|
||||
|
||||
def test_error_page_no_image(self):
|
||||
html = sx(
|
||||
'(~error-page :title "500 Error" :message "SERVER ERROR" :asset-url "/static")',
|
||||
'(~shared:pages/error-page :title "500 Error" :message "SERVER ERROR" :asset-url "/static")',
|
||||
**{"asset-url": "/static"},
|
||||
)
|
||||
assert "SERVER ERROR" in html
|
||||
@@ -261,13 +261,13 @@ class TestErrorPage:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ~relation-nav
|
||||
# ~shared:navigation/relation-nav
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRelationNav:
|
||||
def test_renders_link(self):
|
||||
html = sx(
|
||||
'(~relation-nav :href "/market/farm/" :name "Farm Shop" :icon "fa fa-shopping-bag")',
|
||||
'(~shared:navigation/relation-nav :href "/market/farm/" :name "Farm Shop" :icon "fa fa-shopping-bag")',
|
||||
)
|
||||
assert 'href="/market/farm/"' in html
|
||||
assert "Farm Shop" in html
|
||||
@@ -275,7 +275,7 @@ class TestRelationNav:
|
||||
|
||||
def test_no_icon(self):
|
||||
html = sx(
|
||||
'(~relation-nav :href "/cal/" :name "Events")',
|
||||
'(~shared:navigation/relation-nav :href "/cal/" :name "Events")',
|
||||
)
|
||||
assert 'href="/cal/"' in html
|
||||
assert "Events" in html
|
||||
@@ -283,20 +283,20 @@ class TestRelationNav:
|
||||
|
||||
def test_custom_nav_class(self):
|
||||
html = sx(
|
||||
'(~relation-nav :href "/" :name "X" :nav-class "custom-class")',
|
||||
'(~shared:navigation/relation-nav :href "/" :name "X" :nav-class "custom-class")',
|
||||
**{"nav-class": "custom-class"},
|
||||
)
|
||||
assert 'class="custom-class"' in html
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ~relation-attach
|
||||
# ~shared:relations/attach
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRelationAttach:
|
||||
def test_renders_button(self):
|
||||
html = sx(
|
||||
'(~relation-attach :create-url "/market/create/" :label "Add Market" :icon "fa fa-plus")',
|
||||
'(~shared:relations/attach :create-url "/market/create/" :label "Add Market" :icon "fa fa-plus")',
|
||||
**{"create-url": "/market/create/"},
|
||||
)
|
||||
assert 'href="/market/create/"' in html
|
||||
@@ -306,20 +306,20 @@ class TestRelationAttach:
|
||||
|
||||
def test_default_label(self):
|
||||
html = sx(
|
||||
'(~relation-attach :create-url "/create/")',
|
||||
'(~shared:relations/attach :create-url "/create/")',
|
||||
**{"create-url": "/create/"},
|
||||
)
|
||||
assert "Add" in html
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ~relation-detach
|
||||
# ~shared:relations/detach
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRelationDetach:
|
||||
def test_renders_button(self):
|
||||
html = sx(
|
||||
'(~relation-detach :detach-url "/api/unrelate" :name "Farm Shop")',
|
||||
'(~shared:relations/detach :detach-url "/api/unrelate" :name "Farm Shop")',
|
||||
**{"detach-url": "/api/unrelate"},
|
||||
)
|
||||
assert 'sx-delete="/api/unrelate"' in html
|
||||
@@ -328,7 +328,7 @@ class TestRelationDetach:
|
||||
|
||||
def test_default_name(self):
|
||||
html = sx(
|
||||
'(~relation-detach :detach-url "/api/unrelate")',
|
||||
'(~shared:relations/detach :detach-url "/api/unrelate")',
|
||||
**{"detach-url": "/api/unrelate"},
|
||||
)
|
||||
assert "this item" in html
|
||||
@@ -343,7 +343,7 @@ class TestRenderPage:
|
||||
from shared.sx.page import render_page
|
||||
|
||||
html = render_page(
|
||||
'(~error-page :title "Test" :message "MSG" :asset-url "/s")',
|
||||
'(~shared:pages/error-page :title "Test" :message "MSG" :asset-url "/s")',
|
||||
**{"asset-url": "/s"},
|
||||
)
|
||||
assert "<!doctype html>" in html
|
||||
|
||||
@@ -33,10 +33,10 @@ def make_env(*sx_sources: str) -> dict:
|
||||
|
||||
class TestScanAst:
|
||||
def test_simple_component_ref(self):
|
||||
env = make_env('(defcomp ~card (&key title) (div (~badge :label title)))')
|
||||
env = make_env('(defcomp ~card (&key title) (div (~shared:misc/badge :label title)))')
|
||||
comp = env["~card"]
|
||||
refs = _scan_ast(comp.body)
|
||||
assert refs == {"~badge"}
|
||||
assert refs == {"~shared:misc/badge"}
|
||||
|
||||
def test_no_refs(self):
|
||||
env = make_env('(defcomp ~plain (&key text) (div :class "p-4" text))')
|
||||
@@ -77,11 +77,11 @@ class TestScanAst:
|
||||
class TestTransitiveDeps:
|
||||
def test_direct_dep(self):
|
||||
env = make_env(
|
||||
'(defcomp ~card (&key) (div (~badge)))',
|
||||
'(defcomp ~badge (&key) (span "★"))',
|
||||
'(defcomp ~card (&key) (div (~shared:misc/badge)))',
|
||||
'(defcomp ~shared:misc/badge (&key) (span "★"))',
|
||||
)
|
||||
deps = transitive_deps("~card", env)
|
||||
assert deps == {"~badge"}
|
||||
assert deps == {"~shared:misc/badge"}
|
||||
|
||||
def test_transitive(self):
|
||||
env = make_env(
|
||||
@@ -115,11 +115,11 @@ class TestTransitiveDeps:
|
||||
|
||||
def test_without_tilde_prefix(self):
|
||||
env = make_env(
|
||||
'(defcomp ~card (&key) (div (~badge)))',
|
||||
'(defcomp ~badge (&key) (span "★"))',
|
||||
'(defcomp ~card (&key) (div (~shared:misc/badge)))',
|
||||
'(defcomp ~shared:misc/badge (&key) (span "★"))',
|
||||
)
|
||||
deps = transitive_deps("card", env)
|
||||
assert deps == {"~badge"}
|
||||
assert deps == {"~shared:misc/badge"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -130,13 +130,13 @@ class TestComputeAllDeps:
|
||||
def test_sets_deps_on_components(self):
|
||||
env = make_env(
|
||||
'(defcomp ~page (&key) (div (~card)))',
|
||||
'(defcomp ~card (&key) (div (~badge)))',
|
||||
'(defcomp ~badge (&key) (span "★"))',
|
||||
'(defcomp ~card (&key) (div (~shared:misc/badge)))',
|
||||
'(defcomp ~shared:misc/badge (&key) (span "★"))',
|
||||
)
|
||||
compute_all_deps(env)
|
||||
assert env["~page"].deps == {"~card", "~badge"}
|
||||
assert env["~card"].deps == {"~badge"}
|
||||
assert env["~badge"].deps == set()
|
||||
assert env["~page"].deps == {"~card", "~shared:misc/badge"}
|
||||
assert env["~card"].deps == {"~shared:misc/badge"}
|
||||
assert env["~shared:misc/badge"].deps == set()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -145,9 +145,9 @@ class TestComputeAllDeps:
|
||||
|
||||
class TestScanComponentsFromSx:
|
||||
def test_basic(self):
|
||||
source = '(~card :title "hi" (~badge :label "new"))'
|
||||
source = '(~card :title "hi" (~shared:misc/badge :label "new"))'
|
||||
refs = scan_components_from_sx(source)
|
||||
assert refs == {"~card", "~badge"}
|
||||
assert refs == {"~card", "~shared:misc/badge"}
|
||||
|
||||
def test_no_components(self):
|
||||
source = '(div :class "p-4" (p "hello"))'
|
||||
@@ -162,8 +162,8 @@ class TestScanComponentsFromSx:
|
||||
class TestComponentsNeeded:
|
||||
def test_page_with_deps(self):
|
||||
env = make_env(
|
||||
'(defcomp ~page-layout (&key) (div (~nav) (~footer)))',
|
||||
'(defcomp ~nav (&key) (nav "nav"))',
|
||||
'(defcomp ~page-layout (&key) (div (~plans/environment-images/nav) (~footer)))',
|
||||
'(defcomp ~plans/environment-images/nav (&key) (nav "nav"))',
|
||||
'(defcomp ~footer (&key) (footer "footer"))',
|
||||
'(defcomp ~unused (&key) (div "not needed"))',
|
||||
)
|
||||
@@ -171,6 +171,6 @@ class TestComponentsNeeded:
|
||||
page_sx = '(~page-layout)'
|
||||
needed = components_needed(page_sx, env)
|
||||
assert "~page-layout" in needed
|
||||
assert "~nav" in needed
|
||||
assert "~plans/environment-images/nav" in needed
|
||||
assert "~footer" in needed
|
||||
assert "~unused" not in needed
|
||||
|
||||
@@ -228,8 +228,8 @@ class TestRawHtml:
|
||||
class TestComponents:
|
||||
def test_basic_component(self):
|
||||
env = {}
|
||||
evaluate(parse('(defcomp ~badge (&key label) (span :class "badge" label))'), env)
|
||||
html = render(parse('(~badge :label "New")'), env)
|
||||
evaluate(parse('(defcomp ~shared:misc/badge (&key label) (span :class "badge" label))'), env)
|
||||
html = render(parse('(~shared:misc/badge :label "New")'), env)
|
||||
assert html == '<span class="badge">New</span>'
|
||||
|
||||
def test_component_with_children(self):
|
||||
|
||||
@@ -87,7 +87,7 @@ class TestScanIoRefs:
|
||||
assert refs == set()
|
||||
|
||||
def test_component_ref_not_io(self):
|
||||
"""Component references (~name) should not appear as IO refs."""
|
||||
"""Component references (~plans/content-addressed-components/name) should not appear as IO refs."""
|
||||
env = make_env(
|
||||
'(defcomp ~page (&key) (div (~card :title "hi")))',
|
||||
'(defcomp ~card (&key title) (div title))',
|
||||
@@ -119,8 +119,8 @@ class TestTransitiveIoRefs:
|
||||
def test_transitive_io_through_dep(self):
|
||||
"""IO ref in a dependency should propagate to the parent."""
|
||||
env = make_env(
|
||||
'(defcomp ~page (&key) (div (~nav)))',
|
||||
'(defcomp ~nav (&key) (nav (app-url "/home")))',
|
||||
'(defcomp ~page (&key) (div (~plans/environment-images/nav)))',
|
||||
'(defcomp ~plans/environment-images/nav (&key) (nav (app-url "/home")))',
|
||||
)
|
||||
refs = _transitive_io_refs_fallback("~page", env, IO_NAMES)
|
||||
assert refs == {"app-url"}
|
||||
@@ -157,7 +157,7 @@ class TestTransitiveIoRefs:
|
||||
def test_without_tilde_prefix(self):
|
||||
"""Should auto-add ~ prefix when not provided."""
|
||||
env = make_env(
|
||||
'(defcomp ~nav (&key) (nav (app-url "/")))',
|
||||
'(defcomp ~plans/environment-images/nav (&key) (nav (app-url "/")))',
|
||||
)
|
||||
refs = _transitive_io_refs_fallback("nav", env, IO_NAMES)
|
||||
assert refs == {"app-url"}
|
||||
@@ -187,13 +187,13 @@ class TestTransitiveIoRefs:
|
||||
class TestComputeAllIoRefs:
|
||||
def test_sets_io_refs_on_components(self):
|
||||
env = make_env(
|
||||
'(defcomp ~page (&key) (div (~nav) (fetch-data "x")))',
|
||||
'(defcomp ~nav (&key) (nav (app-url "/")))',
|
||||
'(defcomp ~page (&key) (div (~plans/environment-images/nav) (fetch-data "x")))',
|
||||
'(defcomp ~plans/environment-images/nav (&key) (nav (app-url "/")))',
|
||||
'(defcomp ~card (&key title) (div title))',
|
||||
)
|
||||
_compute_all_io_refs_fallback(env, IO_NAMES)
|
||||
assert env["~page"].io_refs == {"fetch-data", "app-url"}
|
||||
assert env["~nav"].io_refs == {"app-url"}
|
||||
assert env["~plans/environment-images/nav"].io_refs == {"app-url"}
|
||||
assert env["~card"].io_refs == set()
|
||||
|
||||
def test_pure_components_get_empty_set(self):
|
||||
@@ -284,8 +284,8 @@ class TestSxRefIoFunctions:
|
||||
def test_transitive_io_refs(self):
|
||||
from shared.sx.ref.sx_ref import transitive_io_refs
|
||||
env = make_env(
|
||||
'(defcomp ~page (&key) (div (~nav)))',
|
||||
'(defcomp ~nav (&key) (nav (app-url "/")))',
|
||||
'(defcomp ~page (&key) (div (~plans/environment-images/nav)))',
|
||||
'(defcomp ~plans/environment-images/nav (&key) (nav (app-url "/")))',
|
||||
)
|
||||
refs = transitive_io_refs("~page", env, list(IO_NAMES))
|
||||
assert set(refs) == {"app-url"}
|
||||
@@ -299,13 +299,13 @@ class TestSxRefIoFunctions:
|
||||
def test_compute_all_io_refs(self):
|
||||
from shared.sx.ref.sx_ref import compute_all_io_refs as ref_compute
|
||||
env = make_env(
|
||||
'(defcomp ~page (&key) (div (~nav) (fetch-data "x")))',
|
||||
'(defcomp ~nav (&key) (nav (app-url "/")))',
|
||||
'(defcomp ~page (&key) (div (~plans/environment-images/nav) (fetch-data "x")))',
|
||||
'(defcomp ~plans/environment-images/nav (&key) (nav (app-url "/")))',
|
||||
'(defcomp ~card (&key) (div "pure"))',
|
||||
)
|
||||
ref_compute(env, list(IO_NAMES))
|
||||
page_refs = env["~page"].io_refs
|
||||
nav_refs = env["~nav"].io_refs
|
||||
nav_refs = env["~plans/environment-images/nav"].io_refs
|
||||
card_refs = env["~card"].io_refs
|
||||
assert "fetch-data" in page_refs
|
||||
assert "app-url" in page_refs
|
||||
@@ -385,8 +385,8 @@ class TestFallbackVsRefParity:
|
||||
|
||||
def test_parity_mixed(self):
|
||||
self._check_parity(
|
||||
'(defcomp ~layout (&key) (div (~nav) (~content) (~footer)))',
|
||||
'(defcomp ~nav (&key) (nav (app-url "/")))',
|
||||
'(defcomp ~layout (&key) (div (~plans/environment-images/nav) (~content) (~footer)))',
|
||||
'(defcomp ~plans/environment-images/nav (&key) (nav (app-url "/")))',
|
||||
'(defcomp ~content (&key) (main "pure content"))',
|
||||
'(defcomp ~footer (&key) (footer (config "name")))',
|
||||
)
|
||||
|
||||
@@ -74,12 +74,12 @@ class TestIoDepsSerialization:
|
||||
def test_multiple_io_deps_collected(self):
|
||||
"""Multiple distinct IO primitives from different components are unioned."""
|
||||
env = make_env(
|
||||
'(defcomp ~nav (&key) (nav (app-url "/")))',
|
||||
'(defcomp ~page (&key) (div (~nav) (config "key")))',
|
||||
'(defcomp ~plans/environment-images/nav (&key) (nav (app-url "/")))',
|
||||
'(defcomp ~page (&key) (div (~plans/environment-images/nav) (config "key")))',
|
||||
)
|
||||
_compute_all_io_refs_fallback(env, IO_NAMES)
|
||||
|
||||
deps = {"~nav", "~page"}
|
||||
deps = {"~plans/environment-images/nav", "~page"}
|
||||
io_deps: set[str] = set()
|
||||
for dep_name in deps:
|
||||
comp = env.get(dep_name)
|
||||
|
||||
@@ -57,10 +57,10 @@ class TestSx:
|
||||
class TestComponents:
|
||||
def test_register_and_use(self):
|
||||
register_components('''
|
||||
(defcomp ~badge (&key label)
|
||||
(defcomp ~shared:misc/badge (&key label)
|
||||
(span :class "badge" label))
|
||||
''')
|
||||
html = sx('(~badge :label "New")')
|
||||
html = sx('(~shared:misc/badge :label "New")')
|
||||
assert html == '<span class="badge">New</span>'
|
||||
|
||||
def test_multiple_components(self):
|
||||
@@ -112,7 +112,7 @@ class TestLinkCard:
|
||||
def setup_method(self):
|
||||
_COMPONENT_ENV.clear()
|
||||
register_components('''
|
||||
(defcomp ~link-card (&key link title image icon brand)
|
||||
(defcomp ~shared:fragments/link-card (&key link title image icon brand)
|
||||
(a :href link
|
||||
:class "block rounded border border-stone-200 bg-white hover:bg-stone-50 transition-colors no-underline"
|
||||
(div :class "flex flex-row items-start gap-3 p-3"
|
||||
@@ -128,7 +128,7 @@ class TestLinkCard:
|
||||
|
||||
def test_with_image(self):
|
||||
html = sx('''
|
||||
(~link-card
|
||||
(~shared:fragments/link-card
|
||||
:link "/products/apple/"
|
||||
:title "Apple"
|
||||
:image "/img/apple.jpg"
|
||||
@@ -140,7 +140,7 @@ class TestLinkCard:
|
||||
|
||||
def test_without_image(self):
|
||||
html = sx('''
|
||||
(~link-card
|
||||
(~shared:fragments/link-card
|
||||
:link "/posts/hello/"
|
||||
:title "Hello World"
|
||||
:icon "fas fa-file-alt")
|
||||
@@ -152,7 +152,7 @@ class TestLinkCard:
|
||||
|
||||
def test_with_brand(self):
|
||||
html = sx('''
|
||||
(~link-card
|
||||
(~shared:fragments/link-card
|
||||
:link "/p/x/"
|
||||
:title "Widget"
|
||||
:image "/img/w.jpg"
|
||||
@@ -162,7 +162,7 @@ class TestLinkCard:
|
||||
|
||||
def test_without_brand(self):
|
||||
html = sx('''
|
||||
(~link-card
|
||||
(~shared:fragments/link-card
|
||||
:link "/p/x/"
|
||||
:title "Widget"
|
||||
:image "/img/w.jpg")
|
||||
@@ -173,7 +173,7 @@ class TestLinkCard:
|
||||
def test_kwargs_from_python(self):
|
||||
"""Pass data from Python (like a route handler would)."""
|
||||
html = sx(
|
||||
'(~link-card :link link :title title :image image :icon "fas fa-box")',
|
||||
'(~shared:fragments/link-card :link link :title title :image image :icon "fas fa-box")',
|
||||
link="/products/banana/",
|
||||
title="Banana",
|
||||
image="/img/banana.jpg",
|
||||
|
||||
@@ -708,7 +708,7 @@ class TestParityDeps:
|
||||
def test_scan_components_from_sx(self):
|
||||
from shared.sx.deps import _scan_components_from_sx_fallback
|
||||
from shared.sx.ref.sx_ref import scan_components_from_source as ref_sc
|
||||
source = '(~card :title "hi" (~badge :label "new"))'
|
||||
source = '(~card :title "hi" (~shared:misc/badge :label "new"))'
|
||||
hw = _scan_components_from_sx_fallback(source)
|
||||
ref = set(ref_sc(source))
|
||||
assert hw == ref
|
||||
@@ -718,13 +718,13 @@ class TestParityDeps:
|
||||
from shared.sx.ref.sx_ref import compute_all_io_refs as ref_cio
|
||||
io_names = {"highlight", "app-url", "config", "fetch-data"}
|
||||
hw_env, ref_env = self._make_envs(
|
||||
'(defcomp ~page (&key) (div (~nav) (fetch-data "x")))',
|
||||
'(defcomp ~nav (&key) (nav (app-url "/")))',
|
||||
'(defcomp ~page (&key) (div (~plans/environment-images/nav) (fetch-data "x")))',
|
||||
'(defcomp ~plans/environment-images/nav (&key) (nav (app-url "/")))',
|
||||
'(defcomp ~pure (&key) (div "hello"))',
|
||||
)
|
||||
_compute_all_io_refs_fallback(hw_env, io_names)
|
||||
ref_cio(ref_env, list(io_names))
|
||||
for key in ("~page", "~nav", "~pure"):
|
||||
for key in ("~page", "~plans/environment-images/nav", "~pure"):
|
||||
hw_refs = hw_env[key].io_refs or set()
|
||||
ref_refs = ref_env[key].io_refs
|
||||
assert set(hw_refs) == set(ref_refs), f"IO refs mismatch for {key}"
|
||||
|
||||
@@ -224,7 +224,7 @@ class TestBuildPagesSx:
|
||||
from shared.sx.helpers import _sx_literal
|
||||
from shared.sx.parser import parse_all
|
||||
|
||||
content = '(~doc-page :title "Hello \\"World\\"")'
|
||||
content = '(~docs/page :title "Hello \\"World\\"")'
|
||||
entry = (
|
||||
"{:name " + _sx_literal("test")
|
||||
+ " :content " + _sx_literal(content)
|
||||
|
||||
@@ -142,9 +142,9 @@ class TestComponents:
|
||||
assert html == '<div class="box"><p>inside</p></div>'
|
||||
|
||||
def test_component_with_conditional(self):
|
||||
comp = '(defcomp ~badge (&key show label) (when show (span label)))'
|
||||
assert _js_render('(~badge :show true :label "ok")', comp) == '<span>ok</span>'
|
||||
assert _js_render('(~badge :show false :label "ok")', comp) == ''
|
||||
comp = '(defcomp ~shared:misc/badge (&key show label) (when show (span label)))'
|
||||
assert _js_render('(~shared:misc/badge :show true :label "ok")', comp) == '<span>ok</span>'
|
||||
assert _js_render('(~shared:misc/badge :show false :label "ok")', comp) == ''
|
||||
|
||||
def test_nested_components(self):
|
||||
comps = """
|
||||
|
||||
@@ -156,7 +156,7 @@ class Macro:
|
||||
|
||||
@dataclass
|
||||
class Component:
|
||||
"""A reusable UI component defined via ``(defcomp ~name (&key ...) body)``.
|
||||
"""A reusable UI component defined via ``(defcomp ~plans/content-addressed-components/name (&key ...) body)``.
|
||||
|
||||
Components are like lambdas but accept keyword arguments and support
|
||||
a ``children`` rest parameter.
|
||||
@@ -196,7 +196,7 @@ class Component:
|
||||
|
||||
@dataclass
|
||||
class Island:
|
||||
"""A reactive UI component defined via ``(defisland ~name (&key ...) body)``.
|
||||
"""A reactive UI component defined via ``(defisland ~plans/content-addressed-components/name (&key ...) body)``.
|
||||
|
||||
Islands are like components but create a reactive boundary. Inside an
|
||||
island, signals are tracked — deref subscribes DOM nodes to signals.
|
||||
|
||||
@@ -33,7 +33,7 @@ def _clean_env():
|
||||
|
||||
class TestRender:
|
||||
def test_basic_render(self):
|
||||
register_components('(defcomp ~badge (&key label) (span :class "badge" label))')
|
||||
register_components('(defcomp ~shared:misc/badge (&key label) (span :class "badge" label))')
|
||||
html = render("badge", label="New")
|
||||
assert html == '<span class="badge">New</span>'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user