No pages found.
')
+
+ return "".join(parts)
+
+
+def _page_card_html(page: dict, ctx: dict) -> str:
+ """Single page card."""
+ slug = page.get("slug", "")
+ href = call_url(ctx, "blog_url", f"/{slug}/")
+ hx_select = ctx.get("hx_select_search", "#main-panel")
+
+ parts = ['')
+ else:
+ parts.append('
')
+ parts.append(_blog_cards_html(ctx))
+ parts.append('
')
+
+ return "".join(parts)
+
+
+# ---------------------------------------------------------------------------
+# Desktop aside (filter sidebar)
+# ---------------------------------------------------------------------------
+
+def _blog_aside_html(ctx: dict) -> str:
+ """Desktop aside with search, action buttons, and filters."""
+ parts = []
+ parts.append(search_desktop_html(ctx))
+ parts.append(_action_buttons_html(ctx))
+ parts.append(f'
')
+ parts.append(_tag_groups_filter_html(ctx))
+ parts.append(_authors_filter_html(ctx))
+ parts.append('
')
+ parts.append('
')
+ return "".join(parts)
+
+
+def _blog_filter_html(ctx: dict) -> str:
+ """Mobile filter (details/summary)."""
+ current_local_href = ctx.get("current_local_href", "/index")
+ search = ctx.get("search", "")
+ search_count = ctx.get("search_count", "")
+ hx_select = ctx.get("hx_select", "#main-panel")
+
+ # Mobile filter summary tags
+ summary_parts = []
+ summary_parts.append(_tag_groups_filter_summary_html(ctx))
+ summary_parts.append(_authors_filter_summary_html(ctx))
+ summary_html = "".join(summary_parts)
+
+ filter_content = search_mobile_html(ctx) + summary_html
+ action_buttons = _action_buttons_html(ctx)
+ filter_details = _tag_groups_filter_html(ctx) + _authors_filter_html(ctx)
+
+ return sexp(
+ '(~mobile-filter :filter-summary-html fsh :action-buttons-html abh'
+ ' :filter-details-html fdh)',
+ fsh=filter_content,
+ abh=action_buttons,
+ fdh=filter_details,
+ )
+
+
+def _action_buttons_html(ctx: dict) -> str:
+ """New Post/Page + Drafts toggle buttons."""
+ from quart import g
+
+ rights = ctx.get("rights") or {}
+ has_admin = rights.get("admin") if isinstance(rights, dict) else getattr(rights, "admin", False)
+ user = getattr(g, "user", None)
+ hx_select = ctx.get("hx_select_search", "#main-panel")
+ drafts = ctx.get("drafts")
+ draft_count = ctx.get("draft_count", 0)
+ current_local_href = ctx.get("current_local_href", "/index")
+
+ parts = ['
']
+
+ if has_admin:
+ new_href = call_url(ctx, "blog_url", "/new/")
+ parts.append(
+ f'
New Post'
+ )
+ new_page_href = call_url(ctx, "blog_url", "/new-page/")
+ parts.append(
+ f'
New Page'
+ )
+
+ if user and (draft_count or drafts):
+ if drafts:
+ off_href = f"{current_local_href}"
+ parts.append(
+ f'
Drafts'
+ f' {draft_count} '
+ )
+ else:
+ on_href = f"{current_local_href}{'&' if '?' in current_local_href else '?'}drafts=1"
+ parts.append(
+ f'
Drafts'
+ f' {draft_count} '
+ )
+
+ parts.append('
')
+ return "".join(parts)
+
+
+def _tag_groups_filter_html(ctx: dict) -> str:
+ """Tag group filter bar for desktop/mobile."""
+ tag_groups = ctx.get("tag_groups") or []
+ selected_groups = ctx.get("selected_groups") or ()
+ selected_tags = ctx.get("selected_tags") or ()
+ hx_select = ctx.get("hx_select_search", "#main-panel")
+
+ parts = ['
'
+ '']
+
+ # "Any Topic" link
+ is_any = len(selected_groups) == 0 and len(selected_tags) == 0
+ any_cls = "bg-stone-900 text-white border-stone-900" if is_any else "bg-white text-stone-600 border-stone-300 hover:bg-stone-50"
+ parts.append(
+ f'Any Topic '
+ )
+
+ for group in tag_groups:
+ g_slug = getattr(group, "slug", "") if hasattr(group, "slug") else group.get("slug", "")
+ g_name = getattr(group, "name", "") if hasattr(group, "name") else group.get("name", "")
+ g_fi = getattr(group, "feature_image", None) if hasattr(group, "feature_image") else group.get("feature_image")
+ g_colour = getattr(group, "colour", None) if hasattr(group, "colour") else group.get("colour")
+ g_count = getattr(group, "post_count", 0) if hasattr(group, "post_count") else group.get("post_count", 0)
+
+ if g_count <= 0 and g_slug not in selected_groups:
+ continue
+
+ is_on = g_slug in selected_groups
+ cls = "bg-stone-900 text-white border-stone-900" if is_on else "bg-white text-stone-600 border-stone-300 hover:bg-stone-50"
+
+ if g_fi:
+ icon = f' '
+ else:
+ style = f"background-color: {g_colour}; color: white;" if g_colour else "background-color: #e7e5e4; color: #57534e;"
+ icon = (
+ f'{escape(g_name[:1])}
'
+ )
+
+ parts.append(
+ f'{icon}'
+ f'{escape(g_name)} '
+ f' '
+ f'{g_count} '
+ f' '
+ )
+
+ parts.append(' ')
+ return "".join(parts)
+
+
+def _authors_filter_html(ctx: dict) -> str:
+ """Author filter bar for desktop/mobile."""
+ authors = ctx.get("authors") or []
+ selected_authors = ctx.get("selected_authors") or ()
+ hx_select = ctx.get("hx_select_search", "#main-panel")
+
+ parts = ['
'
+ '']
+
+ is_any = len(selected_authors) == 0
+ any_cls = "bg-stone-900 text-white border-stone-900" if is_any else "bg-white text-stone-600 border-stone-300 hover:bg-stone-50"
+ parts.append(
+ f'Any author '
+ )
+
+ for author in authors:
+ a_slug = getattr(author, "slug", "") if hasattr(author, "slug") else author.get("slug", "")
+ a_name = getattr(author, "name", "") if hasattr(author, "name") else author.get("name", "")
+ a_img = getattr(author, "profile_image", None) if hasattr(author, "profile_image") else author.get("profile_image")
+ a_count = getattr(author, "published_post_count", 0) if hasattr(author, "published_post_count") else author.get("published_post_count", 0)
+
+ is_on = a_slug in selected_authors
+ cls = "bg-stone-900 text-white border-stone-900" if is_on else "bg-white text-stone-600 border-stone-300 hover:bg-stone-50"
+
+ icon = ""
+ if a_img:
+ icon = f' '
+
+ parts.append(
+ f'{icon}'
+ f'{escape(a_name)} '
+ f' '
+ f'{a_count} '
+ f' '
+ )
+
+ parts.append(' ')
+ return "".join(parts)
+
+
+def _tag_groups_filter_summary_html(ctx: dict) -> str:
+ """Mobile filter summary for tag groups."""
+ selected_groups = ctx.get("selected_groups") or ()
+ tag_groups = ctx.get("tag_groups") or []
+ if not selected_groups:
+ return ""
+ names = []
+ for g in tag_groups:
+ g_slug = getattr(g, "slug", "") if hasattr(g, "slug") else g.get("slug", "")
+ g_name = getattr(g, "name", "") if hasattr(g, "name") else g.get("name", "")
+ if g_slug in selected_groups:
+ names.append(g_name)
+ if not names:
+ return ""
+ return f'
{escape(", ".join(names))} '
+
+
+def _authors_filter_summary_html(ctx: dict) -> str:
+ """Mobile filter summary for authors."""
+ selected_authors = ctx.get("selected_authors") or ()
+ authors = ctx.get("authors") or []
+ if not selected_authors:
+ return ""
+ names = []
+ for a in authors:
+ a_slug = getattr(a, "slug", "") if hasattr(a, "slug") else a.get("slug", "")
+ a_name = getattr(a, "name", "") if hasattr(a, "name") else a.get("name", "")
+ if a_slug in selected_authors:
+ names.append(a_name)
+ if not names:
+ return ""
+ return f'
{escape(", ".join(names))} '
+
+
+# ---------------------------------------------------------------------------
+# Post detail main panel
+# ---------------------------------------------------------------------------
+
+def _post_main_panel_html(ctx: dict) -> str:
+ """Post/page article content."""
+ from quart import g, url_for as qurl
+
+ post = ctx.get("post") or {}
+ slug = post.get("slug", "")
+ user = getattr(g, "user", None)
+ rights = ctx.get("rights") or {}
+ is_admin = rights.get("admin") if isinstance(rights, dict) else getattr(rights, "admin", False)
+ hx_select = ctx.get("hx_select_search", "#main-panel")
+
+ parts = ['
']
+
+ # Draft indicator
+ if post.get("status") == "draft":
+ parts.append('')
+ parts.append('
Draft ')
+ if post.get("publish_requested"):
+ parts.append('
Publish requested ')
+ if is_admin or (user and post.get("user_id") == getattr(user, "id", None)):
+ edit_href = qurl("blog.post.admin.edit", slug=slug)
+ parts.append(
+ f'
'
+ f' Edit '
+ )
+ parts.append('
')
+
+ # Blog post chrome (not for pages)
+ if not post.get("is_page"):
+ if user:
+ liked = post.get("is_liked", False)
+ like_url = call_url(ctx, "blog_url", f"/{slug}/like/toggle/")
+ parts.append(
+ f''
+ f'{"❤️" if liked else "🤍"}
'
+ )
+
+ if post.get("custom_excerpt"):
+ parts.append(f'{post["custom_excerpt"]}
')
+
+ # Desktop at_bar
+ parts.append(f'{_at_bar_html(post, ctx)}
')
+
+ # Feature image
+ fi = post.get("feature_image")
+ if fi:
+ parts.append(f'')
+
+ # Post HTML content
+ html_content = post.get("html", "")
+ if html_content:
+ parts.append(f'{html_content}
')
+
+ parts.append('
')
+ return "".join(parts)
+
+
+def _post_meta_html(ctx: dict) -> str:
+ """Post SEO meta tags (Open Graph, Twitter, JSON-LD)."""
+ post = ctx.get("post") or {}
+ base_title = ctx.get("base_title", "")
+
+ is_public = post.get("visibility") == "public"
+ is_published = post.get("status") == "published"
+ email_only = post.get("email_only", False)
+ robots = "index,follow" if (is_public and is_published and not email_only) else "noindex,nofollow"
+
+ # Description
+ desc = (post.get("meta_description") or post.get("og_description") or
+ post.get("twitter_description") or post.get("custom_excerpt") or
+ post.get("excerpt") or "")
+ if not desc and post.get("html"):
+ import re
+ desc = re.sub(r'<[^>]+>', '', post["html"])
+ desc = desc.replace("\n", " ").replace("\r", " ").strip()[:160]
+
+ # Image
+ image = (post.get("og_image") or post.get("twitter_image") or post.get("feature_image") or "")
+
+ # Canonical
+ from quart import request as req
+ canonical = post.get("canonical_url") or (req.url if req else "")
+
+ og_title = post.get("og_title") or base_title
+ tw_title = post.get("twitter_title") or base_title
+ is_article = not post.get("is_page")
+
+ parts = [f'
']
+ parts.append(f'
{escape(base_title)} ')
+ parts.append(f'
')
+ if canonical:
+ parts.append(f'
')
+
+ parts.append(f'
')
+ parts.append(f'
')
+ parts.append(f'
')
+ if canonical:
+ parts.append(f'
')
+ if image:
+ parts.append(f'
')
+
+ parts.append(f'
')
+ parts.append(f'
')
+ parts.append(f'
')
+ if image:
+ parts.append(f'
')
+
+ return "".join(parts)
+
+
+# ---------------------------------------------------------------------------
+# Home page (Ghost "home" page)
+# ---------------------------------------------------------------------------
+
+def _home_main_panel_html(ctx: dict) -> str:
+ """Home page content — renders the Ghost page HTML."""
+ post = ctx.get("post") or {}
+ html = post.get("html", "")
+ return f'
{html}
'
+
+
+# ---------------------------------------------------------------------------
+# Post admin - empty main panel
+# ---------------------------------------------------------------------------
+
+def _post_admin_main_panel_html(ctx: dict) -> str:
+ return '
'
+
+
+# ---------------------------------------------------------------------------
+# Settings main panels
+# ---------------------------------------------------------------------------
+
+def _settings_main_panel_html(ctx: dict) -> str:
+ return '
'
+
+
+def _cache_main_panel_html(ctx: dict) -> str:
+ from quart import url_for as qurl
+
+ csrf = ctx.get("csrf_token", "")
+ clear_url = qurl("settings.cache_clear")
+ return (
+ f'
'
+ )
+
+
+# ---------------------------------------------------------------------------
+# Snippets main panel
+# ---------------------------------------------------------------------------
+
+def _snippets_main_panel_html(ctx: dict) -> str:
+ return (
+ f'
'
+ f'
'
+ f'
Snippets '
+ f'
{_snippets_list_html(ctx)}
'
+ )
+
+
+def _snippets_list_html(ctx: dict) -> str:
+ """Snippets list with visibility badges and delete buttons."""
+ from quart import url_for as qurl, g
+
+ snippets = ctx.get("snippets") or []
+ is_admin = ctx.get("is_admin", False)
+ csrf = ctx.get("csrf_token", "")
+ user = getattr(g, "user", None)
+ user_id = getattr(user, "id", None)
+
+ if not snippets:
+ return (
+ '
'
+ '
'
+ '
'
+ '
No snippets yet. Create one from the blog editor.
'
+ )
+
+ badge_colours = {
+ "private": "bg-stone-200 text-stone-700",
+ "shared": "bg-blue-100 text-blue-700",
+ "admin": "bg-amber-100 text-amber-700",
+ }
+
+ parts = ['
']
+ for s in snippets:
+ s_id = getattr(s, "id", None) or s.get("id")
+ s_name = getattr(s, "name", "") if hasattr(s, "name") else s.get("name", "")
+ s_uid = getattr(s, "user_id", None) if hasattr(s, "user_id") else s.get("user_id")
+ s_vis = getattr(s, "visibility", "private") if hasattr(s, "visibility") else s.get("visibility", "private")
+
+ owner = "You" if s_uid == user_id else f"User #{s_uid}"
+ badge_cls = badge_colours.get(s_vis, "bg-stone-200 text-stone-700")
+
+ parts.append(
+ f'
'
+ f'
{escape(s_name)}
'
+ f'
{owner}
'
+ f'
{s_vis} '
+ )
+
+ if is_admin:
+ patch_url = qurl("snippets.patch_visibility", snippet_id=s_id)
+ parts.append(
+ f'
'
+ )
+ for v in ["private", "shared", "admin"]:
+ sel = " selected" if s_vis == v else ""
+ parts.append(f'{v} ')
+ parts.append(' ')
+
+ if s_uid == user_id or is_admin:
+ del_url = qurl("snippets.delete_snippet", snippet_id=s_id)
+ parts.append(
+ f'
'
+ f' Delete '
+ )
+
+ parts.append('
')
+
+ parts.append('
')
+ return "".join(parts)
+
+
+# ---------------------------------------------------------------------------
+# Menu items main panel
+# ---------------------------------------------------------------------------
+
+def _menu_items_main_panel_html(ctx: dict) -> str:
+ from quart import url_for as qurl
+
+ new_url = qurl("menu_items.new_menu_item")
+ return (
+ f'
'
+ f'
'
+ f''
+ f' Add Menu Item
'
+ f''
+ f'
'
+ )
+
+
+def _menu_items_list_html(ctx: dict) -> str:
+ from quart import url_for as qurl
+
+ menu_items = ctx.get("menu_items") or []
+ csrf = ctx.get("csrf_token", "")
+
+ if not menu_items:
+ return (
+ '
'
+ '
'
+ '
'
+ '
No menu items yet. Add one to get started!
'
+ )
+
+ parts = ['
']
+ for item in menu_items:
+ i_id = getattr(item, "id", None) or item.get("id")
+ label = getattr(item, "label", "") if hasattr(item, "label") else item.get("label", "")
+ slug = getattr(item, "slug", "") if hasattr(item, "slug") else item.get("slug", "")
+ fi = getattr(item, "feature_image", None) if hasattr(item, "feature_image") else item.get("feature_image")
+ sort = getattr(item, "sort_order", 0) if hasattr(item, "sort_order") else item.get("sort_order", 0)
+
+ edit_url = qurl("menu_items.edit_menu_item", item_id=i_id)
+ del_url = qurl("menu_items.delete_menu_item_route", item_id=i_id)
+
+ img = (f'
'
+ if fi else '
')
+
+ parts.append(
+ f'
'
+ f'
'
+ f'{img}'
+ f'
{escape(label)}
'
+ f'
{escape(slug)}
'
+ f'
Order: {sort}
'
+ f'
'
+ f' Edit '
+ f''
+ f' Delete
'
+ )
+
+ parts.append('
')
+ return "".join(parts)
+
+
+# ---------------------------------------------------------------------------
+# Tag groups main panel
+# ---------------------------------------------------------------------------
+
+def _tag_groups_main_panel_html(ctx: dict) -> str:
+ from quart import url_for as qurl
+
+ groups = ctx.get("groups") or []
+ unassigned_tags = ctx.get("unassigned_tags") or []
+ csrf = ctx.get("csrf_token", "")
+
+ parts = ['
']
+
+ # Create form
+ create_url = qurl("blog.tag_groups_admin.create")
+ parts.append(
+ f'
'
+ )
+
+ # Groups list
+ if groups:
+ parts.append('
')
+ for group in groups:
+ g_id = getattr(group, "id", None) or group.get("id")
+ g_name = getattr(group, "name", "") if hasattr(group, "name") else group.get("name", "")
+ g_slug = getattr(group, "slug", "") if hasattr(group, "slug") else group.get("slug", "")
+ g_fi = getattr(group, "feature_image", None) if hasattr(group, "feature_image") else group.get("feature_image")
+ g_colour = getattr(group, "colour", None) if hasattr(group, "colour") else group.get("colour")
+ g_sort = getattr(group, "sort_order", 0) if hasattr(group, "sort_order") else group.get("sort_order", 0)
+
+ edit_href = qurl("blog.tag_groups_admin.edit", id=g_id)
+
+ if g_fi:
+ icon = f' '
+ else:
+ style = f"background-color: {g_colour}; color: white;" if g_colour else "background-color: #e7e5e4; color: #57534e;"
+ icon = (
+ f'{escape(g_name[:1])}
'
+ )
+
+ parts.append(
+ f'{icon}'
+ f''
+ f'order: {g_sort} '
+ )
+ parts.append(' ')
+ else:
+ parts.append('
No tag groups yet.
')
+
+ # Unassigned tags
+ if unassigned_tags:
+ parts.append(f'
Unassigned Tags ({len(unassigned_tags)}) ')
+ parts.append('
')
+ for tag in unassigned_tags:
+ t_name = getattr(tag, "name", "") if hasattr(tag, "name") else tag.get("name", "")
+ parts.append(
+ f''
+ f'{escape(t_name)} '
+ )
+ parts.append('
')
+
+ parts.append('
')
+ return "".join(parts)
+
+
+def _tag_groups_edit_main_panel_html(ctx: dict) -> str:
+ from quart import url_for as qurl
+
+ group = ctx.get("group")
+ all_tags = ctx.get("all_tags") or []
+ assigned_tag_ids = ctx.get("assigned_tag_ids") or set()
+ csrf = ctx.get("csrf_token", "")
+
+ g_id = getattr(group, "id", None) or group.get("id") if group else None
+ g_name = getattr(group, "name", "") if hasattr(group, "name") else (group.get("name", "") if group else "")
+ g_colour = getattr(group, "colour", "") if hasattr(group, "colour") else (group.get("colour", "") if group else "")
+ g_sort = getattr(group, "sort_order", 0) if hasattr(group, "sort_order") else (group.get("sort_order", 0) if group else 0)
+ g_fi = getattr(group, "feature_image", "") if hasattr(group, "feature_image") else (group.get("feature_image", "") if group else "")
+
+ save_url = qurl("blog.tag_groups_admin.save", id=g_id)
+ del_url = qurl("blog.tag_groups_admin.delete_group", id=g_id)
+
+ parts = [f'
']
+
+ # Edit form
+ parts.append(
+ f'
'
+ )
+
+ # Delete form
+ parts.append(
+ f'
'
+ )
+
+ parts.append('
')
+ return "".join(parts)
+
+
+# ---------------------------------------------------------------------------
+# New post/page main panel — left as render_template (uses Koenig editor JS)
+# Post edit main panel — left as render_template (uses Koenig editor JS)
+# Post settings main panel — left as render_template (complex form macros)
+# Post entries main panel — left as render_template (calendar browser lazy-loads)
+# Post data main panel — left as render_template (uses ORM introspection macros)
+# ---------------------------------------------------------------------------
+
+
+# ===========================================================================
+# PUBLIC API — called from route handlers
+# ===========================================================================
+
+# ---- Home page ----
+
+async def render_home_page(ctx: dict) -> str:
+ root_hdr = root_header_html(ctx)
+ post_hdr = _post_header_html(ctx)
+ header_rows = root_hdr + post_hdr
+ content = _home_main_panel_html(ctx)
+ meta = _post_meta_html(ctx)
+ menu_html = ctx.get("nav_html", "") or ""
+ return full_page(ctx, header_rows_html=header_rows, content_html=content,
+ meta_html=meta, menu_html=menu_html)
+
+
+async def render_home_oob(ctx: dict) -> str:
+ root_hdr = root_header_html(ctx, oob=True)
+ post_oob = _oob_header_html("root-header-child", "post-header-child",
+ _post_header_html(ctx))
+ content = _home_main_panel_html(ctx)
+ return oob_page(ctx, oobs_html=root_hdr + post_oob, content_html=content)
+
+
+# ---- Blog index ----
+
+async def render_blog_page(ctx: dict) -> str:
+ root_hdr = root_header_html(ctx)
+ blog_hdr = _blog_header_html(ctx)
+ header_rows = root_hdr + blog_hdr
+ content = _blog_main_panel_html(ctx)
+ aside = _blog_aside_html(ctx)
+ filter_html = _blog_filter_html(ctx)
+ return full_page(ctx, header_rows_html=header_rows, content_html=content,
+ aside_html=aside, filter_html=filter_html)
+
+
+async def render_blog_oob(ctx: dict) -> str:
+ root_hdr = root_header_html(ctx, oob=True)
+ blog_oob = _oob_header_html("root-header-child", "blog-header-child",
+ _blog_header_html(ctx))
+ content = _blog_main_panel_html(ctx)
+ aside = _blog_aside_html(ctx)
+ filter_html = _blog_filter_html(ctx)
+ nav_html = ctx.get("nav_html", "") or ""
+ return oob_page(ctx, oobs_html=root_hdr + blog_oob,
+ content_html=content, aside_html=aside,
+ filter_html=filter_html, menu_html=nav_html)
+
+
+async def render_blog_cards(ctx: dict) -> str:
+ """Pagination-only response (page > 1)."""
+ return _blog_cards_html(ctx)
+
+
+async def render_blog_page_cards(ctx: dict) -> str:
+ """Page cards pagination response."""
+ return _page_cards_html(ctx)
+
+
+# ---- New post/page ----
+
+async def render_new_post_page(ctx: dict) -> str:
+ root_hdr = root_header_html(ctx)
+ blog_hdr = _blog_header_html(ctx)
+ header_rows = root_hdr + blog_hdr
+ # Content comes from Jinja (editor template)
+ content = ctx.get("editor_html", "")
+ return full_page(ctx, header_rows_html=header_rows, content_html=content)
+
+
+async def render_new_post_oob(ctx: dict) -> str:
+ root_hdr = root_header_html(ctx, oob=True)
+ blog_oob = _blog_header_html(ctx, oob=True)
+ content = ctx.get("editor_html", "")
+ return oob_page(ctx, oobs_html=root_hdr + blog_oob, content_html=content)
+
+
+# ---- Post detail ----
+
+async def render_post_page(ctx: dict) -> str:
+ root_hdr = root_header_html(ctx)
+ post_hdr = _post_header_html(ctx)
+ header_rows = root_hdr + post_hdr
+ content = _post_main_panel_html(ctx)
+ meta = _post_meta_html(ctx)
+ menu_html = ctx.get("nav_html", "") or ""
+ return full_page(ctx, header_rows_html=header_rows, content_html=content,
+ meta_html=meta, menu_html=menu_html)
+
+
+async def render_post_oob(ctx: dict) -> str:
+ root_hdr = root_header_html(ctx, oob=True)
+ post_oob = _oob_header_html("root-header-child", "post-header-child",
+ _post_header_html(ctx))
+ content = _post_main_panel_html(ctx)
+ menu_html = ctx.get("nav_html", "") or ""
+ return oob_page(ctx, oobs_html=root_hdr + post_oob,
+ content_html=content, menu_html=menu_html)
+
+
+# ---- Post admin ----
+
+async def render_post_admin_page(ctx: dict) -> str:
+ root_hdr = root_header_html(ctx)
+ post_hdr = _post_header_html(ctx)
+ admin_hdr = _post_admin_header_html(ctx)
+ header_rows = root_hdr + post_hdr + admin_hdr
+ content = _post_admin_main_panel_html(ctx)
+ menu_html = ctx.get("nav_html", "") or ""
+ return full_page(ctx, header_rows_html=header_rows, content_html=content,
+ menu_html=menu_html)
+
+
+async def render_post_admin_oob(ctx: dict) -> str:
+ post_hdr_oob = _post_header_html(ctx, oob=True)
+ admin_oob = _oob_header_html("post-header-child", "post-admin-header-child",
+ _post_admin_header_html(ctx))
+ content = _post_admin_main_panel_html(ctx)
+ menu_html = ctx.get("nav_html", "") or ""
+ return oob_page(ctx, oobs_html=post_hdr_oob + admin_oob,
+ content_html=content, menu_html=menu_html)
+
+
+# ---- Post data ----
+
+async def render_post_data_page(ctx: dict) -> str:
+ root_hdr = root_header_html(ctx)
+ post_hdr = _post_header_html(ctx)
+ admin_hdr = _post_admin_header_html(ctx)
+ from quart import url_for as qurl
+ slug = (ctx.get("post") or {}).get("slug", "")
+ data_hdr = _post_sub_admin_header_html(
+ "post_data-row", "post_data-header-child",
+ qurl("blog.post.admin.data", slug=slug),
+ "database", "data", ctx,
+ )
+ header_rows = root_hdr + post_hdr + admin_hdr + data_hdr
+ content = ctx.get("data_html", "")
+ return full_page(ctx, header_rows_html=header_rows, content_html=content)
+
+
+async def render_post_data_oob(ctx: dict) -> str:
+ admin_hdr_oob = _post_admin_header_html(ctx, oob=True)
+ from quart import url_for as qurl
+ slug = (ctx.get("post") or {}).get("slug", "")
+ data_hdr = _post_sub_admin_header_html(
+ "post_data-row", "post_data-header-child",
+ qurl("blog.post.admin.data", slug=slug),
+ "database", "data", ctx,
+ )
+ data_oob = _oob_header_html("post-admin-header-child", "post_data-header-child",
+ data_hdr)
+ content = ctx.get("data_html", "")
+ return oob_page(ctx, oobs_html=admin_hdr_oob + data_oob, content_html=content)
+
+
+# ---- Post entries ----
+
+async def render_post_entries_page(ctx: dict) -> str:
+ root_hdr = root_header_html(ctx)
+ post_hdr = _post_header_html(ctx)
+ admin_hdr = _post_admin_header_html(ctx)
+ from quart import url_for as qurl
+ slug = (ctx.get("post") or {}).get("slug", "")
+ entries_hdr = _post_sub_admin_header_html(
+ "post_entries-row", "post_entries-header-child",
+ qurl("blog.post.admin.entries", slug=slug),
+ "clock", "entries", ctx,
+ )
+ header_rows = root_hdr + post_hdr + admin_hdr + entries_hdr
+ content = ctx.get("entries_html", "")
+ return full_page(ctx, header_rows_html=header_rows, content_html=content)
+
+
+async def render_post_entries_oob(ctx: dict) -> str:
+ admin_hdr_oob = _post_admin_header_html(ctx, oob=True)
+ from quart import url_for as qurl
+ slug = (ctx.get("post") or {}).get("slug", "")
+ entries_hdr = _post_sub_admin_header_html(
+ "post_entries-row", "post_entries-header-child",
+ qurl("blog.post.admin.entries", slug=slug),
+ "clock", "entries", ctx,
+ )
+ entries_oob = _oob_header_html("post-admin-header-child", "post_entries-header-child",
+ entries_hdr)
+ content = ctx.get("entries_html", "")
+ return oob_page(ctx, oobs_html=admin_hdr_oob + entries_oob, content_html=content)
+
+
+# ---- Post edit ----
+
+async def render_post_edit_page(ctx: dict) -> str:
+ root_hdr = root_header_html(ctx)
+ post_hdr = _post_header_html(ctx)
+ admin_hdr = _post_admin_header_html(ctx)
+ from quart import url_for as qurl
+ slug = (ctx.get("post") or {}).get("slug", "")
+ edit_hdr = _post_sub_admin_header_html(
+ "post_edit-row", "post_edit-header-child",
+ qurl("blog.post.admin.edit", slug=slug),
+ "pen-to-square", "edit", ctx,
+ )
+ header_rows = root_hdr + post_hdr + admin_hdr + edit_hdr
+ content = ctx.get("edit_html", "")
+ body_end = ctx.get("body_end_html", "")
+ return full_page(ctx, header_rows_html=header_rows, content_html=content,
+ body_end_html=body_end)
+
+
+async def render_post_edit_oob(ctx: dict) -> str:
+ admin_hdr_oob = _post_admin_header_html(ctx, oob=True)
+ from quart import url_for as qurl
+ slug = (ctx.get("post") or {}).get("slug", "")
+ edit_hdr = _post_sub_admin_header_html(
+ "post_edit-row", "post_edit-header-child",
+ qurl("blog.post.admin.edit", slug=slug),
+ "pen-to-square", "edit", ctx,
+ )
+ edit_oob = _oob_header_html("post-admin-header-child", "post_edit-header-child",
+ edit_hdr)
+ content = ctx.get("edit_html", "")
+ return oob_page(ctx, oobs_html=admin_hdr_oob + edit_oob, content_html=content)
+
+
+# ---- Post settings ----
+
+async def render_post_settings_page(ctx: dict) -> str:
+ root_hdr = root_header_html(ctx)
+ post_hdr = _post_header_html(ctx)
+ admin_hdr = _post_admin_header_html(ctx)
+ from quart import url_for as qurl
+ slug = (ctx.get("post") or {}).get("slug", "")
+ settings_hdr = _post_sub_admin_header_html(
+ "post_settings-row", "post_settings-header-child",
+ qurl("blog.post.admin.settings", slug=slug),
+ "cog", "settings", ctx,
+ )
+ header_rows = root_hdr + post_hdr + admin_hdr + settings_hdr
+ content = ctx.get("settings_html", "")
+ return full_page(ctx, header_rows_html=header_rows, content_html=content)
+
+
+async def render_post_settings_oob(ctx: dict) -> str:
+ admin_hdr_oob = _post_admin_header_html(ctx, oob=True)
+ from quart import url_for as qurl
+ slug = (ctx.get("post") or {}).get("slug", "")
+ settings_hdr = _post_sub_admin_header_html(
+ "post_settings-row", "post_settings-header-child",
+ qurl("blog.post.admin.settings", slug=slug),
+ "cog", "settings", ctx,
+ )
+ settings_oob = _oob_header_html("post-admin-header-child", "post_settings-header-child",
+ settings_hdr)
+ content = ctx.get("settings_html", "")
+ return oob_page(ctx, oobs_html=admin_hdr_oob + settings_oob, content_html=content)
+
+
+# ---- Settings home ----
+
+async def render_settings_page(ctx: dict) -> str:
+ root_hdr = root_header_html(ctx)
+ settings_hdr = _settings_header_html(ctx)
+ header_rows = root_hdr + settings_hdr
+ content = _settings_main_panel_html(ctx)
+ menu_html = _settings_nav_html(ctx)
+ return full_page(ctx, header_rows_html=header_rows, content_html=content,
+ menu_html=menu_html)
+
+
+async def render_settings_oob(ctx: dict) -> str:
+ root_hdr = root_header_html(ctx, oob=True)
+ settings_oob = _oob_header_html("root-header-child", "root-settings-header-child",
+ _settings_header_html(ctx))
+ content = _settings_main_panel_html(ctx)
+ menu_html = _settings_nav_html(ctx)
+ return oob_page(ctx, oobs_html=root_hdr + settings_oob,
+ content_html=content, menu_html=menu_html)
+
+
+# ---- Cache ----
+
+async def render_cache_page(ctx: dict) -> str:
+ root_hdr = root_header_html(ctx)
+ settings_hdr = _settings_header_html(ctx)
+ from quart import url_for as qurl
+ cache_hdr = _sub_settings_header_html(
+ "cache-row", "cache-header-child",
+ qurl("settings.cache"), "refresh", "Cache", ctx,
+ )
+ header_rows = root_hdr + settings_hdr + cache_hdr
+ content = _cache_main_panel_html(ctx)
+ return full_page(ctx, header_rows_html=header_rows, content_html=content)
+
+
+async def render_cache_oob(ctx: dict) -> str:
+ settings_hdr_oob = _settings_header_html(ctx, oob=True)
+ from quart import url_for as qurl
+ cache_hdr = _sub_settings_header_html(
+ "cache-row", "cache-header-child",
+ qurl("settings.cache"), "refresh", "Cache", ctx,
+ )
+ cache_oob = _oob_header_html("root-settings-header-child", "cache-header-child",
+ cache_hdr)
+ content = _cache_main_panel_html(ctx)
+ return oob_page(ctx, oobs_html=settings_hdr_oob + cache_oob, content_html=content)
+
+
+# ---- Snippets ----
+
+async def render_snippets_page(ctx: dict) -> str:
+ root_hdr = root_header_html(ctx)
+ settings_hdr = _settings_header_html(ctx)
+ from quart import url_for as qurl
+ snippets_hdr = _sub_settings_header_html(
+ "snippets-row", "snippets-header-child",
+ qurl("snippets.list_snippets"), "puzzle-piece", "Snippets", ctx,
+ )
+ header_rows = root_hdr + settings_hdr + snippets_hdr
+ content = _snippets_main_panel_html(ctx)
+ return full_page(ctx, header_rows_html=header_rows, content_html=content)
+
+
+async def render_snippets_oob(ctx: dict) -> str:
+ settings_hdr_oob = _settings_header_html(ctx, oob=True)
+ from quart import url_for as qurl
+ snippets_hdr = _sub_settings_header_html(
+ "snippets-row", "snippets-header-child",
+ qurl("snippets.list_snippets"), "puzzle-piece", "Snippets", ctx,
+ )
+ snippets_oob = _oob_header_html("root-settings-header-child", "snippets-header-child",
+ snippets_hdr)
+ content = _snippets_main_panel_html(ctx)
+ return oob_page(ctx, oobs_html=settings_hdr_oob + snippets_oob, content_html=content)
+
+
+# ---- Menu items ----
+
+async def render_menu_items_page(ctx: dict) -> str:
+ root_hdr = root_header_html(ctx)
+ settings_hdr = _settings_header_html(ctx)
+ from quart import url_for as qurl
+ mi_hdr = _sub_settings_header_html(
+ "menu_items-row", "menu_items-header-child",
+ qurl("menu_items.list_menu_items"), "bars", "Menu Items", ctx,
+ )
+ header_rows = root_hdr + settings_hdr + mi_hdr
+ content = _menu_items_main_panel_html(ctx)
+ return full_page(ctx, header_rows_html=header_rows, content_html=content)
+
+
+async def render_menu_items_oob(ctx: dict) -> str:
+ settings_hdr_oob = _settings_header_html(ctx, oob=True)
+ from quart import url_for as qurl
+ mi_hdr = _sub_settings_header_html(
+ "menu_items-row", "menu_items-header-child",
+ qurl("menu_items.list_menu_items"), "bars", "Menu Items", ctx,
+ )
+ mi_oob = _oob_header_html("root-settings-header-child", "menu_items-header-child",
+ mi_hdr)
+ content = _menu_items_main_panel_html(ctx)
+ return oob_page(ctx, oobs_html=settings_hdr_oob + mi_oob, content_html=content)
+
+
+# ---- Tag groups ----
+
+async def render_tag_groups_page(ctx: dict) -> str:
+ root_hdr = root_header_html(ctx)
+ settings_hdr = _settings_header_html(ctx)
+ from quart import url_for as qurl
+ tg_hdr = _sub_settings_header_html(
+ "tag-groups-row", "tag-groups-header-child",
+ qurl("blog.tag_groups_admin.index"), "tags", "Tag Groups", ctx,
+ )
+ header_rows = root_hdr + settings_hdr + tg_hdr
+ content = _tag_groups_main_panel_html(ctx)
+ return full_page(ctx, header_rows_html=header_rows, content_html=content)
+
+
+async def render_tag_groups_oob(ctx: dict) -> str:
+ settings_hdr_oob = _settings_header_html(ctx, oob=True)
+ from quart import url_for as qurl
+ tg_hdr = _sub_settings_header_html(
+ "tag-groups-row", "tag-groups-header-child",
+ qurl("blog.tag_groups_admin.index"), "tags", "Tag Groups", ctx,
+ )
+ tg_oob = _oob_header_html("root-settings-header-child", "tag-groups-header-child",
+ tg_hdr)
+ content = _tag_groups_main_panel_html(ctx)
+ return oob_page(ctx, oobs_html=settings_hdr_oob + tg_oob, content_html=content)
+
+
+# ---- Tag group edit ----
+
+async def render_tag_group_edit_page(ctx: dict) -> str:
+ root_hdr = root_header_html(ctx)
+ settings_hdr = _settings_header_html(ctx)
+ from quart import url_for as qurl
+ g_id = (ctx.get("group") or {}).get("id") or getattr(ctx.get("group"), "id", None)
+ tg_hdr = _sub_settings_header_html(
+ "tag-groups-row", "tag-groups-header-child",
+ qurl("blog.tag_groups_admin.edit", id=g_id), "tags", "Tag Groups", ctx,
+ )
+ header_rows = root_hdr + settings_hdr + tg_hdr
+ content = _tag_groups_edit_main_panel_html(ctx)
+ return full_page(ctx, header_rows_html=header_rows, content_html=content)
+
+
+async def render_tag_group_edit_oob(ctx: dict) -> str:
+ settings_hdr_oob = _settings_header_html(ctx, oob=True)
+ from quart import url_for as qurl
+ g_id = (ctx.get("group") or {}).get("id") or getattr(ctx.get("group"), "id", None)
+ tg_hdr = _sub_settings_header_html(
+ "tag-groups-row", "tag-groups-header-child",
+ qurl("blog.tag_groups_admin.edit", id=g_id), "tags", "Tag Groups", ctx,
+ )
+ tg_oob = _oob_header_html("root-settings-header-child", "tag-groups-header-child",
+ tg_hdr)
+ content = _tag_groups_edit_main_panel_html(ctx)
+ return oob_page(ctx, oobs_html=settings_hdr_oob + tg_oob, content_html=content)
diff --git a/cart/app.py b/cart/app.py
index 0eefe90..255c605 100644
--- a/cart/app.py
+++ b/cart/app.py
@@ -1,5 +1,6 @@
from __future__ import annotations
import path_setup # noqa: F401 # adds shared/ to sys.path
+import sexp_components # noqa: F401 # ensure Hypercorn --reload watches this file
from decimal import Decimal
from pathlib import Path
diff --git a/cart/bp/cart/overview_routes.py b/cart/bp/cart/overview_routes.py
index 15f9eb6..8ac65c4 100644
--- a/cart/bp/cart/overview_routes.py
+++ b/cart/bp/cart/overview_routes.py
@@ -14,18 +14,16 @@ def register(url_prefix: str) -> Blueprint:
@bp.get("/")
async def overview():
from quart import g
+ from shared.sexp.page import get_template_context
+ from sexp_components import render_overview_page, render_overview_oob
+
page_groups = await get_cart_grouped_by_page(g.s)
+ ctx = await get_template_context()
if not is_htmx_request():
- html = await render_template(
- "_types/cart/overview/index.html",
- page_groups=page_groups,
- )
+ html = await render_overview_page(ctx, page_groups)
else:
- html = await render_template(
- "_types/cart/overview/_oob_elements.html",
- page_groups=page_groups,
- )
+ html = await render_overview_oob(ctx, page_groups)
return await make_response(html)
return bp
diff --git a/cart/bp/cart/page_routes.py b/cart/bp/cart/page_routes.py
index 729fc57..59749e6 100644
--- a/cart/bp/cart/page_routes.py
+++ b/cart/bp/cart/page_routes.py
@@ -40,10 +40,20 @@ def register(url_prefix: str) -> Blueprint:
ticket_total=ticket_total,
)
+ from shared.sexp.page import get_template_context
+ from sexp_components import render_page_cart_page, render_page_cart_oob
+
+ ctx = await get_template_context()
if not is_htmx_request():
- html = await render_template("_types/cart/page/index.html", **tpl_ctx)
+ html = await render_page_cart_page(
+ ctx, post, cart, cal_entries, page_tickets,
+ ticket_groups, total, calendar_total, ticket_total,
+ )
else:
- html = await render_template("_types/cart/page/_oob_elements.html", **tpl_ctx)
+ html = await render_page_cart_oob(
+ ctx, post, cart, cal_entries, page_tickets,
+ ticket_groups, total, calendar_total, ticket_total,
+ )
return await make_response(html)
@bp.post("/checkout/")
diff --git a/cart/bp/order/routes.py b/cart/bp/order/routes.py
index 2452679..bb869e4 100644
--- a/cart/bp/order/routes.py
+++ b/cart/bp/order/routes.py
@@ -55,12 +55,16 @@ def register() -> Blueprint:
order = result.scalar_one_or_none()
if not order:
return await make_response("Order not found", 404)
+ from shared.sexp.page import get_template_context
+ from sexp_components import render_order_page, render_order_oob
+
+ ctx = await get_template_context()
+ calendar_entries = ctx.get("calendar_entries")
+
if not is_htmx_request():
- # Normal browser request: full page with layout
- html = await render_template("_types/order/index.html", order=order,)
+ html = await render_order_page(ctx, order, calendar_entries, url_for)
else:
- # HTMX navigation (page 1): main panel + OOB elements
- html = await render_template("_types/order/_oob_elements.html", order=order,)
+ html = await render_order_oob(ctx, order, calendar_entries, url_for)
return await make_response(html)
diff --git a/cart/bp/orders/routes.py b/cart/bp/orders/routes.py
index a6fbd8a..cdbf717 100644
--- a/cart/bp/orders/routes.py
+++ b/cart/bp/orders/routes.py
@@ -136,24 +136,30 @@ def register(url_prefix: str) -> Blueprint:
result = await g.s.execute(stmt)
orders = result.scalars().all()
- context = {
- "orders": orders,
- "page": page,
- "total_pages": total_pages,
- "search": search,
- "search_count": total_count, # For search display
- }
+ from shared.sexp.page import get_template_context
+ from sexp_components import (
+ render_orders_page,
+ render_orders_rows,
+ render_orders_oob,
+ )
+
+ ctx = await get_template_context()
+ qs_fn = makeqs_factory()
- # Determine which template to use based on request type and pagination
if not is_htmx_request():
- # Normal browser request: full page with layout
- html = await render_template("_types/orders/index.html", **context)
+ html = await render_orders_page(
+ ctx, orders, page, total_pages, search, total_count,
+ url_for, qs_fn,
+ )
elif page > 1:
- # HTMX pagination: just table rows + sentinel
- html = await render_template("_types/orders/_rows.html", **context)
+ html = await render_orders_rows(
+ ctx, orders, page, total_pages, url_for, qs_fn,
+ )
else:
- # HTMX navigation (page 1): main panel + OOB elements
- html = await render_template("_types/orders/_oob_elements.html", **context)
+ html = await render_orders_oob(
+ ctx, orders, page, total_pages, search, total_count,
+ url_for, qs_fn,
+ )
resp = await make_response(html)
resp.headers["Hx-Push-Url"] = _current_url_without_page()
diff --git a/cart/sexp_components.py b/cart/sexp_components.py
new file mode 100644
index 0000000..b769cda
--- /dev/null
+++ b/cart/sexp_components.py
@@ -0,0 +1,805 @@
+"""
+Cart service s-expression page components.
+
+Renders cart overview, page cart, orders list, and single order detail.
+Called from route handlers in place of ``render_template()``.
+"""
+from __future__ import annotations
+
+from typing import Any
+
+from shared.sexp.jinja_bridge import sexp
+from shared.sexp.helpers import (
+ call_url, root_header_html, search_desktop_html,
+ search_mobile_html, full_page, oob_page,
+)
+from shared.infrastructure.urls import market_product_url
+
+
+# ---------------------------------------------------------------------------
+# Header helpers
+# ---------------------------------------------------------------------------
+
+def _cart_header_html(ctx: dict, *, oob: bool = False) -> str:
+ """Build the cart section header row."""
+ return sexp(
+ '(~menu-row :id "cart-row" :level 1 :colour "sky"'
+ ' :link-href lh :link-label "cart" :icon "fa fa-shopping-cart"'
+ ' :child-id "cart-header-child" :oob oob)',
+ lh=call_url(ctx, "cart_url", "/"),
+ oob=oob,
+ )
+
+
+def _page_cart_header_html(ctx: dict, page_post: Any, *, oob: bool = False) -> str:
+ """Build the per-page cart header row."""
+ slug = page_post.slug if page_post else ""
+ title = (page_post.title or "")[:160]
+ img_html = ""
+ if page_post and page_post.feature_image:
+ img_html = (
+ f'
'
+ )
+ label_html = f'{img_html}
{title} '
+ nav_html = sexp(
+ '(a :href h :class "inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition"'
+ ' (raw! i) "All carts")',
+ h=call_url(ctx, "cart_url", "/"),
+ i='
',
+ )
+ return sexp(
+ '(~menu-row :id "page-cart-row" :level 2 :colour "sky"'
+ ' :link-href lh :link-label-html llh :nav-html nh :oob oob)',
+ lh=call_url(ctx, "cart_url", f"/{slug}/"),
+ llh=label_html,
+ nh=nav_html,
+ oob=oob,
+ )
+
+
+def _auth_header_html(ctx: dict, *, oob: bool = False) -> str:
+ """Build the account section header row (for orders)."""
+ return sexp(
+ '(~menu-row :id "auth-row" :level 1 :colour "sky"'
+ ' :link-href lh :link-label "account" :icon "fa-solid fa-user"'
+ ' :child-id "auth-header-child" :oob oob)',
+ lh=call_url(ctx, "account_url", "/"),
+ oob=oob,
+ )
+
+
+def _orders_header_html(ctx: dict, list_url: str) -> str:
+ """Build the orders section header row."""
+ return sexp(
+ '(~menu-row :id "orders-row" :level 2 :colour "sky"'
+ ' :link-href lh :link-label "Orders" :icon "fa fa-gbp"'
+ ' :child-id "orders-header-child")',
+ lh=list_url,
+ )
+
+
+# ---------------------------------------------------------------------------
+# Cart overview
+# ---------------------------------------------------------------------------
+
+def _page_group_card_html(grp: Any, ctx: dict) -> str:
+ """Render a single page group card for cart overview."""
+ post = grp.get("post") if isinstance(grp, dict) else getattr(grp, "post", None)
+ cart_items = grp.get("cart_items", []) if isinstance(grp, dict) else getattr(grp, "cart_items", [])
+ cal_entries = grp.get("calendar_entries", []) if isinstance(grp, dict) else getattr(grp, "calendar_entries", [])
+ tickets = grp.get("tickets", []) if isinstance(grp, dict) else getattr(grp, "tickets", [])
+ product_count = grp.get("product_count", 0) if isinstance(grp, dict) else getattr(grp, "product_count", 0)
+ calendar_count = grp.get("calendar_count", 0) if isinstance(grp, dict) else getattr(grp, "calendar_count", 0)
+ ticket_count = grp.get("ticket_count", 0) if isinstance(grp, dict) else getattr(grp, "ticket_count", 0)
+ total = grp.get("total", 0) if isinstance(grp, dict) else getattr(grp, "total", 0)
+ market_place = grp.get("market_place") if isinstance(grp, dict) else getattr(grp, "market_place", None)
+
+ if not cart_items and not cal_entries and not tickets:
+ return ""
+
+ # Count badges
+ badges = []
+ if product_count > 0:
+ s = "s" if product_count != 1 else ""
+ badges.append(
+ f'
'
+ f' {product_count} item{s} '
+ )
+ if calendar_count > 0:
+ s = "s" if calendar_count != 1 else ""
+ badges.append(
+ f'
'
+ f' {calendar_count} booking{s} '
+ )
+ if ticket_count > 0:
+ s = "s" if ticket_count != 1 else ""
+ badges.append(
+ f'
'
+ f' {ticket_count} ticket{s} '
+ )
+ badges_html = '
' + "".join(badges) + '
'
+
+ if post:
+ slug = post.slug if hasattr(post, "slug") else post.get("slug", "")
+ title = post.title if hasattr(post, "title") else post.get("title", "")
+ feature_image = post.feature_image if hasattr(post, "feature_image") else post.get("feature_image")
+ cart_url = call_url(ctx, "cart_url", f"/{slug}/")
+
+ if feature_image:
+ img = f'
'
+ else:
+ img = '
'
+
+ mp_name = ""
+ mp_sub = ""
+ if market_place:
+ mp_name = market_place.name if hasattr(market_place, "name") else market_place.get("name", "")
+ mp_sub = f'
{title}
'
+ display_title = mp_name or title
+
+ return (
+ f'
'
+ f'{img}'
+ f'
{display_title} {mp_sub}{badges_html}'
+ f'
£{total:.2f}
'
+ f'
View cart →
'
+ )
+ else:
+ # Orphan items
+ badges_html_amber = badges_html.replace("bg-stone-100", "bg-amber-100")
+ return (
+ f'
'
+ f'
'
+ f'
'
+ f'
'
+ f'
Other items {badges_html_amber}'
+ f'
'
+ )
+
+
+def _overview_main_panel_html(page_groups: list, ctx: dict) -> str:
+ """Cart overview main panel."""
+ if not page_groups:
+ return (
+ '
'
+ '
'
+ '
'
+ '
'
+ '
Your cart is empty
'
+ )
+
+ cards = [_page_group_card_html(grp, ctx) for grp in page_groups]
+ has_items = any(c for c in cards)
+ if not has_items:
+ return (
+ '
'
+ '
'
+ '
'
+ '
'
+ '
Your cart is empty
'
+ )
+
+ return '
'
+
+
+# ---------------------------------------------------------------------------
+# Page cart
+# ---------------------------------------------------------------------------
+
+def _cart_item_html(item: Any, ctx: dict) -> str:
+ """Render a single product cart item."""
+ from shared.browser.app.csrf import generate_csrf_token
+ from quart import url_for
+
+ p = item.product if hasattr(item, "product") else item
+ slug = p.slug if hasattr(p, "slug") else ""
+ unit_price = getattr(p, "special_price", None) or getattr(p, "regular_price", None)
+ currency = getattr(p, "regular_price_currency", "GBP") or "GBP"
+ symbol = "\u00a3" if currency == "GBP" else currency
+ csrf = generate_csrf_token()
+ qty_url = url_for("cart_global.update_quantity", product_id=p.id)
+ prod_url = market_product_url(slug)
+
+ if p.image:
+ img = f'
'
+ else:
+ img = '
No image
'
+
+ price_html = ""
+ if unit_price:
+ price_html = f'
{symbol}{unit_price:.2f}
'
+ if p.special_price and p.special_price != p.regular_price:
+ price_html += f'
{symbol}{p.regular_price:.2f}
'
+ else:
+ price_html = '
No price
'
+
+ deleted_html = ""
+ if getattr(item, "is_deleted", False):
+ deleted_html = (
+ '
'
+ ' '
+ ' This item is no longer available or price has changed
'
+ )
+
+ brand_html = f'
{p.brand}
' if getattr(p, "brand", None) else ""
+
+ line_total_html = ""
+ if unit_price:
+ lt = unit_price * item.quantity
+ line_total_html = f'
Line total: {symbol}{lt:.2f}
'
+
+ return (
+ f'
'
+ f'{img}
'
+ f''
+ f'
'
+ f'
{brand_html}{deleted_html}
'
+ f'
{price_html}
'
+ f'
'
+ f'
'
+ f'Quantity '
+ f''
+ f'{item.quantity} '
+ f'
'
+ f'
{line_total_html}
'
+ )
+
+
+def _calendar_entries_html(entries: list) -> str:
+ """Render calendar booking entries in cart."""
+ if not entries:
+ return ""
+ items = []
+ for e in entries:
+ name = getattr(e, "name", None) or getattr(e, "calendar_name", "")
+ start = e.start_at if hasattr(e, "start_at") else ""
+ end = getattr(e, "end_at", None)
+ cost = getattr(e, "cost", 0) or 0
+ end_html = f" \u2013 {end}" if end else ""
+ items.append(
+ f'
'
+ f'{name}
'
+ f'
{start}{end_html}
'
+ f'\u00a3{cost:.2f}
'
+ )
+ return (
+ '
'
+ '
Calendar bookings '
+ f'
'
+ )
+
+
+def _ticket_groups_html(ticket_groups: list, ctx: dict) -> str:
+ """Render ticket groups in cart."""
+ if not ticket_groups:
+ return ""
+ from shared.browser.app.csrf import generate_csrf_token
+ from quart import url_for
+
+ csrf = generate_csrf_token()
+ qty_url = url_for("cart_global.update_ticket_quantity")
+ parts = ['
',
+ '
Event tickets',
+ '
']
+
+ for tg in ticket_groups:
+ name = tg.entry_name if hasattr(tg, "entry_name") else tg.get("entry_name", "")
+ tt_name = tg.ticket_type_name if hasattr(tg, "ticket_type_name") else tg.get("ticket_type_name", "")
+ price = tg.price if hasattr(tg, "price") else tg.get("price", 0)
+ quantity = tg.quantity if hasattr(tg, "quantity") else tg.get("quantity", 0)
+ line_total = tg.line_total if hasattr(tg, "line_total") else tg.get("line_total", 0)
+ entry_id = tg.entry_id if hasattr(tg, "entry_id") else tg.get("entry_id", "")
+ tt_id = tg.ticket_type_id if hasattr(tg, "ticket_type_id") else tg.get("ticket_type_id", "")
+ start_at = tg.entry_start_at if hasattr(tg, "entry_start_at") else tg.get("entry_start_at")
+ end_at = tg.entry_end_at if hasattr(tg, "entry_end_at") else tg.get("entry_end_at")
+
+ date_str = start_at.strftime("%-d %b %Y, %H:%M") if start_at else ""
+ if end_at:
+ date_str += f" \u2013 {end_at.strftime('%-d %b %Y, %H:%M')}"
+
+ tt_name_html = f'
{tt_name}
' if tt_name else ""
+ tt_hidden = f'
' if tt_id else ""
+
+ parts.append(
+ f'
'
+ f''
+ f'
'
+ f'
{name} {tt_name_html}'
+ f'
{date_str}
'
+ f'
'
+ f'
'
+ f'
'
+ f'Quantity '
+ f''
+ f'{quantity} '
+ f'
'
+ f'
'
+ f'
Line total: \u00a3{line_total:.2f}
'
+ )
+
+ parts.append('
')
+ return "".join(parts)
+
+
+def _cart_summary_html(ctx: dict, cart: list, cal_entries: list, tickets: list,
+ total_fn: Any, cal_total_fn: Any, ticket_total_fn: Any) -> str:
+ """Render the order summary sidebar."""
+ from shared.browser.app.csrf import generate_csrf_token
+ from quart import g, url_for, request
+ from shared.infrastructure.urls import login_url
+
+ csrf = generate_csrf_token()
+ product_qty = sum(ci.quantity for ci in cart) if cart else 0
+ ticket_qty = len(tickets) if tickets else 0
+ item_count = product_qty + ticket_qty
+
+ product_total = total_fn(cart) or 0
+ cal_total = cal_total_fn(cal_entries) or 0
+ tk_total = ticket_total_fn(tickets) or 0
+ grand = float(product_total) + float(cal_total) + float(tk_total)
+
+ symbol = "\u00a3"
+ if cart and hasattr(cart[0], "product") and getattr(cart[0].product, "regular_price_currency", None):
+ cur = cart[0].product.regular_price_currency
+ symbol = "\u00a3" if cur == "GBP" else cur
+
+ user = getattr(g, "user", None)
+ page_post = ctx.get("page_post")
+
+ if user:
+ if page_post:
+ action = url_for("page_cart.page_checkout")
+ else:
+ action = url_for("cart_global.checkout")
+ from shared.utils import route_prefix
+ action = route_prefix() + action
+ checkout_html = (
+ f'
'
+ )
+ else:
+ href = login_url(request.url)
+ checkout_html = (
+ f'
'
+ )
+
+ return (
+ f'
'
+ f'
Order summary '
+ f'
'
+ f'
Items {item_count} '
+ f'
Subtotal {symbol}{grand:.2f} '
+ f'
This is a test - it will not take actual money '
+ f'
use dummy card number: 5555 5555 5555 4444
'
+ f'
{checkout_html}
'
+ )
+
+
+def _page_cart_main_panel_html(ctx: dict, cart: list, cal_entries: list,
+ tickets: list, ticket_groups: list,
+ total_fn: Any, cal_total_fn: Any,
+ ticket_total_fn: Any) -> str:
+ """Page cart main panel."""
+ if not cart and not cal_entries and not tickets:
+ return (
+ '
'
+ '
'
+ '
'
+ '
'
+ '
Your cart is empty
'
+ )
+
+ items_html = "".join(_cart_item_html(item, ctx) for item in cart)
+ cal_html = _calendar_entries_html(cal_entries)
+ tickets_html = _ticket_groups_html(ticket_groups, ctx)
+ summary_html = _cart_summary_html(ctx, cart, cal_entries, tickets, total_fn, cal_total_fn, ticket_total_fn)
+
+ return (
+ f'
'
+ f'
{items_html}{cal_html}{tickets_html} '
+ f'{summary_html}
'
+ )
+
+
+# ---------------------------------------------------------------------------
+# Orders list (same pattern as orders service)
+# ---------------------------------------------------------------------------
+
+def _order_row_html(order: Any, detail_url: str) -> str:
+ """Render a single order as desktop table row + mobile card."""
+ status = order.status or "pending"
+ sl = status.lower()
+ pill = (
+ "border-emerald-300 bg-emerald-50 text-emerald-700" if sl == "paid"
+ else "border-rose-300 bg-rose-50 text-rose-700" if sl in ("failed", "cancelled")
+ else "border-stone-300 bg-stone-50 text-stone-700"
+ )
+ created = order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else "\u2014"
+ total = f"{order.currency or 'GBP'} {order.total_amount or 0:.2f}"
+
+ return (
+ f'
'
+ f'#{order.id} '
+ f'{created} '
+ f'{order.description or ""} '
+ f'{total} '
+ f'{status} '
+ f'View '
+ f'
'
+ f'
#{order.id} '
+ f'{status}
'
+ f'
{created}
'
+ f'
'
+ )
+
+
+def _orders_rows_html(orders: list, page: int, total_pages: int,
+ url_for_fn: Any, qs_fn: Any) -> str:
+ """Render order rows + infinite scroll sentinel."""
+ from shared.utils import route_prefix
+ pfx = route_prefix()
+
+ parts = [
+ _order_row_html(o, pfx + url_for_fn("orders.order.order_detail", order_id=o.id))
+ for o in orders
+ ]
+
+ if page < total_pages:
+ next_url = pfx + url_for_fn("orders.list_orders") + qs_fn(page=page + 1)
+ parts.append(sexp(
+ '(~infinite-scroll :url u :page p :total-pages tp :id-prefix "orders" :colspan 5)',
+ u=next_url, p=page, **{"total-pages": total_pages},
+ ))
+ else:
+ parts.append('
End of results ')
+
+ return "".join(parts)
+
+
+def _orders_main_panel_html(orders: list, rows_html: str) -> str:
+ """Main panel for orders list."""
+ if not orders:
+ return (
+ '
'
+ )
+ return (
+ '
'
+ '
'
+ '
'
+ ''
+ 'Order '
+ 'Created '
+ 'Description '
+ 'Total '
+ 'Status '
+ ' '
+ f' {rows_html}
'
+ )
+
+
+def _orders_summary_html(ctx: dict) -> str:
+ """Filter section for orders list."""
+ return (
+ '
'
+ )
+
+
+# ---------------------------------------------------------------------------
+# Single order detail
+# ---------------------------------------------------------------------------
+
+def _order_items_html(order: Any) -> str:
+ """Render order items list."""
+ if not order or not order.items:
+ return ""
+ items = []
+ for item in order.items:
+ prod_url = market_product_url(item.product_slug)
+ img = (
+ f'
'
+ if item.product_image else
+ '
No image
'
+ )
+ items.append(
+ f'
'
+ f'{img}
'
+ f''
+ f'
{item.product_title or "Unknown product"}
'
+ f'
Product ID: {item.product_id}
'
+ f'
Qty: {item.quantity}
'
+ f'
{item.currency or order.currency or "GBP"} {item.unit_price or 0:.2f}
'
+ f'
'
+ )
+ return (
+ '
'
+ )
+
+
+def _order_summary_html(order: Any) -> str:
+ """Order summary card."""
+ return sexp(
+ '(~order-summary-card :order-id oid :created-at ca :description d :status s :currency c :total-amount ta)',
+ oid=order.id,
+ ca=order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else None,
+ d=order.description, s=order.status, c=order.currency,
+ ta=f"{order.total_amount:.2f}" if order.total_amount else None,
+ )
+
+
+def _order_calendar_items_html(calendar_entries: list | None) -> str:
+ """Render calendar bookings for an order."""
+ if not calendar_entries:
+ return ""
+ items = []
+ for e in calendar_entries:
+ st = e.state or ""
+ pill = (
+ "bg-emerald-100 text-emerald-800" if st == "confirmed"
+ else "bg-amber-100 text-amber-800" if st == "provisional"
+ else "bg-blue-100 text-blue-800" if st == "ordered"
+ else "bg-stone-100 text-stone-700"
+ )
+ ds = e.start_at.strftime("%-d %b %Y, %H:%M") if e.start_at else ""
+ if e.end_at:
+ ds += f" \u2013 {e.end_at.strftime('%-d %b %Y, %H:%M')}"
+ items.append(
+ f'
'
+ f'{e.name}'
+ f''
+ f'{st.capitalize()}
'
+ f'
{ds}
'
+ f'\u00a3{e.cost or 0:.2f}
'
+ )
+ return (
+ '
'
+ 'Calendar bookings in this order '
+ f' '
+ )
+
+
+def _order_main_html(order: Any, calendar_entries: list | None) -> str:
+ """Main panel for single order detail."""
+ summary = _order_summary_html(order)
+ return f'
{summary}{_order_items_html(order)}{_order_calendar_items_html(calendar_entries)}
'
+
+
+def _order_filter_html(order: Any, list_url: str, recheck_url: str,
+ pay_url: str, csrf_token: str) -> str:
+ """Filter section for single order detail."""
+ created = order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else "\u2014"
+ status = order.status or "pending"
+ pay = (
+ f'
'
+ f' Open payment page '
+ ) if status != "paid" else ""
+
+ return (
+ '
'
+ )
+
+
+# ---------------------------------------------------------------------------
+# Public API: Cart overview
+# ---------------------------------------------------------------------------
+
+async def render_overview_page(ctx: dict, page_groups: list) -> str:
+ """Full page: cart overview."""
+ main = _overview_main_panel_html(page_groups, ctx)
+ hdr = root_header_html(ctx)
+ hdr += sexp(
+ '(div :id "root-header-child" :class "flex flex-col w-full items-center" (raw! c))',
+ c=_cart_header_html(ctx),
+ )
+ return full_page(ctx, header_rows_html=hdr, content_html=main)
+
+
+async def render_overview_oob(ctx: dict, page_groups: list) -> str:
+ """OOB response for cart overview."""
+ main = _overview_main_panel_html(page_groups, ctx)
+ oobs = (
+ _cart_header_html(ctx, oob=True)
+ + root_header_html(ctx, oob=True)
+ )
+ return oob_page(ctx, oobs_html=oobs, content_html=main)
+
+
+# ---------------------------------------------------------------------------
+# Public API: Page cart
+# ---------------------------------------------------------------------------
+
+async def render_page_cart_page(ctx: dict, page_post: Any,
+ cart: list, cal_entries: list, tickets: list,
+ ticket_groups: list, total_fn: Any,
+ cal_total_fn: Any, ticket_total_fn: Any) -> str:
+ """Full page: page-specific cart."""
+ main = _page_cart_main_panel_html(ctx, cart, cal_entries, tickets, ticket_groups,
+ total_fn, cal_total_fn, ticket_total_fn)
+ hdr = root_header_html(ctx)
+ child = _cart_header_html(ctx)
+ page_hdr = _page_cart_header_html(ctx, page_post)
+ hdr += sexp(
+ '(div :id "root-header-child" :class "flex flex-col w-full items-center" (raw! c)'
+ ' (div :id "cart-header-child" :class "flex flex-col w-full items-center" (raw! p)))',
+ c=child, p=page_hdr,
+ )
+ return full_page(ctx, header_rows_html=hdr, content_html=main)
+
+
+async def render_page_cart_oob(ctx: dict, page_post: Any,
+ cart: list, cal_entries: list, tickets: list,
+ ticket_groups: list, total_fn: Any,
+ cal_total_fn: Any, ticket_total_fn: Any) -> str:
+ """OOB response for page cart."""
+ main = _page_cart_main_panel_html(ctx, cart, cal_entries, tickets, ticket_groups,
+ total_fn, cal_total_fn, ticket_total_fn)
+ oobs = (
+ sexp('(div :id "cart-header-child" :hx-swap-oob "outerHTML" :class "flex flex-col w-full items-center" (raw! p))',
+ p=_page_cart_header_html(ctx, page_post))
+ + _cart_header_html(ctx, oob=True)
+ + root_header_html(ctx, oob=True)
+ )
+ return oob_page(ctx, oobs_html=oobs, content_html=main)
+
+
+# ---------------------------------------------------------------------------
+# Public API: Orders list
+# ---------------------------------------------------------------------------
+
+async def render_orders_page(ctx: dict, orders: list, page: int,
+ total_pages: int, search: str | None,
+ search_count: int, url_for_fn: Any,
+ qs_fn: Any) -> str:
+ """Full page: orders list."""
+ from shared.utils import route_prefix
+
+ ctx["search"] = search
+ ctx["search_count"] = search_count
+ list_url = route_prefix() + url_for_fn("orders.list_orders")
+
+ rows = _orders_rows_html(orders, page, total_pages, url_for_fn, qs_fn)
+ main = _orders_main_panel_html(orders, rows)
+
+ hdr = root_header_html(ctx)
+ hdr += sexp(
+ '(div :id "root-header-child" :class "flex flex-col w-full items-center" (raw! a)'
+ ' (div :id "auth-header-child" :class "flex flex-col w-full items-center" (raw! o)))',
+ a=_auth_header_html(ctx), o=_orders_header_html(ctx, list_url),
+ )
+
+ return full_page(ctx, header_rows_html=hdr,
+ filter_html=_orders_summary_html(ctx),
+ aside_html=search_desktop_html(ctx),
+ content_html=main)
+
+
+async def render_orders_rows(ctx: dict, orders: list, page: int,
+ total_pages: int, url_for_fn: Any,
+ qs_fn: Any) -> str:
+ """Pagination: just the table rows."""
+ return _orders_rows_html(orders, page, total_pages, url_for_fn, qs_fn)
+
+
+async def render_orders_oob(ctx: dict, orders: list, page: int,
+ total_pages: int, search: str | None,
+ search_count: int, url_for_fn: Any,
+ qs_fn: Any) -> str:
+ """OOB response for orders list."""
+ from shared.utils import route_prefix
+
+ ctx["search"] = search
+ ctx["search_count"] = search_count
+ list_url = route_prefix() + url_for_fn("orders.list_orders")
+
+ rows = _orders_rows_html(orders, page, total_pages, url_for_fn, qs_fn)
+ main = _orders_main_panel_html(orders, rows)
+
+ oobs = (
+ _auth_header_html(ctx, oob=True)
+ + sexp(
+ '(div :id "auth-header-child" :hx-swap-oob "outerHTML"'
+ ' :class "flex flex-col w-full items-center" (raw! o))',
+ o=_orders_header_html(ctx, list_url),
+ )
+ + root_header_html(ctx, oob=True)
+ )
+
+ return oob_page(ctx, oobs_html=oobs,
+ filter_html=_orders_summary_html(ctx),
+ aside_html=search_desktop_html(ctx),
+ content_html=main)
+
+
+# ---------------------------------------------------------------------------
+# Public API: Single order detail
+# ---------------------------------------------------------------------------
+
+async def render_order_page(ctx: dict, order: Any,
+ calendar_entries: list | None,
+ url_for_fn: Any) -> str:
+ """Full page: single order detail."""
+ from shared.utils import route_prefix
+ from shared.browser.app.csrf import generate_csrf_token
+
+ pfx = route_prefix()
+ detail_url = pfx + url_for_fn("orders.order.order_detail", order_id=order.id)
+ list_url = pfx + url_for_fn("orders.list_orders")
+ recheck_url = pfx + url_for_fn("orders.order.order_recheck", order_id=order.id)
+ pay_url = pfx + url_for_fn("orders.order.order_pay", order_id=order.id)
+
+ main = _order_main_html(order, calendar_entries)
+ filt = _order_filter_html(order, list_url, recheck_url, pay_url, generate_csrf_token())
+
+ hdr = root_header_html(ctx)
+ order_row = sexp(
+ '(~menu-row :id "order-row" :level 3 :colour "sky" :link-href lh :link-label ll :icon "fa fa-gbp")',
+ lh=detail_url, ll=f"Order {order.id}",
+ )
+ hdr += sexp(
+ '(div :id "root-header-child" :class "flex flex-col w-full items-center" (raw! a)'
+ ' (div :id "auth-header-child" :class "flex flex-col w-full items-center" (raw! b)'
+ ' (div :id "orders-header-child" :class "flex flex-col w-full items-center" (raw! c))))',
+ a=_auth_header_html(ctx),
+ b=_orders_header_html(ctx, list_url),
+ c=order_row,
+ )
+
+ return full_page(ctx, header_rows_html=hdr, filter_html=filt, content_html=main)
+
+
+async def render_order_oob(ctx: dict, order: Any,
+ calendar_entries: list | None,
+ url_for_fn: Any) -> str:
+ """OOB response for single order detail."""
+ from shared.utils import route_prefix
+ from shared.browser.app.csrf import generate_csrf_token
+
+ pfx = route_prefix()
+ detail_url = pfx + url_for_fn("orders.order.order_detail", order_id=order.id)
+ list_url = pfx + url_for_fn("orders.list_orders")
+ recheck_url = pfx + url_for_fn("orders.order.order_recheck", order_id=order.id)
+ pay_url = pfx + url_for_fn("orders.order.order_pay", order_id=order.id)
+
+ main = _order_main_html(order, calendar_entries)
+ filt = _order_filter_html(order, list_url, recheck_url, pay_url, generate_csrf_token())
+
+ order_row_oob = sexp(
+ '(~menu-row :id "order-row" :level 3 :colour "sky" :link-href lh :link-label ll :icon "fa fa-gbp" :oob true)',
+ lh=detail_url, ll=f"Order {order.id}",
+ )
+ oobs = (
+ sexp('(div :id "orders-header-child" :hx-swap-oob "outerHTML" :class "flex flex-col w-full items-center" (raw! o))', o=order_row_oob)
+ + root_header_html(ctx, oob=True)
+ )
+
+ return oob_page(ctx, oobs_html=oobs, filter_html=filt, content_html=main)
diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml
index 5596647..0bd15e4 100644
--- a/docker-compose.dev.yml
+++ b/docker-compose.dev.yml
@@ -45,6 +45,7 @@ services:
- ./blog/alembic.ini:/app/blog/alembic.ini:ro
- ./blog/alembic:/app/blog/alembic:ro
- ./blog/app.py:/app/app.py
+ - ./blog/sexp_components.py:/app/sexp_components.py
- ./blog/bp:/app/bp
- ./blog/services:/app/services
- ./blog/templates:/app/templates
@@ -82,6 +83,7 @@ services:
- ./market/alembic.ini:/app/market/alembic.ini:ro
- ./market/alembic:/app/market/alembic:ro
- ./market/app.py:/app/app.py
+ - ./market/sexp_components.py:/app/sexp_components.py
- ./market/bp:/app/bp
- ./market/services:/app/services
- ./market/templates:/app/templates
@@ -118,6 +120,7 @@ services:
- ./cart/alembic.ini:/app/cart/alembic.ini:ro
- ./cart/alembic:/app/cart/alembic:ro
- ./cart/app.py:/app/app.py
+ - ./cart/sexp_components.py:/app/sexp_components.py
- ./cart/bp:/app/bp
- ./cart/services:/app/services
- ./cart/templates:/app/templates
@@ -154,6 +157,7 @@ services:
- ./events/alembic.ini:/app/events/alembic.ini:ro
- ./events/alembic:/app/events/alembic:ro
- ./events/app.py:/app/app.py
+ - ./events/sexp_components.py:/app/sexp_components.py
- ./events/bp:/app/bp
- ./events/services:/app/services
- ./events/templates:/app/templates
@@ -190,6 +194,7 @@ services:
- ./federation/alembic.ini:/app/federation/alembic.ini:ro
- ./federation/alembic:/app/federation/alembic:ro
- ./federation/app.py:/app/app.py
+ - ./federation/sexp_components.py:/app/sexp_components.py
- ./federation/bp:/app/bp
- ./federation/services:/app/services
- ./federation/templates:/app/templates
@@ -226,6 +231,7 @@ services:
- ./account/alembic.ini:/app/account/alembic.ini:ro
- ./account/alembic:/app/account/alembic:ro
- ./account/app.py:/app/app.py
+ - ./account/sexp_components.py:/app/sexp_components.py
- ./account/bp:/app/bp
- ./account/services:/app/services
- ./account/templates:/app/templates
@@ -324,6 +330,7 @@ services:
- ./orders/alembic.ini:/app/orders/alembic.ini:ro
- ./orders/alembic:/app/orders/alembic:ro
- ./orders/app.py:/app/app.py
+ - ./orders/sexp_components.py:/app/sexp_components.py
- ./orders/bp:/app/bp
- ./orders/services:/app/services
- ./orders/templates:/app/templates
diff --git a/events/app.py b/events/app.py
index d450a67..1f51300 100644
--- a/events/app.py
+++ b/events/app.py
@@ -1,6 +1,7 @@
from __future__ import annotations
import path_setup # noqa: F401 # adds shared/ to sys.path
+import sexp_components # noqa: F401 # ensure Hypercorn --reload watches this file
from pathlib import Path
from quart import g, abort, request
diff --git a/events/bp/all_events/routes.py b/events/bp/all_events/routes.py
index b8cc697..c3429e0 100644
--- a/events/bp/all_events/routes.py
+++ b/events/bp/all_events/routes.py
@@ -65,19 +65,14 @@ def register() -> Blueprint:
entries, has_more, pending_tickets, page_info = await _load_entries(page)
- ctx = dict(
- entries=entries,
- has_more=has_more,
- pending_tickets=pending_tickets,
- page_info=page_info,
- page=page,
- view=view,
- )
+ from shared.sexp.page import get_template_context
+ from sexp_components import render_all_events_page, render_all_events_oob
+ ctx = await get_template_context()
if is_htmx_request():
- html = await render_template("_types/all_events/_main_panel.html", **ctx)
+ html = await render_all_events_oob(ctx, entries, has_more, pending_tickets, page_info, page, view)
else:
- html = await render_template("_types/all_events/index.html", **ctx)
+ html = await render_all_events_page(ctx, entries, has_more, pending_tickets, page_info, page, view)
return await make_response(html, 200)
@@ -88,15 +83,8 @@ def register() -> Blueprint:
entries, has_more, pending_tickets, page_info = await _load_entries(page)
- html = await render_template(
- "_types/all_events/_cards.html",
- entries=entries,
- has_more=has_more,
- pending_tickets=pending_tickets,
- page_info=page_info,
- page=page,
- view=view,
- )
+ from sexp_components import render_all_events_cards
+ html = await render_all_events_cards(entries, has_more, pending_tickets, page_info, page, view)
return await make_response(html, 200)
@bp.post("/all-tickets/adjust")
diff --git a/events/bp/calendar/admin/routes.py b/events/bp/calendar/admin/routes.py
index 3d042ff..68d92aa 100644
--- a/events/bp/calendar/admin/routes.py
+++ b/events/bp/calendar/admin/routes.py
@@ -19,13 +19,14 @@ def register():
async def admin(calendar_slug: str, **kwargs):
from shared.browser.app.utils.htmx import is_htmx_request
- # Determine which template to use based on request type
+ from shared.sexp.page import get_template_context
+ from sexp_components import render_calendar_admin_page, render_calendar_admin_oob
+
+ tctx = await get_template_context()
if not is_htmx_request():
- # Normal browser request: full page with layout
- html = await render_template("_types/calendar/admin/index.html")
+ html = await render_calendar_admin_page(tctx)
else:
- # HTMX request: main panel + OOB elements
- html = await render_template("_types/calendar/admin/_oob_elements.html")
+ html = await render_calendar_admin_oob(tctx)
return await make_response(html)
diff --git a/events/bp/calendar/routes.py b/events/bp/calendar/routes.py
index a07ff69..84f1b8c 100644
--- a/events/bp/calendar/routes.py
+++ b/events/bp/calendar/routes.py
@@ -142,47 +142,25 @@ def register():
user_entries = visible.user_entries
confirmed_entries = visible.confirmed_entries
- if not is_htmx_request():
- # Normal browser request: full page with layout
- html = await render_template(
- "_types/calendar/index.html",
+ from shared.sexp.page import get_template_context
+ from sexp_components import render_calendar_page, render_calendar_oob
+
+ tctx = await get_template_context()
+ tctx.update(dict(
qsession=qsession,
- year=year,
- month=month,
- month_name=month_name,
- weekday_names=weekday_names,
- weeks=weeks,
- prev_month=prev_month,
- prev_month_year=prev_month_year,
- next_month=next_month,
- next_month_year=next_month_year,
- prev_year=prev_year,
- next_year=next_year,
- user_entries=user_entries,
- confirmed_entries=confirmed_entries,
- month_entries=month_entries,
- )
+ year=year, month=month, month_name=month_name,
+ weekday_names=weekday_names, weeks=weeks,
+ prev_month=prev_month, prev_month_year=prev_month_year,
+ next_month=next_month, next_month_year=next_month_year,
+ prev_year=prev_year, next_year=next_year,
+ user_entries=user_entries, confirmed_entries=confirmed_entries,
+ month_entries=month_entries,
+ ))
+ if not is_htmx_request():
+ html = await render_calendar_page(tctx)
else:
-
- html = await render_template(
- "_types/calendar/_oob_elements.html",
- qsession=qsession,
- year=year,
- month=month,
- month_name=month_name,
- weekday_names=weekday_names,
- weeks=weeks,
- prev_month=prev_month,
- prev_month_year=prev_month_year,
- next_month=next_month,
- next_month_year=next_month_year,
- prev_year=prev_year,
- next_year=next_year,
- user_entries=user_entries,
- confirmed_entries=confirmed_entries,
- month_entries=month_entries,
- )
-
+ html = await render_calendar_oob(tctx)
+
return await make_response(html)
diff --git a/events/bp/calendars/routes.py b/events/bp/calendars/routes.py
index f723811..989be3e 100644
--- a/events/bp/calendars/routes.py
+++ b/events/bp/calendars/routes.py
@@ -35,14 +35,14 @@ def register():
@bp.get("/")
@cache_page(tag="calendars")
async def home(**kwargs):
+ from shared.sexp.page import get_template_context
+ from sexp_components import render_calendars_page, render_calendars_oob
+
+ ctx = await get_template_context()
if not is_htmx_request():
- html = await render_template(
- "_types/calendars/index.html",
- )
+ html = await render_calendars_page(ctx)
else:
- html = await render_template(
- "_types/calendars/_oob_elements.html",
- )
+ html = await render_calendars_oob(ctx)
return await make_response(html)
diff --git a/events/bp/day/admin/routes.py b/events/bp/day/admin/routes.py
index b14cfe7..a895f7b 100644
--- a/events/bp/day/admin/routes.py
+++ b/events/bp/day/admin/routes.py
@@ -17,12 +17,14 @@ def register():
async def admin(year: int, month: int, day: int, **kwargs):
from shared.browser.app.utils.htmx import is_htmx_request
- # Determine which template to use based on request type
+ from shared.sexp.page import get_template_context
+ from sexp_components import render_day_admin_page, render_day_admin_oob
+
+ tctx = await get_template_context()
if not is_htmx_request():
- # Normal browser request: full page with layout
- html = await render_template("_types/day/admin/index.html")
+ html = await render_day_admin_page(tctx)
else:
- html = await render_template("_types/day/admin/_oob_elements.html")
+ html = await render_day_admin_oob(tctx)
return await make_response(html)
return bp
diff --git a/events/bp/day/routes.py b/events/bp/day/routes.py
index 7fbe550..6e74fe7 100644
--- a/events/bp/day/routes.py
+++ b/events/bp/day/routes.py
@@ -120,16 +120,14 @@ def register():
- all confirmed + provisional + ordered entries for that day (all users)
- pending only for current user/session
"""
+ from shared.sexp.page import get_template_context
+ from sexp_components import render_day_page, render_day_oob
+
+ tctx = await get_template_context()
if not is_htmx_request():
- # Normal browser request: full page with layout
- html = await render_template(
- "_types/day/index.html",
- )
+ html = await render_day_page(tctx)
else:
-
- html = await render_template(
- "_types/day/_oob_elements.html",
- )
+ html = await render_day_oob(tctx)
return await make_response(html)
@bp.get("/w/
/")
diff --git a/events/bp/markets/routes.py b/events/bp/markets/routes.py
index bac523f..385a5b6 100644
--- a/events/bp/markets/routes.py
+++ b/events/bp/markets/routes.py
@@ -23,10 +23,14 @@ def register():
@bp.get("/")
async def home(**kwargs):
+ from shared.sexp.page import get_template_context
+ from sexp_components import render_markets_page, render_markets_oob
+
+ ctx = await get_template_context()
if not is_htmx_request():
- html = await render_template("_types/markets/index.html")
+ html = await render_markets_page(ctx)
else:
- html = await render_template("_types/markets/_oob_elements.html")
+ html = await render_markets_oob(ctx)
return await make_response(html)
@bp.post("/new/")
diff --git a/events/bp/page/routes.py b/events/bp/page/routes.py
index 7944df3..f711801 100644
--- a/events/bp/page/routes.py
+++ b/events/bp/page/routes.py
@@ -45,19 +45,14 @@ def register() -> Blueprint:
entries, has_more, pending_tickets = await _load_entries(post["id"], page)
- ctx = dict(
- entries=entries,
- has_more=has_more,
- pending_tickets=pending_tickets,
- page_info={},
- page=page,
- view=view,
- )
+ from shared.sexp.page import get_template_context
+ from sexp_components import render_page_summary_page, render_page_summary_oob
+ ctx = await get_template_context()
if is_htmx_request():
- html = await render_template("_types/page_summary/_main_panel.html", **ctx)
+ html = await render_page_summary_oob(ctx, entries, has_more, pending_tickets, {}, page, view)
else:
- html = await render_template("_types/page_summary/index.html", **ctx)
+ html = await render_page_summary_page(ctx, entries, has_more, pending_tickets, {}, page, view)
return await make_response(html, 200)
@@ -69,15 +64,8 @@ def register() -> Blueprint:
entries, has_more, pending_tickets = await _load_entries(post["id"], page)
- html = await render_template(
- "_types/page_summary/_cards.html",
- entries=entries,
- has_more=has_more,
- pending_tickets=pending_tickets,
- page_info={},
- page=page,
- view=view,
- )
+ from sexp_components import render_page_summary_cards
+ html = await render_page_summary_cards(entries, has_more, pending_tickets, {}, page, view, post)
return await make_response(html, 200)
@bp.post("/tickets/adjust")
diff --git a/events/bp/payments/routes.py b/events/bp/payments/routes.py
index 5957cef..5fb6e09 100644
--- a/events/bp/payments/routes.py
+++ b/events/bp/payments/routes.py
@@ -42,11 +42,17 @@ def register():
@bp.get("/")
@require_admin
async def home(**kwargs):
- ctx = await _load_payment_ctx()
+ pay_ctx = await _load_payment_ctx()
+
+ from shared.sexp.page import get_template_context
+ from sexp_components import render_payments_page, render_payments_oob
+
+ ctx = await get_template_context()
+ ctx.update(pay_ctx)
if not is_htmx_request():
- html = await render_template("_types/payments/index.html", **ctx)
+ html = await render_payments_page(ctx)
else:
- html = await render_template("_types/payments/_oob_elements.html", **ctx)
+ html = await render_payments_oob(ctx)
return await make_response(html)
@bp.put("/")
diff --git a/events/bp/ticket_admin/routes.py b/events/bp/ticket_admin/routes.py
index 3168a29..945ea6c 100644
--- a/events/bp/ticket_admin/routes.py
+++ b/events/bp/ticket_admin/routes.py
@@ -70,18 +70,14 @@ def register() -> Blueprint:
"reserved": reserved or 0,
}
+ from shared.sexp.page import get_template_context
+ from sexp_components import render_ticket_admin_page, render_ticket_admin_oob
+
+ ctx = await get_template_context()
if not is_htmx_request():
- html = await render_template(
- "_types/ticket_admin/index.html",
- tickets=tickets,
- stats=stats,
- )
+ html = await render_ticket_admin_page(ctx, tickets, stats)
else:
- html = await render_template(
- "_types/ticket_admin/_main_panel.html",
- tickets=tickets,
- stats=stats,
- )
+ html = await render_ticket_admin_oob(ctx, tickets, stats)
return await make_response(html, 200)
diff --git a/events/bp/tickets/routes.py b/events/bp/tickets/routes.py
index a7497b9..2f1a9ce 100644
--- a/events/bp/tickets/routes.py
+++ b/events/bp/tickets/routes.py
@@ -50,16 +50,14 @@ def register() -> Blueprint:
session_id=ident["session_id"],
)
+ from shared.sexp.page import get_template_context
+ from sexp_components import render_tickets_page, render_tickets_oob
+
+ ctx = await get_template_context()
if not is_htmx_request():
- html = await render_template(
- "_types/tickets/index.html",
- tickets=tickets,
- )
+ html = await render_tickets_page(ctx, tickets)
else:
- html = await render_template(
- "_types/tickets/_main_panel.html",
- tickets=tickets,
- )
+ html = await render_tickets_oob(ctx, tickets)
return await make_response(html, 200)
@@ -83,16 +81,14 @@ def register() -> Blueprint:
else:
return await make_response("Ticket not found", 404)
+ from shared.sexp.page import get_template_context
+ from sexp_components import render_ticket_detail_page, render_ticket_detail_oob
+
+ ctx = await get_template_context()
if not is_htmx_request():
- html = await render_template(
- "_types/tickets/detail.html",
- ticket=ticket,
- )
+ html = await render_ticket_detail_page(ctx, ticket)
else:
- html = await render_template(
- "_types/tickets/_detail_panel.html",
- ticket=ticket,
- )
+ html = await render_ticket_detail_oob(ctx, ticket)
return await make_response(html, 200)
diff --git a/events/sexp_components.py b/events/sexp_components.py
new file mode 100644
index 0000000..9a0105c
--- /dev/null
+++ b/events/sexp_components.py
@@ -0,0 +1,1872 @@
+"""
+Events service s-expression page components.
+
+Renders all events, page summary, calendars, calendar month, day, day admin,
+calendar admin, tickets, ticket admin, markets, and payments pages.
+Called from route handlers in place of ``render_template()``.
+"""
+from __future__ import annotations
+
+from typing import Any
+from markupsafe import escape
+
+from shared.sexp.jinja_bridge import sexp
+from shared.sexp.helpers import (
+ call_url, get_asset_url, root_header_html,
+ search_mobile_html, search_desktop_html,
+ full_page, oob_page,
+)
+
+
+# ---------------------------------------------------------------------------
+# OOB header helper (same pattern as market)
+# ---------------------------------------------------------------------------
+
+def _oob_header_html(parent_id: str, child_id: str, row_html: str) -> str:
+ """Wrap a header row in OOB div with child placeholder."""
+ return (
+ f''
+ )
+
+
+# ---------------------------------------------------------------------------
+# Post header helpers (mirrors events/templates/_types/post/header/_header.html)
+# ---------------------------------------------------------------------------
+
+def _post_header_html(ctx: dict, *, oob: bool = False) -> str:
+ """Build the post-level header row."""
+ post = ctx.get("post") or {}
+ slug = post.get("slug", "")
+ title = (post.get("title") or "")[:160]
+ feature_image = post.get("feature_image")
+
+ label_parts = []
+ if feature_image:
+ label_parts.append(
+ f' '
+ )
+ label_parts.append(f"{escape(title)} ")
+ label_html = "".join(label_parts)
+
+ nav_parts = []
+ page_cart_count = ctx.get("page_cart_count", 0)
+ if page_cart_count and page_cart_count > 0:
+ cart_href = call_url(ctx, "cart_url", f"/{slug}/")
+ nav_parts.append(
+ f''
+ f' '
+ f'{page_cart_count} '
+ )
+
+ # Post nav: calendar links + admin
+ nav_parts.append(_post_nav_html(ctx))
+
+ nav_html = "".join(nav_parts)
+ link_href = call_url(ctx, "blog_url", f"/{slug}/")
+
+ return sexp(
+ '(~menu-row :id "post-row" :level 1'
+ ' :link-href lh :link-label-html llh'
+ ' :nav-html nh :child-id "post-header-child" :oob oob)',
+ lh=link_href,
+ llh=label_html,
+ nh=nav_html,
+ oob=oob,
+ )
+
+
+def _post_nav_html(ctx: dict) -> str:
+ """Post desktop nav: calendar links + admin gear."""
+ from quart import url_for
+
+ calendars = ctx.get("calendars") or []
+ rights = ctx.get("rights") or {}
+ is_admin = getattr(rights, "admin", False) if hasattr(rights, "admin") else rights.get("admin", False)
+ hx_select = ctx.get("hx_select_search", "#main-panel")
+ select_colours = ctx.get("select_colours", "")
+ post = ctx.get("post") or {}
+ slug = post.get("slug", "")
+
+ parts = []
+ for cal in calendars:
+ cal_slug = getattr(cal, "slug", "") if hasattr(cal, "slug") else cal.get("slug", "")
+ cal_name = getattr(cal, "name", "") if hasattr(cal, "name") else cal.get("name", "")
+ href = url_for("calendars.calendar.get", calendar_slug=cal_slug)
+ parts.append(sexp(
+ '(~nav-link :href h :icon "fa fa-calendar" :label l :select-colours sc)',
+ h=href,
+ l=cal_name,
+ sc=select_colours,
+ ))
+ if is_admin:
+ admin_href = call_url(ctx, "blog_url", f"/{slug}/admin/")
+ parts.append(
+ f''
+ f' '
+ )
+ return "".join(parts)
+
+
+# ---------------------------------------------------------------------------
+# Post admin header
+# ---------------------------------------------------------------------------
+
+def _post_admin_header_html(ctx: dict, *, oob: bool = False) -> str:
+ """Build the post-admin-level header row."""
+ post = ctx.get("post") or {}
+ slug = post.get("slug", "")
+ link_href = call_url(ctx, "blog_url", f"/{slug}/admin/")
+
+ return sexp(
+ '(~menu-row :id "post-admin-row" :level 2'
+ ' :link-href lh :link-label "admin" :icon "fa fa-cog"'
+ ' :child-id "post-admin-header-child" :oob oob)',
+ lh=link_href,
+ oob=oob,
+ )
+
+
+# ---------------------------------------------------------------------------
+# Calendars header
+# ---------------------------------------------------------------------------
+
+def _calendars_header_html(ctx: dict, *, oob: bool = False) -> str:
+ """Build the calendars section header row."""
+ from quart import url_for
+ link_href = url_for("calendars.home")
+ return sexp(
+ '(~menu-row :id "calendars-row" :level 3'
+ ' :link-href lh :link-label-html llh'
+ ' :child-id "calendars-header-child" :oob oob)',
+ lh=link_href,
+ llh='Calendars
',
+ oob=oob,
+ )
+
+
+# ---------------------------------------------------------------------------
+# Calendar header
+# ---------------------------------------------------------------------------
+
+def _calendar_header_html(ctx: dict, *, oob: bool = False) -> str:
+ """Build a single calendar's header row."""
+ from quart import url_for
+ calendar = ctx.get("calendar")
+ if not calendar:
+ return ""
+ cal_slug = getattr(calendar, "slug", "")
+ cal_name = getattr(calendar, "name", "")
+ cal_desc = getattr(calendar, "description", "") or ""
+
+ link_href = url_for("calendars.calendar.get", calendar_slug=cal_slug)
+ label_html = (
+ ''
+ '
'
+ f'
'
+ f'
{escape(cal_name)}
'
+ '
'
+ f'
{escape(cal_desc)}
'
+ '
'
+ )
+
+ # Desktop nav: slots + admin
+ nav_html = _calendar_nav_html(ctx)
+
+ return sexp(
+ '(~menu-row :id "calendar-row" :level 3'
+ ' :link-href lh :link-label-html llh'
+ ' :nav-html nh :child-id "calendar-header-child" :oob oob)',
+ lh=link_href,
+ llh=label_html,
+ nh=nav_html,
+ oob=oob,
+ )
+
+
+def _calendar_nav_html(ctx: dict) -> str:
+ """Calendar desktop nav: Slots + admin link."""
+ from quart import url_for
+ calendar = ctx.get("calendar")
+ if not calendar:
+ return ""
+ cal_slug = getattr(calendar, "slug", "")
+ rights = ctx.get("rights") or {}
+ is_admin = getattr(rights, "admin", False) if hasattr(rights, "admin") else rights.get("admin", False)
+ select_colours = ctx.get("select_colours", "")
+
+ parts = []
+ slots_href = url_for("calendars.calendar.slots.get", calendar_slug=cal_slug)
+ parts.append(sexp(
+ '(~nav-link :href h :icon "fa fa-clock" :label "Slots" :select-colours sc)',
+ h=slots_href,
+ sc=select_colours,
+ ))
+ if is_admin:
+ admin_href = url_for("calendars.calendar.admin.admin", calendar_slug=cal_slug)
+ parts.append(
+ f''
+ f' '
+ )
+ return "".join(parts)
+
+
+# ---------------------------------------------------------------------------
+# Day header
+# ---------------------------------------------------------------------------
+
+def _day_header_html(ctx: dict, *, oob: bool = False) -> str:
+ """Build day detail header row."""
+ from quart import url_for
+ calendar = ctx.get("calendar")
+ if not calendar:
+ return ""
+ cal_slug = getattr(calendar, "slug", "")
+ day_date = ctx.get("day_date")
+ if not day_date:
+ return ""
+
+ link_href = url_for(
+ "calendars.calendar.day.show_day",
+ calendar_slug=cal_slug,
+ year=day_date.year,
+ month=day_date.month,
+ day=day_date.day,
+ )
+ label_html = (
+ ''
+ f' '
+ f' {escape(day_date.strftime("%A %d %B %Y"))}'
+ '
'
+ )
+
+ nav_html = _day_nav_html(ctx)
+
+ return sexp(
+ '(~menu-row :id "day-row" :level 4'
+ ' :link-href lh :link-label-html llh'
+ ' :nav-html nh :child-id "day-header-child" :oob oob)',
+ lh=link_href,
+ llh=label_html,
+ nh=nav_html,
+ oob=oob,
+ )
+
+
+def _day_nav_html(ctx: dict) -> str:
+ """Day desktop nav: confirmed entries scrolling menu + admin link."""
+ from quart import url_for
+ calendar = ctx.get("calendar")
+ if not calendar:
+ return ""
+ cal_slug = getattr(calendar, "slug", "")
+ day_date = ctx.get("day_date")
+ confirmed_entries = ctx.get("confirmed_entries") or []
+ rights = ctx.get("rights") or {}
+ is_admin = getattr(rights, "admin", False) if hasattr(rights, "admin") else rights.get("admin", False)
+
+ parts = []
+ # Confirmed entries nav (scrolling menu)
+ if confirmed_entries:
+ parts.append(
+ '')
+
+ if is_admin and day_date:
+ admin_href = url_for(
+ "calendars.calendar.day.admin.admin",
+ calendar_slug=cal_slug,
+ year=day_date.year,
+ month=day_date.month,
+ day=day_date.day,
+ )
+ parts.append(
+ f''
+ f' '
+ )
+ return "".join(parts)
+
+
+# ---------------------------------------------------------------------------
+# Day admin header
+# ---------------------------------------------------------------------------
+
+def _day_admin_header_html(ctx: dict, *, oob: bool = False) -> str:
+ """Build day admin header row."""
+ from quart import url_for
+ calendar = ctx.get("calendar")
+ if not calendar:
+ return ""
+ cal_slug = getattr(calendar, "slug", "")
+ day_date = ctx.get("day_date")
+ if not day_date:
+ return ""
+
+ link_href = url_for(
+ "calendars.calendar.day.admin.admin",
+ calendar_slug=cal_slug,
+ year=day_date.year,
+ month=day_date.month,
+ day=day_date.day,
+ )
+ return sexp(
+ '(~menu-row :id "day-admin-row" :level 5'
+ ' :link-href lh :link-label "admin" :icon "fa fa-cog"'
+ ' :child-id "day-admin-header-child" :oob oob)',
+ lh=link_href,
+ oob=oob,
+ )
+
+
+# ---------------------------------------------------------------------------
+# Calendar admin header
+# ---------------------------------------------------------------------------
+
+def _calendar_admin_header_html(ctx: dict, *, oob: bool = False) -> str:
+ """Build calendar admin header row."""
+ return sexp(
+ '(~menu-row :id "calendar-admin-row" :level 4'
+ ' :link-label "admin" :icon "fa fa-cog"'
+ ' :child-id "calendar-admin-header-child" :oob oob)',
+ oob=oob,
+ )
+
+
+# ---------------------------------------------------------------------------
+# Markets header
+# ---------------------------------------------------------------------------
+
+def _markets_header_html(ctx: dict, *, oob: bool = False) -> str:
+ """Build the markets section header row."""
+ from quart import url_for
+ link_href = url_for("markets.home")
+ return sexp(
+ '(~menu-row :id "markets-row" :level 3'
+ ' :link-href lh :link-label-html llh'
+ ' :child-id "markets-header-child" :oob oob)',
+ lh=link_href,
+ llh='Markets
',
+ oob=oob,
+ )
+
+
+# ---------------------------------------------------------------------------
+# Payments header
+# ---------------------------------------------------------------------------
+
+def _payments_header_html(ctx: dict, *, oob: bool = False) -> str:
+ """Build the payments section header row."""
+ from quart import url_for
+ link_href = url_for("payments.home")
+ return sexp(
+ '(~menu-row :id "payments-row" :level 3'
+ ' :link-href lh :link-label-html llh'
+ ' :child-id "payments-header-child" :oob oob)',
+ lh=link_href,
+ llh='Payments
',
+ oob=oob,
+ )
+
+
+# ---------------------------------------------------------------------------
+# Calendars main panel
+# ---------------------------------------------------------------------------
+
+def _calendars_main_panel_html(ctx: dict) -> str:
+ """Render the calendars list + create form panel."""
+ from quart import url_for
+ rights = ctx.get("rights") or {}
+ is_admin = getattr(rights, "admin", False) if hasattr(rights, "admin") else rights.get("admin", False)
+ has_access = ctx.get("has_access")
+ can_create = has_access("calendars.create_calendar") if callable(has_access) else is_admin
+ csrf_token = ctx.get("csrf_token")
+ csrf = csrf_token() if callable(csrf_token) else (csrf_token or "")
+
+ calendars = ctx.get("calendars") or []
+ hx_select = ctx.get("hx_select_search", "#main-panel")
+
+ parts = ['']
+ if can_create:
+ create_url = url_for("calendars.create_calendar")
+ parts.append(
+ '
'
+ f''
+ )
+
+ parts.append('')
+ parts.append(_calendars_list_html(ctx, calendars))
+ parts.append('
')
+ return "".join(parts)
+
+
+def _calendars_list_html(ctx: dict, calendars: list) -> str:
+ """Render the calendars list items."""
+ from quart import url_for
+ from shared.utils import route_prefix
+ hx_select = ctx.get("hx_select_search", "#main-panel")
+ csrf_token = ctx.get("csrf_token")
+ csrf = csrf_token() if callable(csrf_token) else (csrf_token or "")
+ prefix = route_prefix()
+
+ if not calendars:
+ return 'No calendars yet. Create one above.
'
+
+ parts = []
+ for cal in calendars:
+ cal_slug = getattr(cal, "slug", "")
+ cal_name = getattr(cal, "name", "")
+ href = prefix + url_for("calendars.calendar.get", calendar_slug=cal_slug)
+ del_url = url_for("calendars.calendar.delete", calendar_slug=cal_slug)
+ parts.append(
+ f''
+ )
+ return "".join(parts)
+
+
+# ---------------------------------------------------------------------------
+# Calendar month grid
+# ---------------------------------------------------------------------------
+
+def _calendar_main_panel_html(ctx: dict) -> str:
+ """Render the calendar month grid."""
+ from quart import url_for
+ from quart import session as qsession
+
+ calendar = ctx.get("calendar")
+ if not calendar:
+ return ""
+ cal_slug = getattr(calendar, "slug", "")
+ hx_select = ctx.get("hx_select_search", "#main-panel")
+ styles = ctx.get("styles") or {}
+ pill_cls = getattr(styles, "pill", "") if hasattr(styles, "pill") else styles.get("pill", "")
+
+ year = ctx.get("year", 2024)
+ month = ctx.get("month", 1)
+ month_name = ctx.get("month_name", "")
+ weekday_names = ctx.get("weekday_names", [])
+ weeks = ctx.get("weeks", [])
+ prev_month = ctx.get("prev_month", 1)
+ prev_month_year = ctx.get("prev_month_year", year)
+ next_month = ctx.get("next_month", 1)
+ next_month_year = ctx.get("next_month_year", year)
+ prev_year = ctx.get("prev_year", year - 1)
+ next_year = ctx.get("next_year", year + 1)
+ month_entries = ctx.get("month_entries") or []
+ user = ctx.get("user")
+ qs = qsession if "qsession" not in ctx else ctx["qsession"]
+
+ def nav_link(y, m):
+ href = url_for("calendars.calendar.get", calendar_slug=cal_slug, year=y, month=m)
+ return href
+
+ # Month navigation header
+ parts = ['']
+ parts.append('')
+
+ # Calendar grid
+ parts.append('')
+ # Weekday headers
+ parts.append('
')
+ for wd in weekday_names:
+ parts.append(f'
{wd}
')
+ parts.append('
')
+
+ # Weeks grid
+ parts.append('
')
+ for week in weeks:
+ for day_cell in week:
+ in_month = getattr(day_cell, "in_month", True)
+ is_today = getattr(day_cell, "is_today", False)
+ day_date = getattr(day_cell, "date", None)
+
+ cell_cls = "min-h-20 sm:min-h-24 bg-white px-3 py-2 text-xs"
+ if not in_month:
+ cell_cls += " bg-stone-50 text-stone-400"
+ if is_today:
+ cell_cls += " ring-2 ring-blue-500 z-10 relative"
+
+ parts.append(f'
')
+ parts.append('
')
+ parts.append(f'
{day_date.strftime("%a")} ')
+
+ # Clickable day number
+ if day_date:
+ day_href = url_for(
+ "calendars.calendar.day.show_day",
+ calendar_slug=cal_slug,
+ year=day_date.year,
+ month=day_date.month,
+ day=day_date.day,
+ )
+ parts.append(
+ f'
{day_date.day} '
+ )
+ parts.append('
')
+
+ # Entries for this day
+ parts.append('
')
+ if day_date:
+ for e in month_entries:
+ if e.start_at.date() == day_date:
+ is_mine = (
+ (user and e.user_id == user.id)
+ or (not user and e.session_id == qs.get("calendar_sid"))
+ )
+ if e.state == "confirmed":
+ bg_cls = "bg-emerald-200 text-emerald-900" if is_mine else "bg-emerald-100 text-emerald-800"
+ else:
+ bg_cls = "bg-sky-100 text-sky-800" if is_mine else "bg-stone-100 text-stone-700"
+
+ state_label = (e.state or "pending").replace("_", " ")
+ parts.append(
+ f'
'
+ f'{escape(e.name)} '
+ f'{state_label} '
+ f'
'
+ )
+ parts.append('
')
+
+ parts.append('
')
+ return "".join(parts)
+
+
+# ---------------------------------------------------------------------------
+# Day main panel
+# ---------------------------------------------------------------------------
+
+def _day_main_panel_html(ctx: dict) -> str:
+ """Render the day entries table + add button."""
+ from quart import url_for
+
+ calendar = ctx.get("calendar")
+ if not calendar:
+ return ""
+ cal_slug = getattr(calendar, "slug", "")
+ day_entries = ctx.get("day_entries") or []
+ day = ctx.get("day")
+ month = ctx.get("month")
+ year = ctx.get("year")
+ hx_select = ctx.get("hx_select_search", "#main-panel")
+ styles = ctx.get("styles") or {}
+ list_container = getattr(styles, "list_container", "") if hasattr(styles, "list_container") else styles.get("list_container", "")
+ pill_cls = getattr(styles, "pill", "") if hasattr(styles, "pill") else styles.get("pill", "")
+ tr_cls = getattr(styles, "tr", "") if hasattr(styles, "tr") else styles.get("tr", "")
+ pre_action = getattr(styles, "pre_action_button", "") if hasattr(styles, "pre_action_button") else styles.get("pre_action_button", "")
+
+ parts = [f'']
+ parts.append(
+ ''
+ 'Name '
+ 'Slot/Time '
+ 'State '
+ 'Cost '
+ 'Tickets '
+ 'Actions '
+ ' '
+ )
+
+ if day_entries:
+ for entry in day_entries:
+ parts.append(_day_row_html(ctx, entry))
+ else:
+ parts.append('No entries yet. ')
+
+ parts.append('
')
+
+ # Add entry button
+ add_url = url_for(
+ "calendars.calendar.day.calendar_entries.add_form",
+ calendar_slug=cal_slug,
+ day=day, month=month, year=year,
+ )
+ parts.append(
+ f''
+ f''
+ f'+ Add entry
'
+ )
+ parts.append(' ')
+ return "".join(parts)
+
+
+def _day_row_html(ctx: dict, entry) -> str:
+ """Render a single day table row."""
+ from quart import url_for
+ calendar = ctx.get("calendar")
+ cal_slug = getattr(calendar, "slug", "")
+ day = ctx.get("day")
+ month = ctx.get("month")
+ year = ctx.get("year")
+ hx_select = ctx.get("hx_select_search", "#main-panel")
+ styles = ctx.get("styles") or {}
+ pill_cls = getattr(styles, "pill", "") if hasattr(styles, "pill") else styles.get("pill", "")
+ tr_cls = getattr(styles, "tr", "") if hasattr(styles, "tr") else styles.get("tr", "")
+
+ entry_href = url_for(
+ "calendars.calendar.day.calendar_entries.calendar_entry.get",
+ calendar_slug=cal_slug, day=day, month=month, year=year, entry_id=entry.id,
+ )
+
+ # Name
+ name_html = (
+ f' '
+ )
+
+ # Slot/Time
+ slot = getattr(entry, "slot", None)
+ if slot:
+ slot_href = url_for("calendars.calendar.slots.slot.get", calendar_slug=cal_slug, slot_id=slot.id)
+ time_start = slot.time_start.strftime("%H:%M") if slot.time_start else ""
+ time_end = f" → {slot.time_end.strftime('%H:%M')}" if slot.time_end else ""
+ slot_html = (
+ f''
+ f'
'
+ f'{escape(slot.name)} '
+ f'
({time_start}{time_end}) '
+ f'
'
+ )
+ else:
+ start = entry.start_at.strftime("%H:%M") if entry.start_at else ""
+ end = f" → {entry.end_at.strftime('%H:%M')}" if entry.end_at else ""
+ slot_html = f'{start}{end}
'
+
+ # State
+ state = getattr(entry, "state", "pending") or "pending"
+ state_html = _entry_state_badge_html(state)
+ state_td = f'{state_html}
'
+
+ # Cost
+ cost = getattr(entry, "cost", None)
+ cost_str = f"£{cost:.2f}" if cost is not None else "£0.00"
+ cost_td = f'{cost_str} '
+
+ # Tickets
+ tp = getattr(entry, "ticket_price", None)
+ if tp is not None:
+ tc = getattr(entry, "ticket_count", None)
+ tc_str = f"{tc} tickets" if tc is not None else "Unlimited"
+ tickets_td = (
+ f''
+ f'
£{tp:.2f}
'
+ f'
{tc_str}
'
+ )
+ else:
+ tickets_td = 'No tickets '
+
+ # Actions (entry options) - keep simple, just link to entry
+ actions_td = f' '
+
+ return f'{name_html}{slot_html}{state_td}{cost_td}{tickets_td}{actions_td} '
+
+
+def _entry_state_badge_html(state: str) -> str:
+ """Render an entry state badge."""
+ state_classes = {
+ "confirmed": "bg-emerald-100 text-emerald-800",
+ "provisional": "bg-amber-100 text-amber-800",
+ "ordered": "bg-sky-100 text-sky-800",
+ "pending": "bg-stone-100 text-stone-700",
+ "declined": "bg-red-100 text-red-800",
+ }
+ cls = state_classes.get(state, "bg-stone-100 text-stone-700")
+ label = state.replace("_", " ").capitalize()
+ return f'{label} '
+
+
+# ---------------------------------------------------------------------------
+# Day admin main panel
+# ---------------------------------------------------------------------------
+
+def _day_admin_main_panel_html(ctx: dict) -> str:
+ """Render day admin panel (placeholder nav)."""
+ return 'Admin options
'
+
+
+# ---------------------------------------------------------------------------
+# Calendar admin main panel
+# ---------------------------------------------------------------------------
+
+def _calendar_admin_main_panel_html(ctx: dict) -> str:
+ """Render calendar admin config panel with description editor."""
+ from quart import url_for
+ calendar = ctx.get("calendar")
+ if not calendar:
+ return ""
+ csrf_token = ctx.get("csrf_token")
+ csrf = csrf_token() if callable(csrf_token) else (csrf_token or "")
+ cal_slug = getattr(calendar, "slug", "")
+ desc = getattr(calendar, "description", "") or ""
+ hx_select = ctx.get("hx_select_search", "#main-panel")
+
+ desc_edit_url = url_for("calendars.calendar.admin.calendar_description_edit", calendar_slug=cal_slug)
+ description_html = _calendar_description_display_html(calendar, desc_edit_url)
+
+ parts = ['']
+ parts.append('Calendar configuration ')
+ parts.append('
')
+ parts.append(f'
Description ')
+ parts.append(description_html)
+ parts.append('
')
+
+ # Hidden form for direct PUT
+ parts.append(
+ f'
'
+ )
+ parts.append('
')
+ return "".join(parts)
+
+
+def _calendar_description_display_html(calendar, edit_url: str) -> str:
+ """Render calendar description display with edit button."""
+ desc = getattr(calendar, "description", "") or ""
+ if desc:
+ desc_html = f'{escape(desc)}
'
+ else:
+ desc_html = 'No description yet.
'
+ return (
+ f'{desc_html}'
+ f''
+ f'
'
+ )
+
+
+# ---------------------------------------------------------------------------
+# Markets main panel
+# ---------------------------------------------------------------------------
+
+def _markets_main_panel_html(ctx: dict) -> str:
+ """Render markets list + create form panel."""
+ from quart import url_for
+ rights = ctx.get("rights") or {}
+ is_admin = getattr(rights, "admin", False) if hasattr(rights, "admin") else rights.get("admin", False)
+ has_access = ctx.get("has_access")
+ can_create = has_access("markets.create_market") if callable(has_access) else is_admin
+ csrf_token = ctx.get("csrf_token")
+ csrf = csrf_token() if callable(csrf_token) else (csrf_token or "")
+ markets = ctx.get("markets") or []
+ post = ctx.get("post") or {}
+
+ parts = ['']
+ if can_create:
+ create_url = url_for("markets.create_market")
+ parts.append(
+ '
'
+ f'"""
+ f' '
+ 'Name '
+ '
'
+ 'Add market '
+ )
+ parts.append('')
+ parts.append(_markets_list_html(ctx, markets))
+ parts.append('
')
+ return "".join(parts)
+
+
+def _markets_list_html(ctx: dict, markets: list) -> str:
+ """Render markets list items."""
+ from quart import url_for
+ csrf_token = ctx.get("csrf_token")
+ csrf = csrf_token() if callable(csrf_token) else (csrf_token or "")
+ post = ctx.get("post") or {}
+ slug = post.get("slug", "")
+
+ if not markets:
+ return 'No markets yet. Create one above.
'
+
+ parts = []
+ for m in markets:
+ m_slug = getattr(m, "slug", "") if hasattr(m, "slug") else m.get("slug", "")
+ m_name = getattr(m, "name", "") if hasattr(m, "name") else m.get("name", "")
+ market_href = call_url(ctx, "market_url", f"/{slug}/{m_slug}/")
+ del_url = url_for("markets.delete_market", market_slug=m_slug)
+ parts.append(
+ f''
+ )
+ return "".join(parts)
+
+
+# ---------------------------------------------------------------------------
+# Payments main panel
+# ---------------------------------------------------------------------------
+
+def _payments_main_panel_html(ctx: dict) -> str:
+ """Render SumUp payment config form."""
+ from quart import url_for
+ csrf_token = ctx.get("csrf_token")
+ csrf = csrf_token() if callable(csrf_token) else (csrf_token or "")
+ sumup_configured = ctx.get("sumup_configured", False)
+ merchant_code = ctx.get("sumup_merchant_code", "")
+ checkout_prefix = ctx.get("sumup_checkout_prefix", "")
+ update_url = url_for("payments.update_sumup")
+
+ placeholder = "--------" if sumup_configured else "sup_sk_..."
+ key_note = 'Key is set. Leave blank to keep current key.
' if sumup_configured else ""
+ connected = (''
+ ' Connected ') if sumup_configured else ""
+
+ return (
+ ''
+ )
+
+
+# ---------------------------------------------------------------------------
+# Ticket state badge helper
+# ---------------------------------------------------------------------------
+
+def _ticket_state_badge_html(state: str) -> str:
+ """Render a ticket state badge."""
+ cls_map = {
+ "confirmed": "bg-emerald-100 text-emerald-800",
+ "checked_in": "bg-blue-100 text-blue-800",
+ "reserved": "bg-amber-100 text-amber-800",
+ "cancelled": "bg-red-100 text-red-800",
+ }
+ cls = cls_map.get(state, "bg-stone-100 text-stone-700")
+ label = (state or "").replace("_", " ").capitalize()
+ return f'{label} '
+
+
+# ---------------------------------------------------------------------------
+# Tickets main panel (my tickets)
+# ---------------------------------------------------------------------------
+
+def _tickets_main_panel_html(ctx: dict, tickets: list) -> str:
+ """Render my tickets list."""
+ from quart import url_for
+
+ parts = [f'']
+ parts.append('My Tickets ')
+
+ if tickets:
+ parts.append('')
+ for ticket in tickets:
+ href = url_for("tickets.ticket_detail", code=ticket.code)
+ entry = getattr(ticket, "entry", None)
+ entry_name = entry.name if entry else "Unknown event"
+ tt = getattr(ticket, "ticket_type", None)
+ state = getattr(ticket, "state", "")
+
+ parts.append(
+ f'
'
+ ''
+ f'
{escape(entry_name)}
'
+ )
+ if tt:
+ parts.append(f'
{escape(tt.name)}
')
+ if entry:
+ parts.append(
+ '
'
+ f'{entry.start_at.strftime("%A, %B %d, %Y at %H:%M")}'
+ )
+ if entry.end_at:
+ parts.append(f' – {entry.end_at.strftime("%H:%M")}')
+ parts.append('
')
+ cal = getattr(entry, "calendar", None)
+ if cal:
+ parts.append(f'
{escape(cal.name)}
')
+
+ parts.append('
')
+ parts.append(_ticket_state_badge_html(state))
+ parts.append(f'{ticket.code[:8]}... ')
+ parts.append('
')
+ parts.append('
')
+ else:
+ parts.append(
+ ''
+ '
'
+ '
No tickets yet
'
+ '
Tickets will appear here after you purchase them.
'
+ )
+ parts.append(' ')
+ return "".join(parts)
+
+
+# ---------------------------------------------------------------------------
+# Ticket detail panel
+# ---------------------------------------------------------------------------
+
+def _ticket_detail_panel_html(ctx: dict, ticket) -> str:
+ """Render a single ticket detail with QR code."""
+ from quart import url_for
+
+ entry = getattr(ticket, "entry", None)
+ tt = getattr(ticket, "ticket_type", None)
+ state = getattr(ticket, "state", "")
+ code = ticket.code
+
+ # Background color for header
+ bg_map = {"confirmed": "bg-emerald-50", "checked_in": "bg-blue-50", "reserved": "bg-amber-50"}
+ header_bg = bg_map.get(state, "bg-stone-50")
+
+ entry_name = entry.name if entry else "Ticket"
+ back_href = url_for("tickets.my_tickets")
+
+ parts = [f'']
+ parts.append(
+ f''
+ ' Back to my tickets '
+ )
+
+ parts.append('')
+ # Header
+ parts.append(f'')
+
+ # QR code
+ parts.append(
+ f'
'
+ )
+
+ # Event details
+ parts.append('
')
+ if entry:
+ parts.append(
+ '
'
+ f'
{entry.start_at.strftime("%A, %B %d, %Y")}
'
+ f'
{entry.start_at.strftime("%H:%M")}'
+ )
+ if entry.end_at:
+ parts.append(f' – {entry.end_at.strftime("%H:%M")}')
+ parts.append('
')
+
+ cal = getattr(entry, "calendar", None)
+ if cal:
+ parts.append(
+ '
'
+ )
+
+ if tt and getattr(tt, "cost", None):
+ parts.append(
+ '
'
+ f'
{escape(tt.name)} — £{tt.cost:.2f}
'
+ )
+
+ checked_in_at = getattr(ticket, "checked_in_at", None)
+ if checked_in_at:
+ parts.append(
+ '
'
+ f'
Checked in: {checked_in_at.strftime("%B %d, %Y at %H:%M")}
'
+ )
+ parts.append('
')
+
+ # QR code script
+ parts.append(
+ ''
+ ''
+ )
+ parts.append(' ')
+ return "".join(parts)
+
+
+# ---------------------------------------------------------------------------
+# Ticket admin main panel
+# ---------------------------------------------------------------------------
+
+def _ticket_admin_main_panel_html(ctx: dict, tickets: list, stats: dict) -> str:
+ """Render ticket admin dashboard."""
+ from quart import url_for
+ csrf_token = ctx.get("csrf_token")
+ csrf = csrf_token() if callable(csrf_token) else (csrf_token or "")
+ lookup_url = url_for("ticket_admin.lookup")
+
+ parts = [f'']
+ parts.append('Ticket Admin ')
+
+ # Stats
+ parts.append('')
+ for label, key, border, bg, text_cls in [
+ ("Total", "total", "border-stone-200", "", "text-stone-900"),
+ ("Confirmed", "confirmed", "border-emerald-200", "bg-emerald-50", "text-emerald-700"),
+ ("Checked In", "checked_in", "border-blue-200", "bg-blue-50", "text-blue-700"),
+ ("Reserved", "reserved", "border-amber-200", "bg-amber-50", "text-amber-700"),
+ ]:
+ val = stats.get(key, 0)
+ lbl_cls = text_cls.replace("700", "600").replace("900", "500") if "stone" not in text_cls else "text-stone-500"
+ parts.append(
+ f'
'
+ )
+ parts.append('
')
+
+ # Scanner
+ parts.append(
+ ''
+ '
Scan / Look Up Ticket'
+ '
'
+ f' '
+ '"""
+ '
'
+ '
'
+ 'Enter a ticket code to look it up
'
+ )
+
+ # Recent tickets table
+ parts.append('')
+ parts.append('
Recent Tickets ')
+
+ if tickets:
+ parts.append('
')
+ for col in ["Code", "Event", "Type", "State", "Actions"]:
+ parts.append(f'{col} ')
+ parts.append(' ')
+
+ for ticket in tickets:
+ entry = getattr(ticket, "entry", None)
+ tt = getattr(ticket, "ticket_type", None)
+ state = getattr(ticket, "state", "")
+ code = ticket.code
+
+ parts.append(f'')
+ parts.append(f'{code[:12]}... ')
+ parts.append(f'{escape(entry.name) if entry else "—"}
')
+ if entry and entry.start_at:
+ parts.append(f'{entry.start_at.strftime("%d %b %Y, %H:%M")}
')
+ parts.append(' ')
+ parts.append(f'{escape(tt.name) if tt else "—"} ')
+ parts.append(f'{_ticket_state_badge_html(state)} ')
+
+ # Actions
+ parts.append('')
+ if state in ("confirmed", "reserved"):
+ checkin_url = url_for("ticket_admin.do_checkin", code=code)
+ parts.append(
+ f''
+ f' '
+ ''
+ ' Check in '
+ )
+ elif state == "checked_in":
+ checked_in_at = getattr(ticket, "checked_in_at", None)
+ t_str = checked_in_at.strftime("%H:%M") if checked_in_at else ""
+ parts.append(f' {t_str} ')
+ parts.append(' ')
+
+ parts.append('
')
+ else:
+ parts.append('
No tickets yet
')
+
+ parts.append('
')
+ return "".join(parts)
+
+
+# ---------------------------------------------------------------------------
+# All events / page summary entry cards
+# ---------------------------------------------------------------------------
+
+def _entry_card_html(entry, page_info: dict, pending_tickets: dict,
+ ticket_url: str, events_url_fn, *, is_page_scoped: bool = False,
+ post: dict | None = None) -> str:
+ """Render a list card for one event entry."""
+ pi = page_info.get(getattr(entry, "calendar_container_id", 0), {})
+ if is_page_scoped and post:
+ page_slug = pi.get("slug", post.get("slug", ""))
+ else:
+ page_slug = pi.get("slug", "")
+ page_title = pi.get("title")
+
+ day_href = ""
+ if page_slug:
+ day_href = events_url_fn(f"/{page_slug}/calendars/{entry.calendar_slug}/day/{entry.start_at.strftime('%Y/%-m/%-d')}/")
+ entry_href = f"{day_href}entries/{entry.id}/" if day_href else ""
+
+ parts = ['']
+ parts.append('')
+ parts.append('
')
+
+ if entry_href:
+ parts.append(f'
')
+ parts.append(f'{escape(entry.name)} ')
+ if entry_href:
+ parts.append(' ')
+
+ # Badges
+ parts.append('
')
+ if page_title and (not is_page_scoped or page_title != (post or {}).get("title")):
+ page_href = events_url_fn(f"/{page_slug}/")
+ parts.append(
+ f'
'
+ f'{escape(page_title)} '
+ )
+ cal_name = getattr(entry, "calendar_name", "")
+ if cal_name:
+ parts.append(f'
{escape(cal_name)} ')
+ parts.append('
')
+
+ # Time
+ parts.append('
')
+ if day_href and not is_page_scoped:
+ parts.append(f'
{entry.start_at.strftime("%a %-d %b")} · ')
+ elif not is_page_scoped:
+ parts.append(f'{entry.start_at.strftime("%a %-d %b")} · ')
+ parts.append(entry.start_at.strftime("%H:%M"))
+ if entry.end_at:
+ parts.append(f' – {entry.end_at.strftime("%H:%M")}')
+ parts.append('
')
+
+ cost = getattr(entry, "cost", None)
+ if cost:
+ parts.append(f'
£{cost:.2f}
')
+ parts.append('
')
+
+ # Ticket widget
+ tp = getattr(entry, "ticket_price", None)
+ if tp is not None:
+ qty = pending_tickets.get(entry.id, 0)
+ parts.append('
')
+ parts.append(_ticket_widget_html(entry, qty, ticket_url, ctx={}))
+ parts.append('
')
+ parts.append('
')
+ return "".join(parts)
+
+
+def _entry_card_tile_html(entry, page_info: dict, pending_tickets: dict,
+ ticket_url: str, events_url_fn, *, is_page_scoped: bool = False,
+ post: dict | None = None) -> str:
+ """Render a tile card for one event entry."""
+ pi = page_info.get(getattr(entry, "calendar_container_id", 0), {})
+ if is_page_scoped and post:
+ page_slug = pi.get("slug", post.get("slug", ""))
+ else:
+ page_slug = pi.get("slug", "")
+ page_title = pi.get("title")
+
+ day_href = ""
+ if page_slug:
+ day_href = events_url_fn(f"/{page_slug}/calendars/{entry.calendar_slug}/day/{entry.start_at.strftime('%Y/%-m/%-d')}/")
+ entry_href = f"{day_href}entries/{entry.id}/" if day_href else ""
+
+ parts = ['']
+ if entry_href:
+ parts.append(f'
')
+ parts.append(f'{escape(entry.name)} ')
+ if entry_href:
+ parts.append(' ')
+
+ parts.append('
')
+ if page_title and (not is_page_scoped or page_title != (post or {}).get("title")):
+ page_href = events_url_fn(f"/{page_slug}/")
+ parts.append(
+ f'
'
+ f'{escape(page_title)} '
+ )
+ cal_name = getattr(entry, "calendar_name", "")
+ if cal_name:
+ parts.append(f'
{escape(cal_name)} ')
+ parts.append('
')
+
+ parts.append('
')
+ if day_href:
+ parts.append(f'
{entry.start_at.strftime("%a %-d %b")} ')
+ else:
+ parts.append(entry.start_at.strftime("%a %-d %b"))
+ parts.append(f' · {entry.start_at.strftime("%H:%M")}')
+ if entry.end_at:
+ parts.append(f' – {entry.end_at.strftime("%H:%M")}')
+ parts.append('
')
+
+ cost = getattr(entry, "cost", None)
+ if cost:
+ parts.append(f'
£{cost:.2f}
')
+ parts.append('
')
+
+ tp = getattr(entry, "ticket_price", None)
+ if tp is not None:
+ qty = pending_tickets.get(entry.id, 0)
+ parts.append('')
+ parts.append(_ticket_widget_html(entry, qty, ticket_url, ctx={}))
+ parts.append('
')
+ parts.append(' ')
+ return "".join(parts)
+
+
+def _ticket_widget_html(entry, qty: int, ticket_url: str, *, ctx: dict) -> str:
+ """Render the inline +/- ticket widget."""
+ csrf_token_val = ""
+ if ctx:
+ ct = ctx.get("csrf_token")
+ csrf_token_val = ct() if callable(ct) else (ct or "")
+ else:
+ from quart import g as _g
+ ct = getattr(_g, "_csrf_token", None)
+ try:
+ from quart import current_app
+ with current_app.app_context():
+ pass
+ except Exception:
+ pass
+ # Use a deferred approach - get CSRF from template context
+ csrf_token_val = ""
+
+ # For the ticket widget, we need to get csrf token from the app
+ try:
+ from flask_wtf.csrf import generate_csrf
+ csrf_token_val = generate_csrf()
+ except Exception:
+ pass
+
+ if not csrf_token_val:
+ try:
+ from quart import current_app
+ csrf_token_val = current_app.config.get("WTF_CSRF_SECRET_KEY", "")
+ except Exception:
+ pass
+
+ eid = entry.id
+ tp = getattr(entry, "ticket_price", 0) or 0
+ cart_url_fn = None
+
+ parts = [f'']
+ parts.append(f'£{tp:.2f} ')
+
+ if qty == 0:
+ parts.append(
+ f'
'
+ f' '
+ f' '
+ ' '
+ ''
+ ' '
+ )
+ else:
+ # Minus button
+ parts.append(
+ f''
+ f' '
+ f' '
+ f' '
+ '- '
+ )
+ # Cart icon with count
+ parts.append(
+ ''
+ ''
+ ' '
+ ''
+ f'{qty} '
+ ' '
+ )
+ # Plus button
+ parts.append(
+ f''
+ f' '
+ f' '
+ f' '
+ '+ '
+ )
+ parts.append('')
+ return "".join(parts)
+
+
+def _entry_cards_html(entries, page_info, pending_tickets, ticket_url,
+ events_url_fn, view, page, has_more, next_url,
+ *, is_page_scoped=False, post=None) -> str:
+ """Render entry cards (list or tile) with sentinel."""
+ parts = []
+ last_date = None
+ for entry in entries:
+ if view == "tile":
+ parts.append(_entry_card_tile_html(
+ entry, page_info, pending_tickets, ticket_url, events_url_fn,
+ is_page_scoped=is_page_scoped, post=post,
+ ))
+ else:
+ entry_date = entry.start_at.strftime("%A %-d %B %Y")
+ if entry_date != last_date:
+ parts.append(
+ f'
'
+ f'{entry_date} '
+ )
+ last_date = entry_date
+ parts.append(_entry_card_html(
+ entry, page_info, pending_tickets, ticket_url, events_url_fn,
+ is_page_scoped=is_page_scoped, post=post,
+ ))
+
+ if has_more:
+ parts.append(
+ f''
+ )
+ return "".join(parts)
+
+
+# ---------------------------------------------------------------------------
+# All events / page summary main panels
+# ---------------------------------------------------------------------------
+
+_LIST_SVG = ' '
+_TILE_SVG = ' '
+
+
+def _view_toggle_html(ctx: dict, view: str) -> str:
+ """Render the list/tile view toggle bar."""
+ from shared.utils import route_prefix
+ prefix = route_prefix()
+ clh = ctx.get("current_local_href", "/")
+ qs_fn = ctx.get("qs")
+ hx_select = ctx.get("hx_select_search", "#main-panel")
+
+ # Build hrefs - list removes view param, tile sets view=tile
+ list_href = prefix + str(clh)
+ tile_href = prefix + str(clh)
+ # Use simple query parameter manipulation
+ if "?" in list_href:
+ list_href = list_href.split("?")[0]
+ if "?" in tile_href:
+ tile_href = tile_href.split("?")[0] + "?view=tile"
+ else:
+ tile_href = tile_href + "?view=tile"
+
+ list_active = 'bg-stone-200 text-stone-800' if view != 'tile' else 'text-stone-400 hover:text-stone-600'
+ tile_active = 'bg-stone-200 text-stone-800' if view == 'tile' else 'text-stone-400 hover:text-stone-600'
+
+ return (
+ '"""
+ )
+
+
+def _events_main_panel_html(ctx: dict, entries, has_more, pending_tickets, page_info,
+ page, view, ticket_url, next_url, events_url_fn,
+ *, is_page_scoped=False, post=None) -> str:
+ """Render the events main panel with view toggle + cards."""
+ parts = [_view_toggle_html(ctx, view)]
+
+ if entries:
+ cards = _entry_cards_html(
+ entries, page_info, pending_tickets, ticket_url, events_url_fn,
+ view, page, has_more, next_url,
+ is_page_scoped=is_page_scoped, post=post,
+ )
+ if view == "tile":
+ parts.append(f'{cards}
')
+ else:
+ parts.append(f'{cards}
')
+ else:
+ parts.append(
+ ''
+ '
'
+ '
No upcoming events
'
+ )
+ parts.append('
')
+ return "".join(parts)
+
+
+# ---------------------------------------------------------------------------
+# Utility
+# ---------------------------------------------------------------------------
+
+def _list_container(ctx: dict) -> str:
+ styles = ctx.get("styles") or {}
+ return getattr(styles, "list_container", "") if hasattr(styles, "list_container") else styles.get("list_container", "")
+
+
+# ===========================================================================
+# PUBLIC API
+# ===========================================================================
+
+
+# ---------------------------------------------------------------------------
+# All events
+# ---------------------------------------------------------------------------
+
+async def render_all_events_page(ctx: dict, entries, has_more, pending_tickets,
+ page_info, page, view) -> str:
+ """Full page: all events listing."""
+ from quart import url_for
+ from shared.utils import route_prefix, events_url
+
+ prefix = route_prefix()
+ view_param = f"&view={view}" if view != "list" else ""
+ ticket_url = url_for("all_events.adjust_ticket")
+ next_url = prefix + url_for("all_events.entries_fragment", page=page + 1) + (f"?view={view}" if view != "list" else "")
+
+ content = _events_main_panel_html(
+ ctx, entries, has_more, pending_tickets, page_info,
+ page, view, ticket_url, next_url, events_url,
+ )
+ hdr = root_header_html(ctx)
+ return full_page(ctx, header_rows_html=hdr, content_html=content)
+
+
+async def render_all_events_oob(ctx: dict, entries, has_more, pending_tickets,
+ page_info, page, view) -> str:
+ """OOB response: all events listing (htmx nav)."""
+ from quart import url_for
+ from shared.utils import route_prefix, events_url
+
+ prefix = route_prefix()
+ ticket_url = url_for("all_events.adjust_ticket")
+ next_url = prefix + url_for("all_events.entries_fragment", page=page + 1) + (f"?view={view}" if view != "list" else "")
+
+ content = _events_main_panel_html(
+ ctx, entries, has_more, pending_tickets, page_info,
+ page, view, ticket_url, next_url, events_url,
+ )
+ return oob_page(ctx, content_html=content)
+
+
+async def render_all_events_cards(entries, has_more, pending_tickets,
+ page_info, page, view) -> str:
+ """Pagination fragment: all events cards only."""
+ from quart import url_for
+ from shared.utils import route_prefix, events_url
+
+ prefix = route_prefix()
+ ticket_url = url_for("all_events.adjust_ticket")
+ next_url = prefix + url_for("all_events.entries_fragment", page=page + 1) + (f"?view={view}" if view != "list" else "")
+
+ return _entry_cards_html(
+ entries, page_info, pending_tickets, ticket_url, events_url,
+ view, page, has_more, next_url,
+ )
+
+
+# ---------------------------------------------------------------------------
+# Page summary
+# ---------------------------------------------------------------------------
+
+async def render_page_summary_page(ctx: dict, entries, has_more, pending_tickets,
+ page_info, page, view) -> str:
+ """Full page: page-scoped events listing."""
+ from quart import url_for
+ from shared.utils import route_prefix, events_url
+
+ prefix = route_prefix()
+ post = ctx.get("post") or {}
+ ticket_url = url_for("page_summary.adjust_ticket")
+ next_url = prefix + url_for("page_summary.entries_fragment", page=page + 1) + (f"?view={view}" if view != "list" else "")
+
+ content = _events_main_panel_html(
+ ctx, entries, has_more, pending_tickets, page_info,
+ page, view, ticket_url, next_url, events_url,
+ is_page_scoped=True, post=post,
+ )
+
+ hdr = root_header_html(ctx)
+ hdr += sexp(
+ '(div :id "root-header-child" :class "w-full" (raw! ph))',
+ ph=_post_header_html(ctx),
+ )
+ return full_page(ctx, header_rows_html=hdr, content_html=content)
+
+
+async def render_page_summary_oob(ctx: dict, entries, has_more, pending_tickets,
+ page_info, page, view) -> str:
+ """OOB response: page-scoped events (htmx nav)."""
+ from quart import url_for
+ from shared.utils import route_prefix, events_url
+
+ prefix = route_prefix()
+ post = ctx.get("post") or {}
+ ticket_url = url_for("page_summary.adjust_ticket")
+ next_url = prefix + url_for("page_summary.entries_fragment", page=page + 1) + (f"?view={view}" if view != "list" else "")
+
+ content = _events_main_panel_html(
+ ctx, entries, has_more, pending_tickets, page_info,
+ page, view, ticket_url, next_url, events_url,
+ is_page_scoped=True, post=post,
+ )
+
+ oobs = _post_header_html(ctx, oob=True)
+ return oob_page(ctx, oobs_html=oobs, content_html=content)
+
+
+async def render_page_summary_cards(entries, has_more, pending_tickets,
+ page_info, page, view, post) -> str:
+ """Pagination fragment: page-scoped events cards only."""
+ from quart import url_for
+ from shared.utils import route_prefix, events_url
+
+ prefix = route_prefix()
+ ticket_url = url_for("page_summary.adjust_ticket")
+ next_url = prefix + url_for("page_summary.entries_fragment", page=page + 1) + (f"?view={view}" if view != "list" else "")
+
+ return _entry_cards_html(
+ entries, page_info, pending_tickets, ticket_url, events_url,
+ view, page, has_more, next_url,
+ is_page_scoped=True, post=post,
+ )
+
+
+# ---------------------------------------------------------------------------
+# Calendars home
+# ---------------------------------------------------------------------------
+
+async def render_calendars_page(ctx: dict) -> str:
+ """Full page: calendars listing."""
+ content = _calendars_main_panel_html(ctx)
+ hdr = root_header_html(ctx)
+ hdr += sexp(
+ '(div :id "root-header-child" :class "w-full" (raw! ph (raw! pah (raw! ch))))',
+ ph=_post_header_html(ctx),
+ pah=_post_admin_header_html(ctx),
+ ch=_calendars_header_html(ctx),
+ )
+ return full_page(ctx, header_rows_html=hdr, content_html=content)
+
+
+async def render_calendars_oob(ctx: dict) -> str:
+ """OOB response: calendars listing."""
+ content = _calendars_main_panel_html(ctx)
+ oobs = _post_admin_header_html(ctx, oob=True)
+ oobs += _oob_header_html("post-admin-header-child", "calendars-header-child",
+ _calendars_header_html(ctx))
+ return oob_page(ctx, oobs_html=oobs, content_html=content)
+
+
+# ---------------------------------------------------------------------------
+# Calendar month view
+# ---------------------------------------------------------------------------
+
+async def render_calendar_page(ctx: dict) -> str:
+ """Full page: calendar month view."""
+ content = _calendar_main_panel_html(ctx)
+ hdr = root_header_html(ctx)
+ hdr += sexp(
+ '(div :id "root-header-child" :class "w-full" (raw! ph (raw! pah (raw! ch))))',
+ ph=_post_header_html(ctx),
+ pah=_post_admin_header_html(ctx),
+ ch=_calendar_header_html(ctx),
+ )
+ return full_page(ctx, header_rows_html=hdr, content_html=content)
+
+
+async def render_calendar_oob(ctx: dict) -> str:
+ """OOB response: calendar month view."""
+ content = _calendar_main_panel_html(ctx)
+ oobs = _post_header_html(ctx, oob=True)
+ oobs += _oob_header_html("post-header-child", "calendar-header-child",
+ _calendar_header_html(ctx))
+ return oob_page(ctx, oobs_html=oobs, content_html=content)
+
+
+# ---------------------------------------------------------------------------
+# Day detail
+# ---------------------------------------------------------------------------
+
+async def render_day_page(ctx: dict) -> str:
+ """Full page: day detail."""
+ content = _day_main_panel_html(ctx)
+ hdr = root_header_html(ctx)
+ hdr += sexp(
+ '(div :id "root-header-child" :class "w-full" (raw! ph (raw! pah (raw! ch (raw! dh)))))',
+ ph=_post_header_html(ctx),
+ pah=_post_admin_header_html(ctx),
+ ch=_calendar_header_html(ctx),
+ dh=_day_header_html(ctx),
+ )
+ return full_page(ctx, header_rows_html=hdr, content_html=content)
+
+
+async def render_day_oob(ctx: dict) -> str:
+ """OOB response: day detail."""
+ content = _day_main_panel_html(ctx)
+ oobs = _calendar_header_html(ctx, oob=True)
+ oobs += _oob_header_html("calendar-header-child", "day-header-child",
+ _day_header_html(ctx))
+ return oob_page(ctx, oobs_html=oobs, content_html=content)
+
+
+# ---------------------------------------------------------------------------
+# Day admin
+# ---------------------------------------------------------------------------
+
+async def render_day_admin_page(ctx: dict) -> str:
+ """Full page: day admin."""
+ content = _day_admin_main_panel_html(ctx)
+ hdr = root_header_html(ctx)
+ hdr += sexp(
+ '(div :id "root-header-child" :class "w-full" (raw! ph (raw! pah (raw! ch (raw! dh (raw! dah))))))',
+ ph=_post_header_html(ctx),
+ pah=_post_admin_header_html(ctx),
+ ch=_calendar_header_html(ctx),
+ dh=_day_header_html(ctx),
+ dah=_day_admin_header_html(ctx),
+ )
+ return full_page(ctx, header_rows_html=hdr, content_html=content)
+
+
+async def render_day_admin_oob(ctx: dict) -> str:
+ """OOB response: day admin."""
+ content = _day_admin_main_panel_html(ctx)
+ oobs = _calendar_header_html(ctx, oob=True)
+ oobs += _oob_header_html("day-header-child", "day-admin-header-child",
+ _day_admin_header_html(ctx))
+ return oob_page(ctx, oobs_html=oobs, content_html=content)
+
+
+# ---------------------------------------------------------------------------
+# Calendar admin
+# ---------------------------------------------------------------------------
+
+async def render_calendar_admin_page(ctx: dict) -> str:
+ """Full page: calendar admin."""
+ content = _calendar_admin_main_panel_html(ctx)
+ hdr = root_header_html(ctx)
+ hdr += sexp(
+ '(div :id "root-header-child" :class "w-full" (raw! ph (raw! pah (raw! ch (raw! cah)))))',
+ ph=_post_header_html(ctx),
+ pah=_post_admin_header_html(ctx),
+ ch=_calendar_header_html(ctx),
+ cah=_calendar_admin_header_html(ctx),
+ )
+ return full_page(ctx, header_rows_html=hdr, content_html=content)
+
+
+async def render_calendar_admin_oob(ctx: dict) -> str:
+ """OOB response: calendar admin."""
+ content = _calendar_admin_main_panel_html(ctx)
+ oobs = _calendar_header_html(ctx, oob=True)
+ oobs += _oob_header_html("calendar-header-child", "calendar-admin-header-child",
+ _calendar_admin_header_html(ctx))
+ return oob_page(ctx, oobs_html=oobs, content_html=content)
+
+
+# ---------------------------------------------------------------------------
+# Tickets
+# ---------------------------------------------------------------------------
+
+async def render_tickets_page(ctx: dict, tickets: list) -> str:
+ """Full page: my tickets."""
+ content = _tickets_main_panel_html(ctx, tickets)
+ hdr = root_header_html(ctx)
+ return full_page(ctx, header_rows_html=hdr, content_html=content)
+
+
+async def render_tickets_oob(ctx: dict, tickets: list) -> str:
+ """OOB response: my tickets."""
+ content = _tickets_main_panel_html(ctx, tickets)
+ return oob_page(ctx, content_html=content)
+
+
+async def render_ticket_detail_page(ctx: dict, ticket) -> str:
+ """Full page: ticket detail with QR."""
+ content = _ticket_detail_panel_html(ctx, ticket)
+ hdr = root_header_html(ctx)
+ return full_page(ctx, header_rows_html=hdr, content_html=content)
+
+
+async def render_ticket_detail_oob(ctx: dict, ticket) -> str:
+ """OOB response: ticket detail."""
+ content = _ticket_detail_panel_html(ctx, ticket)
+ return oob_page(ctx, content_html=content)
+
+
+# ---------------------------------------------------------------------------
+# Ticket admin
+# ---------------------------------------------------------------------------
+
+async def render_ticket_admin_page(ctx: dict, tickets: list, stats: dict) -> str:
+ """Full page: ticket admin dashboard."""
+ content = _ticket_admin_main_panel_html(ctx, tickets, stats)
+ hdr = root_header_html(ctx)
+ return full_page(ctx, header_rows_html=hdr, content_html=content)
+
+
+async def render_ticket_admin_oob(ctx: dict, tickets: list, stats: dict) -> str:
+ """OOB response: ticket admin dashboard."""
+ content = _ticket_admin_main_panel_html(ctx, tickets, stats)
+ return oob_page(ctx, content_html=content)
+
+
+# ---------------------------------------------------------------------------
+# Markets
+# ---------------------------------------------------------------------------
+
+async def render_markets_page(ctx: dict) -> str:
+ """Full page: markets listing."""
+ content = _markets_main_panel_html(ctx)
+ hdr = root_header_html(ctx)
+ hdr += sexp(
+ '(div :id "root-header-child" :class "w-full" (raw! ph (raw! pah (raw! mh))))',
+ ph=_post_header_html(ctx),
+ pah=_post_admin_header_html(ctx),
+ mh=_markets_header_html(ctx),
+ )
+ return full_page(ctx, header_rows_html=hdr, content_html=content)
+
+
+async def render_markets_oob(ctx: dict) -> str:
+ """OOB response: markets listing."""
+ content = _markets_main_panel_html(ctx)
+ oobs = _post_admin_header_html(ctx, oob=True)
+ oobs += _oob_header_html("post-admin-header-child", "markets-header-child",
+ _markets_header_html(ctx))
+ return oob_page(ctx, oobs_html=oobs, content_html=content)
+
+
+# ---------------------------------------------------------------------------
+# Payments
+# ---------------------------------------------------------------------------
+
+async def render_payments_page(ctx: dict) -> str:
+ """Full page: payments admin."""
+ content = _payments_main_panel_html(ctx)
+ hdr = root_header_html(ctx)
+ hdr += sexp(
+ '(div :id "root-header-child" :class "w-full" (raw! ph (raw! pah (raw! pyh))))',
+ ph=_post_header_html(ctx),
+ pah=_post_admin_header_html(ctx),
+ pyh=_payments_header_html(ctx),
+ )
+ return full_page(ctx, header_rows_html=hdr, content_html=content)
+
+
+async def render_payments_oob(ctx: dict) -> str:
+ """OOB response: payments admin."""
+ content = _payments_main_panel_html(ctx)
+ oobs = _post_admin_header_html(ctx, oob=True)
+ oobs += _oob_header_html("post-admin-header-child", "payments-header-child",
+ _payments_header_html(ctx))
+ return oob_page(ctx, oobs_html=oobs, content_html=content)
diff --git a/federation/app.py b/federation/app.py
index 393b643..55f6519 100644
--- a/federation/app.py
+++ b/federation/app.py
@@ -1,5 +1,6 @@
from __future__ import annotations
import path_setup # noqa: F401 # adds shared/ to sys.path
+import sexp_components # noqa: F401 # ensure Hypercorn --reload watches this file
from pathlib import Path
from quart import g, request
@@ -93,8 +94,13 @@ def create_app() -> "Quart":
# --- home page ---
@app.get("/")
async def home():
- from quart import render_template
- return await render_template("_types/federation/index.html")
+ from quart import make_response
+ from shared.sexp.page import get_template_context
+ from sexp_components import render_federation_home
+
+ ctx = await get_template_context()
+ html = await render_federation_home(ctx)
+ return await make_response(html)
return app
diff --git a/federation/bp/auth/routes.py b/federation/bp/auth/routes.py
index 6b33175..4aa3a8e 100644
--- a/federation/bp/auth/routes.py
+++ b/federation/bp/auth/routes.py
@@ -100,7 +100,10 @@ def register(url_prefix="/auth"):
# If there's a pending redirect (e.g. OAuth authorize), follow it
redirect_url = pop_login_redirect_target()
return redirect(redirect_url)
- return await render_template("auth/login.html")
+ from shared.sexp.page import get_template_context
+ from sexp_components import render_login_page
+ ctx = await get_template_context()
+ return await render_login_page(ctx)
@auth_bp.post("/start/")
async def start_login():
diff --git a/federation/bp/identity/routes.py b/federation/bp/identity/routes.py
index b445eda..b18461c 100644
--- a/federation/bp/identity/routes.py
+++ b/federation/bp/identity/routes.py
@@ -39,7 +39,11 @@ def register(url_prefix="/identity"):
if actor:
return redirect(url_for("activitypub.actor_profile", username=actor.preferred_username))
- return await render_template("federation/choose_username.html")
+ from shared.sexp.page import get_template_context
+ from sexp_components import render_choose_username_page
+ ctx = await get_template_context()
+ ctx["actor"] = actor
+ return await render_choose_username_page(ctx)
@bp.post("/choose-username")
async def choose_username():
diff --git a/federation/bp/social/routes.py b/federation/bp/social/routes.py
index 7878156..9ba7d37 100644
--- a/federation/bp/social/routes.py
+++ b/federation/bp/social/routes.py
@@ -39,12 +39,10 @@ def register(url_prefix="/social"):
return redirect(url_for("auth.login_form"))
actor = _require_actor()
items = await services.federation.get_home_timeline(g.s, actor.id)
- return await render_template(
- "federation/timeline.html",
- items=items,
- timeline_type="home",
- actor=actor,
- )
+ from shared.sexp.page import get_template_context
+ from sexp_components import render_timeline_page
+ ctx = await get_template_context()
+ return await render_timeline_page(ctx, items, "home", actor)
@bp.get("/timeline")
async def home_timeline_page():
@@ -59,23 +57,17 @@ def register(url_prefix="/social"):
items = await services.federation.get_home_timeline(
g.s, actor.id, before=before,
)
- return await render_template(
- "federation/_timeline_items.html",
- items=items,
- timeline_type="home",
- actor=actor,
- )
+ from sexp_components import render_timeline_items
+ return await render_timeline_items(items, "home", actor)
@bp.get("/public")
async def public_timeline():
items = await services.federation.get_public_timeline(g.s)
actor = getattr(g, "_social_actor", None)
- return await render_template(
- "federation/timeline.html",
- items=items,
- timeline_type="public",
- actor=actor,
- )
+ from shared.sexp.page import get_template_context
+ from sexp_components import render_timeline_page
+ ctx = await get_template_context()
+ return await render_timeline_page(ctx, items, "public", actor)
@bp.get("/public/timeline")
async def public_timeline_page():
@@ -88,12 +80,8 @@ def register(url_prefix="/social"):
pass
items = await services.federation.get_public_timeline(g.s, before=before)
actor = getattr(g, "_social_actor", None)
- return await render_template(
- "federation/_timeline_items.html",
- items=items,
- timeline_type="public",
- actor=actor,
- )
+ from sexp_components import render_timeline_items
+ return await render_timeline_items(items, "public", actor)
# -- Compose --------------------------------------------------------------
@@ -101,11 +89,10 @@ def register(url_prefix="/social"):
async def compose_form():
actor = _require_actor()
reply_to = request.args.get("reply_to")
- return await render_template(
- "federation/compose.html",
- actor=actor,
- reply_to=reply_to,
- )
+ from shared.sexp.page import get_template_context
+ from sexp_components import render_compose_page
+ ctx = await get_template_context()
+ return await render_compose_page(ctx, actor, reply_to)
@bp.post("/compose")
async def compose_submit():
@@ -148,15 +135,10 @@ def register(url_prefix="/social"):
g.s, actor.preferred_username, page=1, per_page=1000,
)
followed_urls = {a.actor_url for a in following}
- return await render_template(
- "federation/search.html",
- query=query,
- actors=actors,
- total=total,
- page=1,
- followed_urls=followed_urls,
- actor=actor,
- )
+ from shared.sexp.page import get_template_context
+ from sexp_components import render_search_page
+ ctx = await get_template_context()
+ return await render_search_page(ctx, query, actors, total, 1, followed_urls, actor)
@bp.get("/search/page")
async def search_page():
@@ -175,15 +157,8 @@ def register(url_prefix="/social"):
g.s, actor.preferred_username, page=1, per_page=1000,
)
followed_urls = {a.actor_url for a in following}
- return await render_template(
- "federation/_search_results.html",
- actors=actors,
- total=total,
- page=page,
- query=query,
- followed_urls=followed_urls,
- actor=actor,
- )
+ from sexp_components import render_search_results
+ return await render_search_results(actors, query, page, followed_urls, actor)
@bp.post("/follow")
async def follow():
@@ -340,13 +315,10 @@ def register(url_prefix="/social"):
actors, total = await services.federation.get_following(
g.s, actor.preferred_username,
)
- return await render_template(
- "federation/following.html",
- actors=actors,
- total=total,
- page=1,
- actor=actor,
- )
+ from shared.sexp.page import get_template_context
+ from sexp_components import render_following_page
+ ctx = await get_template_context()
+ return await render_following_page(ctx, actors, total, actor)
@bp.get("/following/page")
async def following_list_page():
@@ -355,15 +327,8 @@ def register(url_prefix="/social"):
actors, total = await services.federation.get_following(
g.s, actor.preferred_username, page=page,
)
- return await render_template(
- "federation/_actor_list_items.html",
- actors=actors,
- total=total,
- page=page,
- list_type="following",
- followed_urls=set(),
- actor=actor,
- )
+ from sexp_components import render_following_items
+ return await render_following_items(actors, page, actor)
@bp.get("/followers")
async def followers_list():
@@ -376,14 +341,10 @@ def register(url_prefix="/social"):
g.s, actor.preferred_username, page=1, per_page=1000,
)
followed_urls = {a.actor_url for a in following}
- return await render_template(
- "federation/followers.html",
- actors=actors,
- total=total,
- page=1,
- followed_urls=followed_urls,
- actor=actor,
- )
+ from shared.sexp.page import get_template_context
+ from sexp_components import render_followers_page
+ ctx = await get_template_context()
+ return await render_followers_page(ctx, actors, total, followed_urls, actor)
@bp.get("/followers/page")
async def followers_list_page():
@@ -396,15 +357,8 @@ def register(url_prefix="/social"):
g.s, actor.preferred_username, page=1, per_page=1000,
)
followed_urls = {a.actor_url for a in following}
- return await render_template(
- "federation/_actor_list_items.html",
- actors=actors,
- total=total,
- page=page,
- list_type="followers",
- followed_urls=followed_urls,
- actor=actor,
- )
+ from sexp_components import render_followers_items
+ return await render_followers_items(actors, page, followed_urls, actor)
@bp.get("/actor/")
async def actor_timeline(id: int):
@@ -435,13 +389,10 @@ def register(url_prefix="/social"):
)
).scalar_one_or_none()
is_following = existing is not None
- return await render_template(
- "federation/actor_timeline.html",
- remote_actor=remote_dto,
- items=items,
- is_following=is_following,
- actor=actor,
- )
+ from shared.sexp.page import get_template_context
+ from sexp_components import render_actor_timeline_page
+ ctx = await get_template_context()
+ return await render_actor_timeline_page(ctx, remote_dto, items, is_following, actor)
@bp.get("/actor//timeline")
async def actor_timeline_page(id: int):
@@ -456,13 +407,8 @@ def register(url_prefix="/social"):
items = await services.federation.get_actor_timeline(
g.s, id, before=before,
)
- return await render_template(
- "federation/_timeline_items.html",
- items=items,
- timeline_type="actor",
- actor_id=id,
- actor=actor,
- )
+ from sexp_components import render_actor_timeline_items
+ return await render_actor_timeline_items(items, id, actor)
# -- Notifications --------------------------------------------------------
@@ -471,11 +417,10 @@ def register(url_prefix="/social"):
actor = _require_actor()
items = await services.federation.get_notifications(g.s, actor.id)
await services.federation.mark_notifications_read(g.s, actor.id)
- return await render_template(
- "federation/notifications.html",
- notifications=items,
- actor=actor,
- )
+ from shared.sexp.page import get_template_context
+ from sexp_components import render_notifications_page
+ ctx = await get_template_context()
+ return await render_notifications_page(ctx, items, actor)
@bp.get("/notifications/count")
async def notification_count():
diff --git a/federation/sexp_components.py b/federation/sexp_components.py
new file mode 100644
index 0000000..f27b0e4
--- /dev/null
+++ b/federation/sexp_components.py
@@ -0,0 +1,710 @@
+"""
+Federation service s-expression page components.
+
+Renders social timeline, compose, search, following/followers, notifications,
+actor profiles, login, and username selection pages.
+"""
+from __future__ import annotations
+
+from typing import Any
+from markupsafe import escape
+
+from shared.sexp.jinja_bridge import sexp
+from shared.sexp.helpers import root_header_html, full_page
+
+
+# ---------------------------------------------------------------------------
+# Social header nav
+# ---------------------------------------------------------------------------
+
+def _social_nav_html(actor: Any) -> str:
+ """Build the social header nav bar content."""
+ from quart import url_for, request
+
+ if not actor:
+ choose_url = url_for("identity.choose_username_form")
+ return (
+ ''
+ f'Choose username '
+ ' '
+ )
+
+ links = [
+ ("social.home_timeline", "Timeline"),
+ ("social.public_timeline", "Public"),
+ ("social.compose_form", "Compose"),
+ ("social.following_list", "Following"),
+ ("social.followers_list", "Followers"),
+ ("social.search", "Search"),
+ ]
+
+ parts = ['']
+ for endpoint, label in links:
+ href = url_for(endpoint)
+ bold = " font-bold" if request.path == href else ""
+ parts.append(f'{label} ')
+
+ # Notifications with live badge
+ notif_url = url_for("social.notifications")
+ notif_count_url = url_for("social.notification_count")
+ notif_bold = " font-bold" if request.path == notif_url else ""
+ parts.append(
+ f'Notifications'
+ f' '
+ )
+
+ # Profile link
+ profile_url = url_for("activitypub.actor_profile", username=actor.preferred_username)
+ parts.append(f'@{actor.preferred_username} ')
+ parts.append(' ')
+ return "".join(parts)
+
+
+def _social_header_html(actor: Any) -> str:
+ """Build the social section header row."""
+ nav_html = _social_nav_html(actor)
+ return sexp(
+ '(div :id "social-row" :class "flex flex-col items-center md:flex-row justify-center md:justify-between w-full p-1 bg-sky-400"'
+ ' (div :class "w-full flex flex-row items-center gap-2 flex-wrap" (raw! nh)))',
+ nh=nav_html,
+ )
+
+
+def _social_page(ctx: dict, actor: Any, *, content_html: str,
+ title: str = "Rose Ash", meta_html: str = "") -> str:
+ """Render a social page with header and content."""
+ hdr = root_header_html(ctx)
+ hdr += sexp(
+ '(div :id "root-header-child" :class "flex flex-col w-full items-center" (raw! sh))',
+ sh=_social_header_html(actor),
+ )
+ return full_page(ctx, header_rows_html=hdr, content_html=content_html,
+ meta_html=meta_html or f'{escape(title)} ')
+
+
+# ---------------------------------------------------------------------------
+# Post card
+# ---------------------------------------------------------------------------
+
+def _interaction_buttons_html(item: Any, actor: Any) -> str:
+ """Render like/boost/reply buttons for a post."""
+ from shared.browser.app.csrf import generate_csrf_token
+ from quart import url_for
+
+ oid = getattr(item, "object_id", "") or ""
+ ainbox = getattr(item, "author_inbox", "") or ""
+ lcount = getattr(item, "like_count", 0) or 0
+ bcount = getattr(item, "boost_count", 0) or 0
+ liked = getattr(item, "liked_by_me", False)
+ boosted = getattr(item, "boosted_by_me", False)
+ csrf = generate_csrf_token()
+
+ safe_id = oid.replace("/", "_").replace(":", "_")
+ target = f"#interactions-{safe_id}"
+
+ if liked:
+ like_action = url_for("social.unlike")
+ like_cls = "text-red-500 hover:text-red-600"
+ like_icon = "♥"
+ else:
+ like_action = url_for("social.like")
+ like_cls = "hover:text-red-500"
+ like_icon = "♡"
+
+ if boosted:
+ boost_action = url_for("social.unboost")
+ boost_cls = "text-green-600 hover:text-green-700"
+ else:
+ boost_action = url_for("social.boost")
+ boost_cls = "hover:text-green-600"
+
+ reply_url = url_for("social.compose_form", reply_to=oid) if oid else ""
+ reply_html = f'Reply ' if reply_url else ""
+
+ return (
+ f''
+ f'
'
+ f' '
+ f' '
+ f' '
+ f'{like_icon} {lcount} '
+ f''
+ f' '
+ f' '
+ f' '
+ f'↻ {bcount} '
+ f'{reply_html}'
+ )
+
+
+def _post_card_html(item: Any, actor: Any) -> str:
+ """Render a single timeline post card."""
+ boosted_by = getattr(item, "boosted_by", None)
+ actor_icon = getattr(item, "actor_icon", None)
+ actor_name = getattr(item, "actor_name", "?")
+ actor_username = getattr(item, "actor_username", "")
+ actor_domain = getattr(item, "actor_domain", "")
+ content = getattr(item, "content", "")
+ summary = getattr(item, "summary", None)
+ published = getattr(item, "published", None)
+ url = getattr(item, "url", None)
+ post_type = getattr(item, "post_type", "")
+
+ boost_html = f'Boosted by {escape(boosted_by)}
' if boosted_by else ""
+
+ if actor_icon:
+ avatar = f' '
+ else:
+ initial = actor_name[0].upper() if actor_name else "?"
+ avatar = f'{initial}
'
+
+ domain_html = f"@{escape(actor_domain)}" if actor_domain else ""
+ time_html = published.strftime("%b %d, %H:%M") if published else ""
+
+ if summary:
+ content_html = (
+ f'CW: {escape(summary)} '
+ f'{content}
'
+ )
+ else:
+ content_html = f'{content}
'
+
+ original_html = ""
+ if url and post_type == "remote":
+ original_html = f'original '
+
+ interactions_html = ""
+ if actor:
+ oid = getattr(item, "object_id", "") or ""
+ safe_id = oid.replace("/", "_").replace(":", "_")
+ interactions_html = f'{_interaction_buttons_html(item, actor)}
'
+
+ return (
+ f''
+ f'{boost_html}'
+ f'{avatar}'
+ f'
'
+ f'
'
+ f'{escape(actor_name)} '
+ f'@{escape(actor_username)}{domain_html} '
+ f'{time_html}
'
+ f'{content_html}{original_html}{interactions_html}
'
+ )
+
+
+# ---------------------------------------------------------------------------
+# Timeline items (pagination fragment)
+# ---------------------------------------------------------------------------
+
+def _timeline_items_html(items: list, timeline_type: str, actor: Any,
+ actor_id: int | None = None) -> str:
+ """Render timeline items with infinite scroll sentinel."""
+ from quart import url_for
+
+ parts = [_post_card_html(item, actor) for item in items]
+
+ if items:
+ last = items[-1]
+ before = last.published.isoformat() if last.published else ""
+ if timeline_type == "actor" and actor_id is not None:
+ next_url = url_for("social.actor_timeline_page", id=actor_id, before=before)
+ else:
+ next_url = url_for(f"social.{timeline_type}_timeline_page", before=before)
+ parts.append(f'
')
+
+ return "".join(parts)
+
+
+# ---------------------------------------------------------------------------
+# Search results (pagination fragment)
+# ---------------------------------------------------------------------------
+
+def _actor_card_html(a: Any, actor: Any, followed_urls: set,
+ *, list_type: str = "search") -> str:
+ """Render a single actor card with follow/unfollow button."""
+ from shared.browser.app.csrf import generate_csrf_token
+ from quart import url_for
+
+ csrf = generate_csrf_token()
+ display_name = getattr(a, "display_name", None) or getattr(a, "preferred_username", "")
+ username = getattr(a, "preferred_username", "")
+ domain = getattr(a, "domain", "")
+ icon_url = getattr(a, "icon_url", None)
+ actor_url = getattr(a, "actor_url", "")
+ summary = getattr(a, "summary", None)
+ aid = getattr(a, "id", None)
+
+ safe_id = actor_url.replace("/", "_").replace(":", "_")
+
+ if icon_url:
+ avatar = f' '
+ else:
+ initial = (display_name or username)[0].upper() if (display_name or username) else "?"
+ avatar = f'{initial}
'
+
+ # Name link
+ if list_type == "following" and aid:
+ name_html = f'{escape(display_name)} '
+ elif list_type == "search" and aid:
+ name_html = f'{escape(display_name)} '
+ else:
+ name_html = f'{escape(display_name)} '
+
+ summary_html = f'{summary}
' if summary else ""
+
+ # Follow/unfollow button
+ button_html = ""
+ if actor:
+ is_followed = actor_url in (followed_urls or set())
+ if list_type == "following" or is_followed:
+ button_html = (
+ f'
'
+ f' '
+ f' '
+ f'Unfollow '
+ )
+ else:
+ label = "Follow Back" if list_type == "followers" else "Follow"
+ button_html = (
+ f'
'
+ f' '
+ f' '
+ f'{label} '
+ )
+
+ return (
+ f''
+ f'{avatar}{name_html}'
+ f'
@{escape(username)}@{escape(domain)}
'
+ f'{summary_html}
{button_html} '
+ )
+
+
+def _search_results_html(actors: list, query: str, page: int,
+ followed_urls: set, actor: Any) -> str:
+ """Render search results with pagination sentinel."""
+ from quart import url_for
+
+ parts = [_actor_card_html(a, actor, followed_urls, list_type="search") for a in actors]
+ if len(actors) >= 20:
+ next_url = url_for("social.search_page", q=query, page=page + 1)
+ parts.append(f'
')
+ return "".join(parts)
+
+
+def _actor_list_items_html(actors: list, page: int, list_type: str,
+ followed_urls: set, actor: Any) -> str:
+ """Render actor list items (following/followers) with pagination sentinel."""
+ from quart import url_for
+
+ parts = [_actor_card_html(a, actor, followed_urls, list_type=list_type) for a in actors]
+ if len(actors) >= 20:
+ next_url = url_for(f"social.{list_type}_list_page", page=page + 1)
+ parts.append(f'
')
+ return "".join(parts)
+
+
+# ---------------------------------------------------------------------------
+# Notification card
+# ---------------------------------------------------------------------------
+
+def _notification_html(notif: Any) -> str:
+ """Render a single notification."""
+ from_name = getattr(notif, "from_actor_name", "?")
+ from_username = getattr(notif, "from_actor_username", "")
+ from_domain = getattr(notif, "from_actor_domain", "")
+ from_icon = getattr(notif, "from_actor_icon", None)
+ ntype = getattr(notif, "notification_type", "")
+ preview = getattr(notif, "target_content_preview", None)
+ created = getattr(notif, "created_at", None)
+ read = getattr(notif, "read", True)
+ app_domain = getattr(notif, "app_domain", "")
+
+ border = " border-l-4 border-l-stone-400" if not read else ""
+
+ if from_icon:
+ avatar = f' '
+ else:
+ initial = from_name[0].upper() if from_name else "?"
+ avatar = f'{initial}
'
+
+ domain_html = f"@{escape(from_domain)}" if from_domain else ""
+
+ type_map = {
+ "follow": "followed you",
+ "like": "liked your post",
+ "boost": "boosted your post",
+ "mention": "mentioned you",
+ "reply": "replied to your post",
+ }
+ action = type_map.get(ntype, "")
+ if ntype == "follow" and app_domain and app_domain != "federation":
+ action += f" on {escape(app_domain)}"
+
+ preview_html = f'{escape(preview)}
' if preview else ""
+ time_html = created.strftime("%b %d, %H:%M") if created else ""
+
+ return (
+ f''
+ f'
{avatar}
'
+ f'
{escape(from_name)} '
+ f' @{escape(from_username)}{domain_html} '
+ f' {action}
'
+ f'{preview_html}
{time_html}
'
+ )
+
+
+# ---------------------------------------------------------------------------
+# Public API: Home page
+# ---------------------------------------------------------------------------
+
+async def render_federation_home(ctx: dict) -> str:
+ """Full page: federation home (minimal)."""
+ hdr = root_header_html(ctx)
+ return full_page(ctx, header_rows_html=hdr)
+
+
+# ---------------------------------------------------------------------------
+# Public API: Login
+# ---------------------------------------------------------------------------
+
+async def render_login_page(ctx: dict) -> str:
+ """Full page: federation login form."""
+ from shared.browser.app.csrf import generate_csrf_token
+ from quart import url_for
+
+ error = ctx.get("error", "")
+ email = ctx.get("email", "")
+ action = url_for("auth.start_login")
+ csrf = generate_csrf_token()
+
+ error_html = f'{error}
' if error else ""
+ content = (
+ f''
+ )
+
+ hdr = root_header_html(ctx)
+ return full_page(ctx, header_rows_html=hdr, content_html=content,
+ meta_html="Login \u2014 Rose Ash ")
+
+
+# ---------------------------------------------------------------------------
+# Public API: Timeline
+# ---------------------------------------------------------------------------
+
+async def render_timeline_page(ctx: dict, items: list, timeline_type: str,
+ actor: Any) -> str:
+ """Full page: timeline (home or public)."""
+ from quart import url_for
+
+ label = "Home" if timeline_type == "home" else "Public"
+ compose_html = ""
+ if actor:
+ compose_url = url_for("social.compose_form")
+ compose_html = f'Compose '
+
+ timeline_html = _timeline_items_html(items, timeline_type, actor)
+
+ content = (
+ f''
+ f'
{label} Timeline {compose_html}'
+ f'{timeline_html}
'
+ )
+
+ return _social_page(ctx, actor, content_html=content,
+ title=f"{label} Timeline \u2014 Rose Ash")
+
+
+async def render_timeline_items(items: list, timeline_type: str,
+ actor: Any, actor_id: int | None = None) -> str:
+ """Pagination fragment: timeline items."""
+ return _timeline_items_html(items, timeline_type, actor, actor_id)
+
+
+# ---------------------------------------------------------------------------
+# Public API: Compose
+# ---------------------------------------------------------------------------
+
+async def render_compose_page(ctx: dict, actor: Any, reply_to: str | None) -> str:
+ """Full page: compose form."""
+ from shared.browser.app.csrf import generate_csrf_token
+ from quart import url_for
+
+ csrf = generate_csrf_token()
+ action = url_for("social.compose_submit")
+
+ reply_html = ""
+ if reply_to:
+ reply_html = (
+ f' '
+ f'Replying to {escape(reply_to)}
'
+ )
+
+ content = (
+ f'Compose '
+ f''
+ f' {reply_html}'
+ f' '
+ f''
+ f''
+ f'Public Unlisted '
+ f'Followers only '
+ f'Publish
'
+ )
+
+ return _social_page(ctx, actor, content_html=content,
+ title="Compose \u2014 Rose Ash")
+
+
+# ---------------------------------------------------------------------------
+# Public API: Search
+# ---------------------------------------------------------------------------
+
+async def render_search_page(ctx: dict, query: str, actors: list, total: int,
+ page: int, followed_urls: set, actor: Any) -> str:
+ """Full page: search."""
+ from quart import url_for
+
+ search_url = url_for("social.search")
+ search_page_url = url_for("social.search_page")
+
+ results_html = _search_results_html(actors, query, page, followed_urls, actor)
+
+ info_html = ""
+ if query and total:
+ s = "s" if total != 1 else ""
+ info_html = f'{total} result{s} for {escape(query)}
'
+ elif query:
+ info_html = f'No results found for {escape(query)}
'
+
+ content = (
+ f'Search '
+ f''
+ f' '
+ f'Search
'
+ f'{info_html}{results_html}
'
+ )
+
+ return _social_page(ctx, actor, content_html=content,
+ title="Search \u2014 Rose Ash")
+
+
+async def render_search_results(actors: list, query: str, page: int,
+ followed_urls: set, actor: Any) -> str:
+ """Pagination fragment: search results."""
+ return _search_results_html(actors, query, page, followed_urls, actor)
+
+
+# ---------------------------------------------------------------------------
+# Public API: Following / Followers
+# ---------------------------------------------------------------------------
+
+async def render_following_page(ctx: dict, actors: list, total: int,
+ actor: Any) -> str:
+ """Full page: following list."""
+ items_html = _actor_list_items_html(actors, 1, "following", set(), actor)
+ content = (
+ f'Following ({total}) '
+ f'{items_html}
'
+ )
+ return _social_page(ctx, actor, content_html=content,
+ title="Following \u2014 Rose Ash")
+
+
+async def render_following_items(actors: list, page: int, actor: Any) -> str:
+ """Pagination fragment: following items."""
+ return _actor_list_items_html(actors, page, "following", set(), actor)
+
+
+async def render_followers_page(ctx: dict, actors: list, total: int,
+ followed_urls: set, actor: Any) -> str:
+ """Full page: followers list."""
+ items_html = _actor_list_items_html(actors, 1, "followers", followed_urls, actor)
+ content = (
+ f'Followers ({total}) '
+ f'{items_html}
'
+ )
+ return _social_page(ctx, actor, content_html=content,
+ title="Followers \u2014 Rose Ash")
+
+
+async def render_followers_items(actors: list, page: int,
+ followed_urls: set, actor: Any) -> str:
+ """Pagination fragment: followers items."""
+ return _actor_list_items_html(actors, page, "followers", followed_urls, actor)
+
+
+# ---------------------------------------------------------------------------
+# Public API: Actor timeline
+# ---------------------------------------------------------------------------
+
+async def render_actor_timeline_page(ctx: dict, remote_actor: Any, items: list,
+ is_following: bool, actor: Any) -> str:
+ """Full page: remote actor timeline."""
+ from shared.browser.app.csrf import generate_csrf_token
+ from quart import url_for
+
+ csrf = generate_csrf_token()
+ display_name = remote_actor.display_name or remote_actor.preferred_username
+ icon_url = getattr(remote_actor, "icon_url", None)
+ summary = getattr(remote_actor, "summary", None)
+ actor_url = getattr(remote_actor, "actor_url", "")
+
+ if icon_url:
+ avatar = f' '
+ else:
+ initial = display_name[0].upper() if display_name else "?"
+ avatar = f'{initial}
'
+
+ summary_html = f'{summary}
' if summary else ""
+
+ follow_html = ""
+ if actor:
+ if is_following:
+ follow_html = (
+ f'
'
+ f' '
+ f' '
+ f'Unfollow '
+ )
+ else:
+ follow_html = (
+ f'
'
+ f' '
+ f' '
+ f'Follow '
+ )
+
+ timeline_html = _timeline_items_html(items, "actor", actor, remote_actor.id)
+
+ content = (
+ f''
+ f'
{avatar}'
+ f'
{escape(display_name)} '
+ f'
@{escape(remote_actor.preferred_username)}@{escape(remote_actor.domain)}
'
+ f'{summary_html}
{follow_html}
'
+ f'{timeline_html}
'
+ )
+
+ return _social_page(ctx, actor, content_html=content,
+ title=f"{display_name} \u2014 Rose Ash")
+
+
+async def render_actor_timeline_items(items: list, actor_id: int,
+ actor: Any) -> str:
+ """Pagination fragment: actor timeline items."""
+ return _timeline_items_html(items, "actor", actor, actor_id)
+
+
+# ---------------------------------------------------------------------------
+# Public API: Notifications
+# ---------------------------------------------------------------------------
+
+async def render_notifications_page(ctx: dict, notifications: list,
+ actor: Any) -> str:
+ """Full page: notifications."""
+ if not notifications:
+ notif_html = 'No notifications yet.
'
+ else:
+ notif_html = '' + "".join(_notification_html(n) for n in notifications) + '
'
+
+ content = f'Notifications {notif_html}'
+ return _social_page(ctx, actor, content_html=content,
+ title="Notifications \u2014 Rose Ash")
+
+
+# ---------------------------------------------------------------------------
+# Public API: Choose username
+# ---------------------------------------------------------------------------
+
+async def render_choose_username_page(ctx: dict) -> str:
+ """Full page: choose username form."""
+ from shared.browser.app.csrf import generate_csrf_token
+ from quart import url_for
+ from shared.config import config
+
+ csrf = generate_csrf_token()
+ error = ctx.get("error", "")
+ username = ctx.get("username", "")
+ ap_domain = config().get("ap_domain", "rose-ash.com")
+ check_url = url_for("identity.check_username")
+ actor = ctx.get("actor")
+
+ error_html = f'{error}
' if error else ""
+
+ content = (
+ f''
+ f'
Choose your username '
+ f'
This will be your identity on the fediverse: '
+ f'@username@{escape(ap_domain)}
'
+ f'{error_html}'
+ f'
'
+ f' '
+ f'Username '
+ f'
@ '
+ f' '
+ f'
'
+ f'
3-32 characters. Lowercase letters, numbers, underscores. Must start with a letter.
'
+ f'Claim username '
+ )
+
+ return _social_page(ctx, actor, content_html=content,
+ title="Choose Username \u2014 Rose Ash")
+
+
+# ---------------------------------------------------------------------------
+# Public API: Actor profile
+# ---------------------------------------------------------------------------
+
+async def render_profile_page(ctx: dict, actor: Any, activities: list,
+ total: int) -> str:
+ """Full page: actor profile."""
+ from shared.config import config
+
+ ap_domain = config().get("ap_domain", "rose-ash.com")
+ display_name = actor.display_name or actor.preferred_username
+ summary_html = f'{escape(actor.summary)}
' if actor.summary else ""
+
+ activities_html = ""
+ if activities:
+ parts = []
+ for a in activities:
+ published = a.published.strftime("%Y-%m-%d %H:%M") if a.published else ""
+ obj_type = f'{a.object_type} ' if a.object_type else ""
+ parts.append(
+ f''
+ f'{a.activity_type} '
+ f'{published}
{obj_type}
'
+ )
+ activities_html = '' + "".join(parts) + '
'
+ else:
+ activities_html = 'No activities yet.
'
+
+ content = (
+ f''
+ f'
{escape(display_name)} '
+ f'
@{escape(actor.preferred_username)}@{escape(ap_domain)}
'
+ f'{summary_html}
'
+ f'
Activities ({total}) {activities_html}
'
+ )
+
+ return _social_page(ctx, actor, content_html=content,
+ title=f"@{actor.preferred_username} \u2014 Rose Ash")
diff --git a/market/app.py b/market/app.py
index ed73144..e318c04 100644
--- a/market/app.py
+++ b/market/app.py
@@ -1,5 +1,6 @@
from __future__ import annotations
import path_setup # noqa: F401 # adds shared/ to sys.path
+import sexp_components # noqa: F401 # ensure Hypercorn --reload watches this file
from pathlib import Path
diff --git a/market/bp/all_markets/routes.py b/market/bp/all_markets/routes.py
index 66506a2..d79f0bf 100644
--- a/market/bp/all_markets/routes.py
+++ b/market/bp/all_markets/routes.py
@@ -55,10 +55,14 @@ def register() -> Blueprint:
page=page,
)
+ from shared.sexp.page import get_template_context
+ from sexp_components import render_all_markets_page, render_all_markets_oob
+
+ tctx = await get_template_context()
if is_htmx_request():
- html = await render_template("_types/all_markets/_main_panel.html", **ctx)
+ html = await render_all_markets_oob(tctx, markets, has_more, page_info, page)
else:
- html = await render_template("_types/all_markets/index.html", **ctx)
+ html = await render_all_markets_page(tctx, markets, has_more, page_info, page)
return await make_response(html, 200)
@@ -67,13 +71,8 @@ def register() -> Blueprint:
page = int(request.args.get("page", 1))
markets, has_more, page_info = await _load_markets(page)
- html = await render_template(
- "_types/all_markets/_cards.html",
- markets=markets,
- has_more=has_more,
- page_info=page_info,
- page=page,
- )
+ from sexp_components import render_all_markets_cards
+ html = await render_all_markets_cards(markets, has_more, page_info, page)
return await make_response(html, 200)
return bp
diff --git a/market/bp/browse/routes.py b/market/bp/browse/routes.py
index 750b816..b0e5bf4 100644
--- a/market/bp/browse/routes.py
+++ b/market/bp/browse/routes.py
@@ -42,12 +42,15 @@ def register():
p_data = getattr(g, "post_data", None) or {}
# Determine which template to use based on request type
+ from shared.sexp.page import get_template_context
+ from sexp_components import render_market_home_page, render_market_home_oob
+
+ ctx = await get_template_context()
+ ctx.update(p_data)
if not is_htmx_request():
- # Normal browser request: full page with layout
- html = await render_template("_types/market/index.html", **p_data)
+ html = await render_market_home_page(ctx)
else:
- # HTMX request: main panel + OOB elements
- html = await render_template("_types/market/_oob_elements.html", **p_data)
+ html = await render_market_home_oob(ctx)
return await make_response(html)
@@ -70,16 +73,18 @@ def register():
product_info = await _productInfo()
full_context = {**product_info, **ctx}
- # Determine which template to use based on request type and pagination
+ from shared.sexp.page import get_template_context
+ from sexp_components import render_browse_page, render_browse_oob, render_browse_cards
+
+ tctx = await get_template_context()
+ tctx.update(full_context)
if not is_htmx_request():
- # Normal browser request: full page with layout
- html = await render_template("_types/browse/index.html", **full_context)
+ html = await render_browse_page(tctx)
elif product_info["page"] > 1:
- # HTMX pagination: just product cards + sentinel
- html = await render_template("_types/browse/_product_cards.html", **product_info)
+ tctx.update(product_info)
+ html = await render_browse_cards(tctx)
else:
- # HTMX navigation (page 1): main panel + OOB elements
- html = await render_template("_types/browse/_oob_elements.html", **full_context)
+ html = await render_browse_oob(tctx)
resp = await make_response(html)
resp.headers["Hx-Push-Url"] = _current_url_without_page()
@@ -107,15 +112,18 @@ def register():
product_info = await _productInfo(top_slug)
full_context = {**product_info, **ctx}
- # Determine which template to use based on request type and pagination
+ from shared.sexp.page import get_template_context
+ from sexp_components import render_browse_page, render_browse_oob, render_browse_cards
+
+ tctx = await get_template_context()
+ tctx.update(full_context)
if not is_htmx_request():
- # Normal browser request: full page with layout
- html = await render_template("_types/browse/index.html", **full_context)
+ html = await render_browse_page(tctx)
elif product_info["page"] > 1:
- # HTMX pagination: just product cards + sentinel
- html = await render_template("_types/browse/_product_cards.html", **product_info)
+ tctx.update(product_info)
+ html = await render_browse_cards(tctx)
else:
- html = await render_template("_types/browse/_oob_elements.html", **full_context)
+ html = await render_browse_oob(tctx)
resp = await make_response(html)
resp.headers["Hx-Push-Url"] = _current_url_without_page()
@@ -143,16 +151,18 @@ def register():
product_info = await _productInfo(top_slug, sub_slug)
full_context = {**product_info, **ctx}
- # Determine which template to use based on request type and pagination
+ from shared.sexp.page import get_template_context
+ from sexp_components import render_browse_page, render_browse_oob, render_browse_cards
+
+ tctx = await get_template_context()
+ tctx.update(full_context)
if not is_htmx_request():
- # Normal browser request: full page with layout
- html = await render_template("_types/browse/index.html", **full_context)
+ html = await render_browse_page(tctx)
elif product_info["page"] > 1:
- # HTMX pagination: just product cards + sentinel
- html = await render_template("_types/browse/_product_cards.html", **product_info)
+ tctx.update(product_info)
+ html = await render_browse_cards(tctx)
else:
- # HTMX navigation (page 1): main panel + OOB elements
- html = await render_template("_types/browse/_oob_elements.html", **full_context)
+ html = await render_browse_oob(tctx)
resp = await make_response(html)
resp.headers["Hx-Push-Url"] = _current_url_without_page()
diff --git a/market/bp/market/admin/routes.py b/market/bp/market/admin/routes.py
index 0b8478a..598aa64 100644
--- a/market/bp/market/admin/routes.py
+++ b/market/bp/market/admin/routes.py
@@ -17,12 +17,14 @@ def register():
async def admin():
from shared.browser.app.utils.htmx import is_htmx_request
- # Determine which template to use based on request type
+ from shared.sexp.page import get_template_context
+ from sexp_components import render_market_admin_page, render_market_admin_oob
+
+ tctx = await get_template_context()
if not is_htmx_request():
- # Normal browser request: full page with layout
- html = await render_template("_types/market/admin/index.html")
+ html = await render_market_admin_page(tctx)
else:
- html = await render_template("_types/market/admin/_oob_elements.html")
+ html = await render_market_admin_oob(tctx)
return await make_response(html)
return bp
diff --git a/market/bp/page_markets/routes.py b/market/bp/page_markets/routes.py
index e18a616..e7c92d6 100644
--- a/market/bp/page_markets/routes.py
+++ b/market/bp/page_markets/routes.py
@@ -39,10 +39,15 @@ def register() -> Blueprint:
page=page,
)
+ from shared.sexp.page import get_template_context
+ from sexp_components import render_page_markets_page, render_page_markets_oob
+
+ tctx = await get_template_context()
+ tctx["post"] = post
if is_htmx_request():
- html = await render_template("_types/page_markets/_main_panel.html", **ctx)
+ html = await render_page_markets_oob(tctx, markets, has_more, page)
else:
- html = await render_template("_types/page_markets/index.html", **ctx)
+ html = await render_page_markets_page(tctx, markets, has_more, page)
return await make_response(html, 200)
@@ -53,13 +58,9 @@ def register() -> Blueprint:
markets, has_more = await _load_markets(post["id"], page)
- html = await render_template(
- "_types/page_markets/_cards.html",
- markets=markets,
- has_more=has_more,
- page_info={},
- page=page,
- )
+ from sexp_components import render_page_markets_cards
+ post_slug = post.get("slug", "")
+ html = await render_page_markets_cards(markets, has_more, page, post_slug)
return await make_response(html, 200)
return bp
diff --git a/market/bp/product/routes.py b/market/bp/product/routes.py
index bd92a56..c4fb591 100644
--- a/market/bp/product/routes.py
+++ b/market/bp/product/routes.py
@@ -107,13 +107,17 @@ def register():
async def product_detail():
from shared.browser.app.utils.htmx import is_htmx_request
- # Determine which template to use based on request type
+ from shared.sexp.page import get_template_context
+ from sexp_components import render_product_page, render_product_oob
+
+ tctx = await get_template_context()
+ item_data = getattr(g, "item_data", {})
+ d = item_data.get("d", {})
+ tctx["liked_by_current_user"] = item_data.get("liked", False)
if not is_htmx_request():
- # Normal browser request: full page with layout
- html = await render_template("_types/product/index.html")
+ html = await render_product_page(tctx, d)
else:
- # HTMX request: main panel + OOB elements
- html = await render_template("_types/product/_oob_elements.html")
+ html = await render_product_oob(tctx, d)
return html
@@ -151,12 +155,17 @@ def register():
async def admin():
from shared.browser.app.utils.htmx import is_htmx_request
+ from shared.sexp.page import get_template_context
+ from sexp_components import render_product_admin_page, render_product_admin_oob
+
+ tctx = await get_template_context()
+ item_data = getattr(g, "item_data", {})
+ d = item_data.get("d", {})
+ tctx["liked_by_current_user"] = item_data.get("liked", False)
if not is_htmx_request():
- # Normal browser request: full page with layout
- html = await render_template("_types/product/admin/index.html")
+ html = await render_product_admin_page(tctx, d)
else:
- # HTMX request: main panel + OOB elements
- html = await render_template("_types/product/admin/_oob_elements.html")
+ html = await render_product_admin_oob(tctx, d)
return await make_response(html)
diff --git a/market/sexp_components.py b/market/sexp_components.py
new file mode 100644
index 0000000..242a309
--- /dev/null
+++ b/market/sexp_components.py
@@ -0,0 +1,1584 @@
+"""
+Market service s-expression page components.
+
+Renders market landing, browse (category/subcategory), product detail,
+product admin, market admin, page markets, and all markets pages.
+Called from route handlers in place of ``render_template()``.
+"""
+from __future__ import annotations
+
+from typing import Any
+from markupsafe import escape
+
+from shared.sexp.jinja_bridge import sexp
+from shared.sexp.helpers import (
+ call_url, get_asset_url, root_header_html,
+ search_mobile_html, search_desktop_html,
+ full_page, oob_page,
+)
+
+
+# ---------------------------------------------------------------------------
+# Price helpers
+# ---------------------------------------------------------------------------
+
+_SYM = {"GBP": "£", "EUR": "€", "USD": "$"}
+
+
+def _price_str(val, raw, cur) -> str:
+ if raw:
+ return str(raw)
+ if isinstance(val, (int, float)):
+ return f"{_SYM.get(cur, '')}{val:.2f}"
+ return str(val or "")
+
+
+def _set_prices(item: dict) -> dict:
+ """Extract price values from product dict (mirrors prices.html set_prices macro)."""
+ oe = item.get("oe_list_price") or {}
+ sp_val = item.get("special_price") or (oe.get("special") if oe else None)
+ sp_raw = item.get("special_price_raw") or (oe.get("special_raw") if oe else None)
+ sp_cur = item.get("special_price_currency") or (oe.get("special_currency") if oe else None)
+ rp_val = item.get("regular_price") or item.get("rrp") or (oe.get("rrp") if oe else None)
+ rp_raw = item.get("regular_price_raw") or item.get("rrp_raw") or (oe.get("rrp_raw") if oe else None)
+ rp_cur = item.get("regular_price_currency") or item.get("rrp_currency") or (oe.get("rrp_currency") if oe else None)
+ return dict(sp_val=sp_val, sp_raw=sp_raw, sp_cur=sp_cur,
+ rp_val=rp_val, rp_raw=rp_raw, rp_cur=rp_cur)
+
+
+def _card_price_html(p: dict) -> str:
+ """Render price line for product card (mirrors prices.html card_price macro)."""
+ pr = _set_prices(p)
+ sp_str = _price_str(pr["sp_val"], pr["sp_raw"], pr["sp_cur"])
+ rp_str = _price_str(pr["rp_val"], pr["rp_raw"], pr["rp_cur"])
+ parts = ['']
+ if pr["sp_val"]:
+ parts.append(f'
{sp_str}
')
+ if pr["rp_val"]:
+ parts.append(f'
{rp_str}
')
+ elif pr["rp_val"]:
+ parts.append(f'
{rp_str}
')
+ parts.append("
")
+ return "".join(parts)
+
+
+# ---------------------------------------------------------------------------
+# Header helpers
+# ---------------------------------------------------------------------------
+
+def _post_header_html(ctx: dict, *, oob: bool = False) -> str:
+ """Build the post-level header row (feature image + title + page cart count)."""
+ post = ctx.get("post") or {}
+ slug = post.get("slug", "")
+ title = (post.get("title") or "")[:160]
+ feature_image = post.get("feature_image")
+
+ label_parts = []
+ if feature_image:
+ label_parts.append(
+ f' '
+ )
+ label_parts.append(f"{escape(title)} ")
+ label_html = "".join(label_parts)
+
+ nav_parts = []
+ page_cart_count = ctx.get("page_cart_count", 0)
+ if page_cart_count and page_cart_count > 0:
+ cart_href = call_url(ctx, "cart_url", f"/{slug}/")
+ nav_parts.append(
+ f''
+ f' '
+ f'{page_cart_count} '
+ )
+
+ # Container nav
+ container_nav = ctx.get("container_nav_html", "")
+ if container_nav:
+ nav_parts.append(container_nav)
+
+ nav_html = "".join(nav_parts)
+ link_href = call_url(ctx, "blog_url", f"/{slug}/")
+
+ return sexp(
+ '(~menu-row :id "post-row" :level 1'
+ ' :link-href lh :link-label-html llh'
+ ' :nav-html nh :child-id "post-header-child" :oob oob)',
+ lh=link_href,
+ llh=label_html,
+ nh=nav_html,
+ oob=oob,
+ )
+
+
+def _market_header_html(ctx: dict, *, oob: bool = False) -> str:
+ """Build the market-level header row (shop icon + market title + category slugs + nav)."""
+ from quart import url_for
+
+ market_title = ctx.get("market_title", "")
+ top_slug = ctx.get("top_slug", "")
+ sub_slug = ctx.get("sub_slug", "")
+ hx_select_search = ctx.get("hx_select_search", "#main-panel")
+
+ label_parts = [
+ '',
+ f'
{escape(market_title)}
',
+ '
',
+ f"
{escape(top_slug or '')}
",
+ ]
+ if sub_slug:
+ label_parts.append(f"
{escape(sub_slug)}
")
+ label_parts.append("
")
+ label_html = "".join(label_parts)
+
+ link_href = url_for("market.browse.home")
+
+ # Build desktop nav from categories
+ categories = ctx.get("categories", {})
+ qs = ctx.get("qs", "")
+ nav_html = _desktop_category_nav_html(ctx, categories, qs, hx_select_search)
+
+ return sexp(
+ '(~menu-row :id "market-row" :level 2'
+ ' :link-href lh :link-label-html llh'
+ ' :nav-html nh :child-id "market-header-child" :oob oob)',
+ lh=link_href,
+ llh=label_html,
+ nh=nav_html,
+ oob=oob,
+ )
+
+
+def _desktop_category_nav_html(ctx: dict, categories: dict, qs: str,
+ hx_select: str) -> str:
+ """Build desktop category navigation links."""
+ from quart import url_for
+ from shared.utils import route_prefix
+
+ prefix = route_prefix()
+ category_label = ctx.get("category_label", "")
+ select_colours = ctx.get("select_colours", "")
+ rights = ctx.get("rights", {})
+
+ parts = ['']
+
+ all_href = prefix + url_for("market.browse.browse_all") + qs
+ all_active = (category_label == "All Products")
+ parts.append(
+ f''
+ )
+
+ for cat, data in categories.items():
+ cat_href = prefix + url_for("market.browse.browse_top", top_slug=data["slug"]) + qs
+ cat_active = (cat == category_label)
+ parts.append(
+ f''
+ )
+
+ # Admin link
+ if rights and rights.get("admin"):
+ admin_href = prefix + url_for("market.admin.admin")
+ parts.append(
+ f''
+ f' '
+ )
+
+ parts.append(" ")
+ return "".join(parts)
+
+
+def _product_header_html(ctx: dict, d: dict, *, oob: bool = False) -> str:
+ """Build the product-level header row (bag icon + title + prices + admin)."""
+ from quart import url_for
+ from shared.browser.app.csrf import generate_csrf_token
+
+ slug = d.get("slug", "")
+ title = d.get("title", "")
+ hx_select_search = ctx.get("hx_select_search", "#main-panel")
+ link_href = url_for("market.browse.product.product_detail", product_slug=slug)
+
+ label_html = f'{escape(title)}
'
+
+ # Prices in nav area
+ pr = _set_prices(d)
+ cart = ctx.get("cart", [])
+ prices_nav = _prices_header_html(d, pr, cart, slug, ctx)
+
+ rights = ctx.get("rights", {})
+ admin_html = ""
+ if rights and rights.get("admin"):
+ admin_href = url_for("market.browse.product.admin", product_slug=slug)
+ admin_html = (
+ f''
+ f' '
+ )
+ nav_html = prices_nav + admin_html
+
+ return sexp(
+ '(~menu-row :id "product-row" :level 3'
+ ' :link-href lh :link-label-html llh'
+ ' :nav-html nh :child-id "product-header-child" :oob oob)',
+ lh=link_href,
+ llh=label_html,
+ nh=nav_html,
+ oob=oob,
+ )
+
+
+def _prices_header_html(d: dict, pr: dict, cart: list, slug: str, ctx: dict) -> str:
+ """Build prices + add-to-cart for product header row."""
+ from quart import url_for
+ from shared.browser.app.csrf import generate_csrf_token
+
+ csrf = generate_csrf_token()
+ cart_action = url_for("market.browse.product.cart", product_slug=slug)
+ cart_url_fn = ctx.get("cart_url")
+
+ # Add-to-cart button
+ quantity = sum(ci.quantity for ci in cart if ci.product.slug == slug) if cart else 0
+ add_html = _cart_add_html(slug, quantity, cart_action, csrf, cart_url_fn)
+
+ parts = ['']
+ parts.append(add_html)
+
+ sp_val, rp_val = pr.get("sp_val"), pr.get("rp_val")
+ if sp_val:
+ parts.append(f'
Special price
')
+ parts.append(f'
{_price_str(sp_val, pr["sp_raw"], pr["sp_cur"])}
')
+ if rp_val:
+ parts.append(f'
{_price_str(rp_val, pr["rp_raw"], pr["rp_cur"])}
')
+ elif rp_val:
+ parts.append(f'
Our price
')
+ parts.append(f'
{_price_str(rp_val, pr["rp_raw"], pr["rp_cur"])}
')
+
+ # RRP
+ rrp_raw = d.get("rrp_raw")
+ rrp_val = d.get("rrp")
+ case_size = d.get("case_size_count") or 1
+ if rrp_raw and rrp_val:
+ rrp_str = f"{rrp_raw[0]}{rrp_val * case_size:.2f}"
+ parts.append(f'
rrp: {rrp_str}
')
+
+ parts.append("
")
+ return "".join(parts)
+
+
+def _cart_add_html(slug: str, quantity: int, action: str, csrf: str,
+ cart_url_fn: Any = None) -> str:
+ """Render add-to-cart button or quantity controls."""
+ if not quantity:
+ return (
+ f''
+ f'
'
+ f' '
+ f' '
+ f''
+ f' '
+ f' '
+ )
+
+ cart_href = cart_url_fn("/") if callable(cart_url_fn) else "/"
+ return (
+ f''
+ )
+
+
+# ---------------------------------------------------------------------------
+# Mobile nav panel
+# ---------------------------------------------------------------------------
+
+def _mobile_nav_panel_html(ctx: dict) -> str:
+ """Build mobile nav panel with category accordion."""
+ from quart import url_for
+ from shared.utils import route_prefix
+
+ prefix = route_prefix()
+ categories = ctx.get("categories", {})
+ qs = ctx.get("qs", "")
+ category_label = ctx.get("category_label", "")
+ top_slug = ctx.get("top_slug", "")
+ sub_slug = ctx.get("sub_slug", "")
+ hx_select = ctx.get("hx_select_search", "#main-panel")
+ select_colours = ctx.get("select_colours", "")
+
+ parts = ['']
+
+ all_href = prefix + url_for("market.browse.browse_all") + qs
+ all_active = (category_label == "All Products")
+ parts.append(
+ f'
'
+ f'All
'
+ )
+
+ for cat, data in categories.items():
+ cat_slug = data.get("slug", "")
+ cat_active = (top_slug == cat_slug.lower() if top_slug else False)
+ open_attr = " open" if cat_active else ""
+ cat_href = prefix + url_for("market.browse.browse_top", top_slug=cat_slug) + qs
+ bg_cls = " bg-stone-900 text-white hover:bg-stone-900" if cat_active else ""
+
+ parts.append(f'
')
+ parts.append(
+ f''
+ f''
+ f'{escape(cat)}
'
+ f'{data.get("count", 0)}
'
+ f' '
+ f' '
+ )
+
+ subs = data.get("subs", [])
+ if subs:
+ parts.append('")
+ else:
+ view_href = prefix + url_for("market.browse.browse_top", top_slug=cat_slug) + qs
+ parts.append(
+ f''
+ )
+ parts.append(" ")
+
+ parts.append("
")
+ return "".join(parts)
+
+
+# ---------------------------------------------------------------------------
+# Product card (browse grid item)
+# ---------------------------------------------------------------------------
+
+def _product_card_html(p: dict, ctx: dict) -> str:
+ """Render a single product card for browse grid."""
+ from quart import url_for
+ from shared.browser.app.csrf import generate_csrf_token
+ from shared.utils import route_prefix
+
+ prefix = route_prefix()
+ slug = p.get("slug", "")
+ item_href = prefix + url_for("market.browse.product.product_detail", product_slug=slug)
+ hx_select = ctx.get("hx_select_search", "#main-panel")
+ asset_url_fn = ctx.get("asset_url")
+ cart = ctx.get("cart", [])
+ selected_brands = ctx.get("selected_brands", [])
+ selected_stickers = ctx.get("selected_stickers", [])
+ search = ctx.get("search", "")
+ user = ctx.get("user")
+ csrf = generate_csrf_token()
+ cart_action = url_for("market.browse.product.cart", product_slug=slug)
+
+ # Like button overlay
+ like_html = ""
+ if user:
+ liked = p.get("is_liked", False)
+ like_html = _like_button_html(slug, liked, csrf, ctx)
+
+ # Image
+ image = p.get("image")
+ labels = p.get("labels", [])
+ brand = p.get("brand", "")
+ brand_highlight = " bg-yellow-200" if brand in selected_brands else ""
+
+ if image:
+ labels_html = "".join(
+ f' '
+ for l in labels
+ ) if callable(asset_url_fn) else ""
+ img_html = (
+ f''
+ f'
'
+ f'
'
+ f'{labels_html}
'
+ f'{escape(brand)} '
+ f' '
+ )
+ else:
+ labels_list = "".join(f"{l} " for l in labels)
+ img_html = (
+ f''
+ f'
'
+ f'
No image
'
+ f'
'
+ f'
{escape(brand)}
'
+ f'
'
+ )
+
+ price_html = _card_price_html(p)
+
+ # Cart button
+ quantity = sum(ci.quantity for ci in cart if ci.product.slug == slug) if cart else 0
+ cart_url_fn = ctx.get("cart_url")
+ add_html = _cart_add_html(slug, quantity, cart_action, csrf, cart_url_fn)
+
+ # Stickers
+ stickers = p.get("stickers", [])
+ stickers_html = ""
+ if stickers and callable(asset_url_fn):
+ sticker_parts = []
+ for s in stickers:
+ found = s in selected_stickers
+ src = asset_url_fn(f"stickers/{s}.svg")
+ sticker_parts.append(
+ f' '
+ )
+ stickers_html = '' + "".join(sticker_parts) + "
"
+
+ # Title with search highlight
+ title = p.get("title", "")
+ if search and search.lower() in title.lower():
+ idx = title.lower().index(search.lower())
+ highlighted = f"{escape(title[:idx])}{escape(title[idx:idx+len(search)])} {escape(title[idx+len(search):])}"
+ else:
+ highlighted = escape(title)
+
+ return (
+ f''
+ )
+
+
+def _like_button_html(slug: str, liked: bool, csrf: str, ctx: dict) -> str:
+ """Render the like/unlike heart button overlay."""
+ from quart import url_for
+
+ action = url_for("market.browse.product.like_toggle", product_slug=slug)
+ icon_cls = "fa-solid fa-heart text-red-500" if liked else "fa-regular fa-heart text-stone-400"
+ return (
+ f''
+ f'
'
+ f' '
+ f''
+ f' '
+ )
+
+
+# ---------------------------------------------------------------------------
+# Product cards (pagination fragment)
+# ---------------------------------------------------------------------------
+
+def _product_cards_html(ctx: dict) -> str:
+ """Render product cards with infinite scroll sentinels."""
+ from shared.utils import route_prefix
+
+ prefix = route_prefix()
+ products = ctx.get("products", [])
+ page = ctx.get("page", 1)
+ total_pages = ctx.get("total_pages", 1)
+ current_local_href = ctx.get("current_local_href", "/")
+ qs_fn = ctx.get("qs_filter")
+
+ parts = [_product_card_html(p, ctx) for p in products]
+
+ if page < total_pages:
+ # Build next page URL
+ if callable(qs_fn):
+ next_qs = qs_fn({"page": page + 1})
+ else:
+ next_qs = f"?page={page + 1}"
+ next_url = prefix + current_local_href + next_qs
+
+ # Mobile sentinel
+ parts.append(
+ f''
+ f'
loading...
'
+ f'
Retrying...
'
+ )
+
+ # Desktop sentinel
+ parts.append(
+ f''
+ f'
loading...
'
+ f'
Retrying...
'
+ )
+ else:
+ parts.append('End of results
')
+
+ return "".join(parts)
+
+
+# ---------------------------------------------------------------------------
+# Browse filter panels (mobile + desktop)
+# ---------------------------------------------------------------------------
+
+def _desktop_filter_html(ctx: dict) -> str:
+ """Build the desktop aside filter panel (search, category, sort, like, labels, stickers, brands)."""
+ from quart import url_for
+ from shared.utils import route_prefix
+
+ prefix = route_prefix()
+ category_label = ctx.get("category_label", "")
+ search = ctx.get("search", "")
+ search_count = ctx.get("search_count", "")
+ current_local_href = ctx.get("current_local_href", "/")
+ hx_select = ctx.get("hx_select", "#main-panel")
+ sort_options = ctx.get("sort_options", [])
+ sort = ctx.get("sort", "")
+ labels = ctx.get("labels", [])
+ selected_labels = ctx.get("selected_labels", [])
+ stickers = ctx.get("stickers", [])
+ selected_stickers = ctx.get("selected_stickers", [])
+ brands = ctx.get("brands", [])
+ selected_brands = ctx.get("selected_brands", [])
+ liked = ctx.get("liked", False)
+ liked_count = ctx.get("liked_count", 0)
+ subs_local = ctx.get("subs_local", [])
+ top_local_href = ctx.get("top_local_href", "")
+ sub_slug = ctx.get("sub_slug", "")
+ asset_url_fn = ctx.get("asset_url")
+
+ # Search
+ search_html = search_desktop_html(ctx)
+
+ # Category summary + sort + like + labels + stickers
+ parts = [search_html]
+ parts.append(f'')
+ parts.append(f'
')
+
+ # Sort stickers
+ if sort_options:
+ parts.append(_sort_stickers_html(sort_options, sort, ctx))
+
+ # Like + labels row
+ parts.append('
')
+ parts.append(_like_filter_html(liked, liked_count, ctx))
+ if labels:
+ parts.append(_labels_filter_html(labels, selected_labels, ctx, prefix="nav-labels"))
+ parts.append(" ")
+
+ # Stickers
+ if stickers:
+ parts.append(_stickers_filter_html(stickers, selected_stickers, ctx))
+
+ # Subcategory selector
+ if subs_local and top_local_href:
+ parts.append(_subcategory_selector_html(subs_local, top_local_href, sub_slug, ctx))
+
+ parts.append("
")
+
+ # Brand filter
+ parts.append(f'')
+ if brands:
+ parts.append(_brand_filter_html(brands, selected_brands, ctx))
+ parts.append("
")
+
+ return "".join(parts)
+
+
+def _mobile_filter_summary_html(ctx: dict) -> str:
+ """Build mobile filter summary (collapsible bar showing active filters)."""
+ # Simplified version — just the filter details/summary wrapper
+ asset_url_fn = ctx.get("asset_url")
+ search = ctx.get("search", "")
+ search_count = ctx.get("search_count", "")
+ current_local_href = ctx.get("current_local_href", "/")
+ hx_select = ctx.get("hx_select", "#main-panel")
+ sort = ctx.get("sort", "")
+ sort_options = ctx.get("sort_options", [])
+ liked = ctx.get("liked", False)
+ liked_count = ctx.get("liked_count", 0)
+ selected_labels = ctx.get("selected_labels", [])
+ selected_stickers = ctx.get("selected_stickers", [])
+ selected_brands = ctx.get("selected_brands", [])
+ labels = ctx.get("labels", [])
+ stickers = ctx.get("stickers", [])
+ brands = ctx.get("brands", [])
+
+ # Search bar
+ search_bar = search_mobile_html(ctx)
+
+ # Summary chips showing active filters
+ chip_parts = ['']
+
+ if sort and sort_options:
+ for k, l, i in sort_options:
+ if k == sort and callable(asset_url_fn):
+ chip_parts.append(f'
'
+ f' ')
+ if liked:
+ chip_parts.append('
'
+ f'
')
+ if liked_count is not None:
+ cls = "text-[10px] text-stone-500" if liked_count != 0 else "text-md text-red-500 font-bold"
+ chip_parts.append(f'
{liked_count}
')
+ chip_parts.append("
")
+
+ # Selected labels
+ if selected_labels:
+ chip_parts.append('
')
+ for sl in selected_labels:
+ for lb in labels:
+ if lb.get("name") == sl and callable(asset_url_fn):
+ chip_parts.append(f''
+ f' ')
+ if lb.get("count") is not None:
+ cls = "text-[10px] text-stone-500" if lb["count"] != 0 else "text-md text-red-500 font-bold"
+ chip_parts.append(f'{lb["count"]}
')
+ chip_parts.append(" ")
+ chip_parts.append(" ")
+
+ # Selected stickers
+ if selected_stickers:
+ chip_parts.append('
')
+ for ss in selected_stickers:
+ for st in stickers:
+ if st.get("name") == ss and callable(asset_url_fn):
+ chip_parts.append(f''
+ f' ')
+ if st.get("count") is not None:
+ cls = "text-[10px] text-stone-500" if st["count"] != 0 else "text-md text-red-500 font-bold"
+ chip_parts.append(f'{st["count"]}
')
+ chip_parts.append(" ")
+ chip_parts.append(" ")
+
+ # Selected brands
+ if selected_brands:
+ chip_parts.append('
')
+ for b in selected_brands:
+ count = 0
+ for br in brands:
+ if br.get("name") == b:
+ count = br.get("count", 0)
+ if count:
+ chip_parts.append(f'{escape(b)}
{count}
')
+ else:
+ chip_parts.append(f'{escape(b)}
0
')
+ chip_parts.append(" ")
+
+ chip_parts.append("
")
+ chips_html = "".join(chip_parts)
+
+ # Full mobile filter details
+ from shared.utils import route_prefix
+ prefix = route_prefix()
+ mobile_filter = _mobile_filter_content_html(ctx, prefix)
+
+ return (
+ f''
+ f''
+ f'{search_bar}'
+ f''
+ f'{chips_html}'
+ f'
'
+ f''
+ f'{mobile_filter}'
+ f'
'
+ )
+
+
+def _mobile_filter_content_html(ctx: dict, prefix: str) -> str:
+ """Build the expanded mobile filter panel contents."""
+ from shared.utils import route_prefix
+
+ search = ctx.get("search", "")
+ selected_labels = ctx.get("selected_labels", [])
+ selected_stickers = ctx.get("selected_stickers", [])
+ selected_brands = ctx.get("selected_brands", [])
+ current_local_href = ctx.get("current_local_href", "/")
+ hx_select = ctx.get("hx_select_search", "#main-panel")
+ sort_options = ctx.get("sort_options", [])
+ sort = ctx.get("sort", "")
+ liked = ctx.get("liked", False)
+ liked_count = ctx.get("liked_count", 0)
+ labels = ctx.get("labels", [])
+ stickers = ctx.get("stickers", [])
+ brands = ctx.get("brands", [])
+ asset_url_fn = ctx.get("asset_url")
+ qs_fn = ctx.get("qs_filter")
+
+ parts = []
+
+ # Sort options
+ if sort_options:
+ parts.append(_sort_stickers_html(sort_options, sort, ctx, mobile=True))
+
+ # Clear filters button
+ has_filters = search or selected_labels or selected_stickers or selected_brands
+ if has_filters and callable(qs_fn):
+ clear_url = prefix + current_local_href + qs_fn({"clear_filters": True})
+ parts.append(
+ f''
+ )
+
+ # Like + labels row
+ parts.append('')
+ parts.append(_like_filter_html(liked, liked_count, ctx, mobile=True))
+ if labels:
+ parts.append(_labels_filter_html(labels, selected_labels, ctx, prefix="nav-labels", mobile=True))
+ parts.append("
")
+
+ # Stickers
+ if stickers:
+ parts.append(_stickers_filter_html(stickers, selected_stickers, ctx, mobile=True))
+
+ # Brands
+ if brands:
+ parts.append(_brand_filter_html(brands, selected_brands, ctx, mobile=True))
+
+ return "".join(parts)
+
+
+def _sort_stickers_html(sort_options: list, current_sort: str, ctx: dict, mobile: bool = False) -> str:
+ """Render sort option stickers."""
+ asset_url_fn = ctx.get("asset_url")
+ current_local_href = ctx.get("current_local_href", "/")
+ hx_select = ctx.get("hx_select_search", "#main-panel")
+ qs_fn = ctx.get("qs_filter")
+ from shared.utils import route_prefix
+ prefix = route_prefix()
+
+ parts = ['']
+ for k, label, icon in sort_options:
+ if callable(qs_fn):
+ href = prefix + current_local_href + qs_fn({"sort": k})
+ else:
+ href = "#"
+ active = (k == current_sort)
+ ring = " ring-2 ring-emerald-500 rounded" if active else ""
+ src = asset_url_fn(icon) if callable(asset_url_fn) else icon
+ parts.append(
+ f'
'
+ f' '
+ f'{escape(label)} '
+ )
+ parts.append("
")
+ return "".join(parts)
+
+
+def _like_filter_html(liked: bool, liked_count: int, ctx: dict, mobile: bool = False) -> str:
+ """Render the like filter toggle."""
+ current_local_href = ctx.get("current_local_href", "/")
+ hx_select = ctx.get("hx_select_search", "#main-panel")
+ qs_fn = ctx.get("qs_filter")
+ from shared.utils import route_prefix
+ prefix = route_prefix()
+
+ if callable(qs_fn):
+ href = prefix + current_local_href + qs_fn({"liked": not liked})
+ else:
+ href = "#"
+
+ icon_cls = "fa-solid fa-heart text-red-500" if liked else "fa-regular fa-heart text-stone-400"
+ size = "text-[40px]" if mobile else "text-2xl"
+ return (
+ f''
+ f' '
+ )
+
+
+def _labels_filter_html(labels: list, selected: list, ctx: dict, *,
+ prefix: str = "nav-labels", mobile: bool = False) -> str:
+ """Render label filter buttons."""
+ asset_url_fn = ctx.get("asset_url")
+ current_local_href = ctx.get("current_local_href", "/")
+ hx_select = ctx.get("hx_select_search", "#main-panel")
+ qs_fn = ctx.get("qs_filter")
+ from shared.utils import route_prefix
+ rp = route_prefix()
+
+ parts = []
+ for lb in labels:
+ name = lb.get("name", "")
+ is_sel = name in selected
+ if callable(qs_fn):
+ new_sel = [s for s in selected if s != name] if is_sel else selected + [name]
+ href = rp + current_local_href + qs_fn({"labels": new_sel})
+ else:
+ href = "#"
+ ring = " ring-2 ring-emerald-500 rounded" if is_sel else ""
+ src = asset_url_fn(f"{prefix}/{name}.svg") if callable(asset_url_fn) else ""
+ parts.append(
+ f''
+ f' '
+ )
+ return "".join(parts)
+
+
+def _stickers_filter_html(stickers: list, selected: list, ctx: dict, mobile: bool = False) -> str:
+ """Render sticker filter grid."""
+ asset_url_fn = ctx.get("asset_url")
+ current_local_href = ctx.get("current_local_href", "/")
+ hx_select = ctx.get("hx_select_search", "#main-panel")
+ qs_fn = ctx.get("qs_filter")
+ from shared.utils import route_prefix
+ rp = route_prefix()
+
+ parts = ['']
+ for st in stickers:
+ name = st.get("name", "")
+ count = st.get("count", 0)
+ is_sel = name in selected
+ if callable(qs_fn):
+ new_sel = [s for s in selected if s != name] if is_sel else selected + [name]
+ href = rp + current_local_href + qs_fn({"stickers": new_sel})
+ else:
+ href = "#"
+ ring = " ring-2 ring-emerald-500 rounded" if is_sel else ""
+ src = asset_url_fn(f"stickers/{name}.svg") if callable(asset_url_fn) else ""
+ cls = "text-[10px] text-stone-500" if count != 0 else "text-md text-red-500 font-bold"
+ parts.append(
+ f'
'
+ f' '
+ f'{count} '
+ )
+ parts.append("
")
+ return "".join(parts)
+
+
+def _brand_filter_html(brands: list, selected: list, ctx: dict, mobile: bool = False) -> str:
+ """Render brand filter checkboxes."""
+ current_local_href = ctx.get("current_local_href", "/")
+ hx_select = ctx.get("hx_select_search", "#main-panel")
+ qs_fn = ctx.get("qs_filter")
+ from shared.utils import route_prefix
+ rp = route_prefix()
+
+ parts = ['']
+ for br in brands:
+ name = br.get("name", "")
+ count = br.get("count", 0)
+ is_sel = name in selected
+ if callable(qs_fn):
+ new_sel = [s for s in selected if s != name] if is_sel else selected + [name]
+ href = rp + current_local_href + qs_fn({"brands": new_sel})
+ else:
+ href = "#"
+ bg = " bg-yellow-200" if is_sel else ""
+ cls = "text-md" if count else "text-md text-red-500"
+ parts.append(
+ f'
'
+ f'{escape(name)}
'
+ f'{count}
'
+ )
+ parts.append("
")
+ return "".join(parts)
+
+
+def _subcategory_selector_html(subs: list, top_href: str, current_sub: str, ctx: dict) -> str:
+ """Render subcategory vertical nav."""
+ hx_select = ctx.get("hx_select_search", "#main-panel")
+ from shared.utils import route_prefix
+ rp = route_prefix()
+
+ parts = ['']
+ # "All" link
+ parts.append(
+ f'
All '
+ )
+ for sub in subs:
+ slug = sub.get("slug", "")
+ name = sub.get("name", "")
+ href = sub.get("href", "")
+ active = (slug == current_sub)
+ parts.append(
+ f'
{escape(name)} '
+ )
+ parts.append("
")
+ return "".join(parts)
+
+
+# ---------------------------------------------------------------------------
+# Product detail page content
+# ---------------------------------------------------------------------------
+
+def _product_detail_html(d: dict, ctx: dict) -> str:
+ """Build product detail main panel content."""
+ from quart import url_for
+ from shared.browser.app.csrf import generate_csrf_token
+
+ asset_url_fn = ctx.get("asset_url")
+ user = ctx.get("user")
+ liked_by_current_user = ctx.get("liked_by_current_user", False)
+ csrf = generate_csrf_token()
+
+ images = d.get("images", [])
+ labels = d.get("labels", [])
+ stickers = d.get("stickers", [])
+ brand = d.get("brand", "")
+ slug = d.get("slug", "")
+
+ # Gallery
+ if images:
+ # Like button
+ like_html = ""
+ if user:
+ like_html = _like_button_html(slug, liked_by_current_user, csrf, ctx)
+
+ # Main image + labels
+ labels_overlay = "".join(
+ f' '
+ for l in labels
+ ) if callable(asset_url_fn) else ""
+
+ gallery_html = (
+ f''
+ f'{like_html}'
+ f'
'
+ f'
'
+ f'{labels_overlay}
'
+ f'{escape(brand)} '
+ )
+
+ # Prev/next buttons
+ if len(images) > 1:
+ gallery_html += (
+ '
‹ '
+ '
› '
+ )
+
+ gallery_html += "
"
+
+ # Thumbnails
+ if len(images) > 1:
+ thumbs = "".join(
+ f''
+ f' '
+ f' '
+ for i, u in enumerate(images)
+ )
+ gallery_html += f''
+ else:
+ like_html = ""
+ if user:
+ like_html = _like_button_html(slug, liked_by_current_user, csrf, ctx)
+ gallery_html = (
+ f''
+ f'{like_html}No image
'
+ )
+
+ # Stickers below gallery
+ stickers_html = ""
+ if stickers and callable(asset_url_fn):
+ sticker_parts = "".join(
+ f' '
+ for s in stickers
+ )
+ stickers_html = f'{sticker_parts}
'
+
+ # Right column: prices, description, sections
+ pr = _set_prices(d)
+ details_parts = ['']
+
+ # Unit price / case size extras
+ extras = []
+ ppu = d.get("price_per_unit") or d.get("price_per_unit_raw")
+ if ppu:
+ extras.append(f'
Unit price: {_price_str(d.get("price_per_unit"), d.get("price_per_unit_raw"), d.get("price_per_unit_currency"))}
')
+ if d.get("case_size_raw"):
+ extras.append(f'
Case size: {d["case_size_raw"]}
')
+ if extras:
+ details_parts.append('
' + "".join(extras) + "
")
+
+ # Description
+ desc_short = d.get("description_short")
+ desc_html = d.get("description_html")
+ if desc_short or desc_html:
+ details_parts.append('
')
+ if desc_short:
+ details_parts.append(f'
{escape(desc_short)}
')
+ if desc_html:
+ details_parts.append(f'
{desc_html}
')
+ details_parts.append("
")
+
+ # Sections (expandable)
+ sections = d.get("sections", [])
+ if sections:
+ details_parts.append('
')
+ for sec in sections:
+ details_parts.append(
+ f'
'
+ f''
+ f'{escape(sec.get("title", ""))} '
+ f'⌄ '
+ f'{sec.get("html", "")}
'
+ )
+ details_parts.append("
")
+
+ details_parts.append("
")
+
+ return (
+ f''
+ f'
{gallery_html}{stickers_html}
'
+ f'{"".join(details_parts)}
'
+ )
+
+
+# ---------------------------------------------------------------------------
+# Product meta (OpenGraph, JSON-LD)
+# ---------------------------------------------------------------------------
+
+def _product_meta_html(d: dict, ctx: dict) -> str:
+ """Build product meta tags for ."""
+ import json
+ from quart import request
+
+ title = d.get("title", "")
+ desc_source = d.get("description_short") or ""
+ if not desc_source and d.get("description_html"):
+ # Strip HTML tags (simple approach)
+ import re
+ desc_source = re.sub(r"<[^>]+>", "", d.get("description_html", ""))
+ description = desc_source.strip().replace("\n", " ")[:160]
+ image_url = d.get("image") or (d.get("images", [None])[0] if d.get("images") else None)
+ canonical = request.url if request else ""
+ brand = d.get("brand", "")
+ sku = d.get("sku", "")
+ price = d.get("special_price") or d.get("regular_price") or d.get("rrp")
+ price_currency = d.get("special_price_currency") or d.get("regular_price_currency") or d.get("rrp_currency")
+
+ parts = [f"{escape(title)} "]
+ parts.append(f' ')
+ if canonical:
+ parts.append(f' ')
+
+ # OpenGraph
+ site_title = ctx.get("base_title", "")
+ parts.append(f' ')
+ parts.append(' ')
+ parts.append(f' ')
+ parts.append(f' ')
+ if canonical:
+ parts.append(f' ')
+ if image_url:
+ parts.append(f' ')
+ if price and price_currency:
+ parts.append(f' ')
+ parts.append(f' ')
+ if brand:
+ parts.append(f' ')
+
+ # Twitter
+ card_type = "summary_large_image" if image_url else "summary"
+ parts.append(f' ')
+ parts.append(f' ')
+ parts.append(f' ')
+ if image_url:
+ parts.append(f' ')
+
+ # JSON-LD
+ jsonld = {
+ "@context": "https://schema.org",
+ "@type": "Product",
+ "name": title,
+ "image": image_url,
+ "description": description,
+ "sku": sku,
+ "url": canonical,
+ }
+ if brand:
+ jsonld["brand"] = {"@type": "Brand", "name": brand}
+ if price and price_currency:
+ jsonld["offers"] = {
+ "@type": "Offer",
+ "price": price,
+ "priceCurrency": price_currency,
+ "url": canonical,
+ "availability": "https://schema.org/InStock",
+ }
+ parts.append(f'')
+
+ return "\n".join(parts)
+
+
+# ---------------------------------------------------------------------------
+# Market cards (all markets / page markets)
+# ---------------------------------------------------------------------------
+
+def _market_card_html(market: Any, page_info: dict, *, show_page_badge: bool = True,
+ post_slug: str = "") -> str:
+ """Render a single market card."""
+ from shared.infrastructure.urls import market_url
+
+ name = getattr(market, "name", "")
+ description = getattr(market, "description", "")
+ slug = getattr(market, "slug", "")
+ container_id = getattr(market, "container_id", None)
+
+ if show_page_badge and page_info:
+ pi = page_info.get(container_id, {})
+ p_slug = pi.get("slug", "")
+ p_title = pi.get("title", "")
+ market_href = market_url(f"/{p_slug}/{slug}/") if p_slug else ""
+ else:
+ p_slug = post_slug
+ p_title = ""
+ market_href = market_url(f"/{post_slug}/{slug}/") if post_slug else ""
+
+ parts = ['']
+ parts.append("")
+ if market_href:
+ parts.append(f'
{escape(name)} ')
+ else:
+ parts.append(f'
{escape(name)} ')
+ if description:
+ parts.append(f'
{escape(description)}
')
+ parts.append("
")
+
+ if show_page_badge and p_title:
+ badge_href = market_url(f"/{p_slug}/")
+ parts.append(
+ f''
+ )
+
+ parts.append(" ")
+ return "".join(parts)
+
+
+def _market_cards_html(markets: list, page_info: dict, page: int, has_more: bool,
+ next_url: str, *, show_page_badge: bool = True,
+ post_slug: str = "") -> str:
+ """Render market cards with infinite scroll sentinel."""
+ parts = [_market_card_html(m, page_info, show_page_badge=show_page_badge,
+ post_slug=post_slug) for m in markets]
+ if has_more:
+ parts.append(
+ f''
+ )
+ return "".join(parts)
+
+
+# ---------------------------------------------------------------------------
+# OOB header helpers
+# ---------------------------------------------------------------------------
+
+def _oob_header_html(parent_id: str, child_id: str, row_html: str) -> str:
+ """Wrap a header row in OOB div with child placeholder."""
+ return (
+ f''
+ )
+
+
+# ===========================================================================
+# PUBLIC API
+# ===========================================================================
+
+
+# ---------------------------------------------------------------------------
+# All markets
+# ---------------------------------------------------------------------------
+
+async def render_all_markets_page(ctx: dict, markets: list, has_more: bool,
+ page_info: dict, page: int) -> str:
+ """Full page: all markets listing."""
+ from quart import url_for
+ from shared.utils import route_prefix
+
+ prefix = route_prefix()
+ next_url = prefix + url_for("all_markets.markets_fragment", page=page + 1)
+
+ if markets:
+ cards = _market_cards_html(markets, page_info, page, has_more, next_url)
+ content = f'{cards}
'
+ else:
+ content = (''
+ '
'
+ '
No markets available
')
+ content += '
'
+
+ hdr = root_header_html(ctx)
+ return full_page(ctx, header_rows_html=hdr, content_html=content)
+
+
+async def render_all_markets_oob(ctx: dict, markets: list, has_more: bool,
+ page_info: dict, page: int) -> str:
+ """OOB response: all markets listing."""
+ from quart import url_for
+ from shared.utils import route_prefix
+
+ prefix = route_prefix()
+ next_url = prefix + url_for("all_markets.markets_fragment", page=page + 1)
+
+ if markets:
+ cards = _market_cards_html(markets, page_info, page, has_more, next_url)
+ content = f'{cards}
'
+ else:
+ content = (''
+ '
'
+ '
No markets available
')
+ content += '
'
+
+ oobs = root_header_html(ctx, oob=True)
+ return oob_page(ctx, oobs_html=oobs, content_html=content)
+
+
+async def render_all_markets_cards(markets: list, has_more: bool,
+ page_info: dict, page: int) -> str:
+ """Pagination fragment: all markets cards."""
+ from quart import url_for
+ from shared.utils import route_prefix
+
+ prefix = route_prefix()
+ next_url = prefix + url_for("all_markets.markets_fragment", page=page + 1)
+ return _market_cards_html(markets, page_info, page, has_more, next_url)
+
+
+# ---------------------------------------------------------------------------
+# Page markets
+# ---------------------------------------------------------------------------
+
+async def render_page_markets_page(ctx: dict, markets: list, has_more: bool,
+ page: int) -> str:
+ """Full page: page-scoped markets listing."""
+ from quart import url_for
+ from shared.utils import route_prefix
+
+ prefix = route_prefix()
+ post = ctx.get("post", {})
+ post_slug = post.get("slug", "")
+ next_url = prefix + url_for("page_markets.markets_fragment", page=page + 1)
+
+ if markets:
+ cards = _market_cards_html(markets, {}, page, has_more, next_url,
+ show_page_badge=False, post_slug=post_slug)
+ content = f'{cards}
'
+ else:
+ content = (''
+ '
'
+ '
No markets for this page
')
+ content += '
'
+
+ hdr = root_header_html(ctx)
+ hdr += sexp(
+ '(div :id "root-header-child" :class "w-full" (raw! ph))',
+ ph=_post_header_html(ctx),
+ )
+ return full_page(ctx, header_rows_html=hdr, content_html=content)
+
+
+async def render_page_markets_oob(ctx: dict, markets: list, has_more: bool,
+ page: int) -> str:
+ """OOB response: page-scoped markets."""
+ from quart import url_for
+ from shared.utils import route_prefix
+
+ prefix = route_prefix()
+ post = ctx.get("post", {})
+ post_slug = post.get("slug", "")
+ next_url = prefix + url_for("page_markets.markets_fragment", page=page + 1)
+
+ if markets:
+ cards = _market_cards_html(markets, {}, page, has_more, next_url,
+ show_page_badge=False, post_slug=post_slug)
+ content = f'{cards}
'
+ else:
+ content = (''
+ '
'
+ '
No markets for this page
')
+ content += '
'
+
+ oobs = _oob_header_html("post-header-child", "market-header-child", "")
+ oobs += _post_header_html(ctx, oob=True)
+ return oob_page(ctx, oobs_html=oobs, content_html=content)
+
+
+async def render_page_markets_cards(markets: list, has_more: bool,
+ page: int, post_slug: str) -> str:
+ """Pagination fragment: page-scoped markets cards."""
+ from quart import url_for
+ from shared.utils import route_prefix
+
+ prefix = route_prefix()
+ next_url = prefix + url_for("page_markets.markets_fragment", page=page + 1)
+ return _market_cards_html(markets, {}, page, has_more, next_url,
+ show_page_badge=False, post_slug=post_slug)
+
+
+# ---------------------------------------------------------------------------
+# Market landing page
+# ---------------------------------------------------------------------------
+
+async def render_market_home_page(ctx: dict) -> str:
+ """Full page: market landing page (post content)."""
+ post = ctx.get("post") or {}
+ content = _market_landing_content(post)
+
+ hdr = root_header_html(ctx)
+ hdr += sexp(
+ '(div :id "root-header-child" :class "w-full" (raw! ph (raw! mh)))',
+ ph=_post_header_html(ctx),
+ mh=_market_header_html(ctx),
+ )
+ menu = _mobile_nav_panel_html(ctx)
+ return full_page(ctx, header_rows_html=hdr, content_html=content, menu_html=menu)
+
+
+async def render_market_home_oob(ctx: dict) -> str:
+ """OOB response: market landing page."""
+ post = ctx.get("post") or {}
+ content = _market_landing_content(post)
+
+ oobs = _oob_header_html("post-header-child", "market-header-child",
+ _market_header_html(ctx))
+ oobs += _post_header_html(ctx, oob=True)
+ menu = _mobile_nav_panel_html(ctx)
+ return oob_page(ctx, oobs_html=oobs, content_html=content, menu_html=menu)
+
+
+def _market_landing_content(post: dict) -> str:
+ """Build market landing page content (excerpt + feature image + html)."""
+ parts = ['']
+ if post.get("custom_excerpt"):
+ parts.append(f'{post["custom_excerpt"]}
')
+ if post.get("feature_image"):
+ parts.append(
+ f''
+ f'
'
+ )
+ if post.get("html"):
+ parts.append(f'{post["html"]}
')
+ parts.append('
')
+ return "".join(parts)
+
+
+# ---------------------------------------------------------------------------
+# Browse page
+# ---------------------------------------------------------------------------
+
+async def render_browse_page(ctx: dict) -> str:
+ """Full page: product browse with filters."""
+ cards_html = _product_cards_html(ctx)
+ content = f'{cards_html}
'
+
+ hdr = root_header_html(ctx)
+ hdr += sexp(
+ '(div :id "root-header-child" :class "w-full" (raw! ph (raw! mh)))',
+ ph=_post_header_html(ctx),
+ mh=_market_header_html(ctx),
+ )
+ menu = _mobile_nav_panel_html(ctx)
+ filter_html = _mobile_filter_summary_html(ctx)
+ aside_html = _desktop_filter_html(ctx)
+
+ return full_page(ctx, header_rows_html=hdr, content_html=content,
+ menu_html=menu, filter_html=filter_html, aside_html=aside_html)
+
+
+async def render_browse_oob(ctx: dict) -> str:
+ """OOB response: product browse."""
+ cards_html = _product_cards_html(ctx)
+ content = f'{cards_html}
'
+
+ oobs = _oob_header_html("post-header-child", "market-header-child",
+ _market_header_html(ctx))
+ oobs += _post_header_html(ctx, oob=True)
+ menu = _mobile_nav_panel_html(ctx)
+ filter_html = _mobile_filter_summary_html(ctx)
+ aside_html = _desktop_filter_html(ctx)
+
+ return oob_page(ctx, oobs_html=oobs, content_html=content,
+ menu_html=menu, filter_html=filter_html, aside_html=aside_html)
+
+
+async def render_browse_cards(ctx: dict) -> str:
+ """Pagination fragment: product cards only."""
+ return _product_cards_html(ctx)
+
+
+# ---------------------------------------------------------------------------
+# Product detail
+# ---------------------------------------------------------------------------
+
+async def render_product_page(ctx: dict, d: dict) -> str:
+ """Full page: product detail."""
+ content = _product_detail_html(d, ctx)
+ meta = _product_meta_html(d, ctx)
+
+ hdr = root_header_html(ctx)
+ hdr += sexp(
+ '(div :id "root-header-child" :class "w-full" (raw! ph (raw! mh (raw! prh))))',
+ ph=_post_header_html(ctx),
+ mh=_market_header_html(ctx),
+ prh=_product_header_html(ctx, d),
+ )
+ return full_page(ctx, header_rows_html=hdr, content_html=content, meta_html=meta)
+
+
+async def render_product_oob(ctx: dict, d: dict) -> str:
+ """OOB response: product detail."""
+ content = _product_detail_html(d, ctx)
+
+ oobs = _market_header_html(ctx, oob=True)
+ oobs += _oob_header_html("market-header-child", "product-header-child",
+ _product_header_html(ctx, d))
+ menu = _mobile_nav_panel_html(ctx)
+ return oob_page(ctx, oobs_html=oobs, content_html=content, menu_html=menu)
+
+
+# ---------------------------------------------------------------------------
+# Product admin
+# ---------------------------------------------------------------------------
+
+async def render_product_admin_page(ctx: dict, d: dict) -> str:
+ """Full page: product admin."""
+ content = _product_detail_html(d, ctx)
+
+ hdr = root_header_html(ctx)
+ hdr += sexp(
+ '(div :id "root-header-child" :class "w-full" (raw! ph (raw! mh (raw! prh (raw! pah)))))',
+ ph=_post_header_html(ctx),
+ mh=_market_header_html(ctx),
+ prh=_product_header_html(ctx, d),
+ pah=_product_admin_header_html(ctx, d),
+ )
+ return full_page(ctx, header_rows_html=hdr, content_html=content)
+
+
+async def render_product_admin_oob(ctx: dict, d: dict) -> str:
+ """OOB response: product admin."""
+ content = _product_detail_html(d, ctx)
+
+ oobs = _product_header_html(ctx, d, oob=True)
+ oobs += _oob_header_html("product-header-child", "product-admin-header-child",
+ _product_admin_header_html(ctx, d))
+ return oob_page(ctx, oobs_html=oobs, content_html=content)
+
+
+def _product_admin_header_html(ctx: dict, d: dict, *, oob: bool = False) -> str:
+ """Build product admin header row."""
+ from quart import url_for
+
+ slug = d.get("slug", "")
+ link_href = url_for("market.browse.product.admin", product_slug=slug)
+ return sexp(
+ '(~menu-row :id "product-admin-row" :level 4'
+ ' :link-href lh :link-label "admin!!" :icon "fa fa-cog"'
+ ' :child-id "product-admin-header-child" :oob oob)',
+ lh=link_href,
+ oob=oob,
+ )
+
+
+# ---------------------------------------------------------------------------
+# Market admin
+# ---------------------------------------------------------------------------
+
+async def render_market_admin_page(ctx: dict) -> str:
+ """Full page: market admin."""
+ content = "market admin"
+
+ hdr = root_header_html(ctx)
+ hdr += sexp(
+ '(div :id "root-header-child" :class "w-full" (raw! ph (raw! mh (raw! mah))))',
+ ph=_post_header_html(ctx),
+ mh=_market_header_html(ctx),
+ mah=_market_admin_header_html(ctx),
+ )
+ return full_page(ctx, header_rows_html=hdr, content_html=content)
+
+
+async def render_market_admin_oob(ctx: dict) -> str:
+ """OOB response: market admin."""
+ content = "market admin"
+
+ oobs = _market_header_html(ctx, oob=True)
+ oobs += _oob_header_html("market-header-child", "market-admin-header-child",
+ _market_admin_header_html(ctx))
+ return oob_page(ctx, oobs_html=oobs, content_html=content)
+
+
+def _market_admin_header_html(ctx: dict, *, oob: bool = False) -> str:
+ """Build market admin header row."""
+ from quart import url_for
+
+ link_href = url_for("market.admin.admin")
+ return sexp(
+ '(~menu-row :id "market-admin-row" :level 3'
+ ' :link-href lh :link-label "admin" :icon "fa fa-cog"'
+ ' :child-id "market-admin-header-child" :oob oob)',
+ lh=link_href,
+ oob=oob,
+ )
diff --git a/orders/app.py b/orders/app.py
index 875255c..b68da41 100644
--- a/orders/app.py
+++ b/orders/app.py
@@ -1,5 +1,6 @@
from __future__ import annotations
import path_setup # noqa: F401 # adds shared/ to sys.path
+import sexp_components # noqa: F401 # ensure Hypercorn --reload watches this file
from pathlib import Path
from types import SimpleNamespace
@@ -69,6 +70,10 @@ def create_app() -> "Quart":
app.jinja_loader,
])
+ # Load orders-specific s-expression components
+ from sexp_components import load_orders_components
+ load_orders_components()
+
app.register_blueprint(register_fragments())
app.register_blueprint(register_actions())
app.register_blueprint(register_data())
diff --git a/orders/bp/order/routes.py b/orders/bp/order/routes.py
index c84f85f..56e2a9a 100644
--- a/orders/bp/order/routes.py
+++ b/orders/bp/order/routes.py
@@ -9,6 +9,7 @@ from shared.browser.app.payments.sumup import create_checkout as sumup_create_ch
from shared.config import config
from shared.infrastructure.cart_identity import current_cart_identity
+from shared.sexp.page import get_template_context
from services.check_sumup_status import check_sumup_status
from shared.browser.app.utils.htmx import is_htmx_request
@@ -46,10 +47,16 @@ def register() -> Blueprint:
order = result.scalar_one_or_none()
if not order:
return await make_response("Order not found", 404)
+
+ from sexp_components import render_order_page, render_order_oob
+
+ ctx = await get_template_context()
+ calendar_entries = ctx.get("calendar_entries")
+
if not is_htmx_request():
- html = await render_template("_types/order/index.html", order=order)
+ html = await render_order_page(ctx, order, calendar_entries, url_for)
else:
- html = await render_template("_types/order/_oob_elements.html", order=order)
+ html = await render_order_oob(ctx, order, calendar_entries, url_for)
return await make_response(html)
@bp.get("/pay/")
diff --git a/orders/bp/orders/routes.py b/orders/bp/orders/routes.py
index 5cc33e0..7841975 100644
--- a/orders/bp/orders/routes.py
+++ b/orders/bp/orders/routes.py
@@ -116,20 +116,30 @@ def register(url_prefix: str) -> Blueprint:
result = await g.s.execute(stmt)
orders = result.scalars().all()
- context = {
- "orders": orders,
- "page": page,
- "total_pages": total_pages,
- "search": search,
- "search_count": total_count,
- }
+ from shared.sexp.page import get_template_context
+ from sexp_components import (
+ render_orders_page,
+ render_orders_rows,
+ render_orders_oob,
+ )
+
+ ctx = await get_template_context()
+ qs_fn = makeqs_factory()
if not is_htmx_request():
- html = await render_template("_types/orders/index.html", **context)
+ html = await render_orders_page(
+ ctx, orders, page, total_pages, search, total_count,
+ url_for, qs_fn,
+ )
elif page > 1:
- html = await render_template("_types/orders/_rows.html", **context)
+ html = await render_orders_rows(
+ ctx, orders, page, total_pages, url_for, qs_fn,
+ )
else:
- html = await render_template("_types/orders/_oob_elements.html", **context)
+ html = await render_orders_oob(
+ ctx, orders, page, total_pages, search, total_count,
+ url_for, qs_fn,
+ )
resp = await make_response(html)
resp.headers["Hx-Push-Url"] = _current_url_without_page()
diff --git a/orders/sexp_components.py b/orders/sexp_components.py
new file mode 100644
index 0000000..212bd34
--- /dev/null
+++ b/orders/sexp_components.py
@@ -0,0 +1,385 @@
+"""
+Orders service s-expression page components.
+
+Each function renders a complete page section (full page, OOB, or pagination)
+using shared s-expression components. Called from route handlers in place
+of ``render_template()``.
+"""
+from __future__ import annotations
+
+from typing import Any
+
+from shared.sexp.jinja_bridge import sexp, register_components
+from shared.sexp.helpers import (
+ call_url, get_asset_url, root_header_html,
+ search_mobile_html, search_desktop_html, full_page, oob_page,
+)
+from shared.sexp.page import HAMBURGER_HTML
+from shared.infrastructure.urls import market_product_url
+
+
+# ---------------------------------------------------------------------------
+# Service-specific component definitions
+# ---------------------------------------------------------------------------
+
+def load_orders_components() -> None:
+ """Register orders-specific s-expression components (placeholder for future)."""
+ pass
+
+
+# ---------------------------------------------------------------------------
+# Header helpers (shared auth + orders-specific)
+# ---------------------------------------------------------------------------
+
+def _auth_nav_html(ctx: dict) -> str:
+ """Auth section desktop nav items."""
+ html = sexp(
+ '(~nav-link :href h :label "newsletters" :select-colours sc)',
+ h=call_url(ctx, "account_url", "/newsletters/"),
+ sc=ctx.get("select_colours", ""),
+ )
+ account_nav_html = ctx.get("account_nav_html", "")
+ if account_nav_html:
+ html += account_nav_html
+ return html
+
+
+def _auth_header_html(ctx: dict, *, oob: bool = False) -> str:
+ """Build the account section header row."""
+ return sexp(
+ '(~menu-row :id "auth-row" :level 1 :colour "sky"'
+ ' :link-href lh :link-label "account" :icon "fa-solid fa-user"'
+ ' :nav-html nh :child-id "auth-header-child" :oob oob)',
+ lh=call_url(ctx, "account_url", "/"),
+ nh=_auth_nav_html(ctx),
+ oob=oob,
+ )
+
+
+def _orders_header_html(ctx: dict, list_url: str) -> str:
+ """Build the orders section header row."""
+ return sexp(
+ '(~menu-row :id "orders-row" :level 2 :colour "sky"'
+ ' :link-href lh :link-label "Orders" :icon "fa fa-gbp"'
+ ' :child-id "orders-header-child")',
+ lh=list_url,
+ )
+
+
+# ---------------------------------------------------------------------------
+# Orders list rendering
+# ---------------------------------------------------------------------------
+
+def _order_row_html(order: Any, detail_url: str) -> str:
+ """Render a single order as desktop table row + mobile card."""
+ status = order.status or "pending"
+ sl = status.lower()
+ pill = (
+ "border-emerald-300 bg-emerald-50 text-emerald-700" if sl == "paid"
+ else "border-rose-300 bg-rose-50 text-rose-700" if sl in ("failed", "cancelled")
+ else "border-stone-300 bg-stone-50 text-stone-700"
+ )
+ created = order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else "\u2014"
+ total = f"{order.currency or 'GBP'} {order.total_amount or 0:.2f}"
+
+ return (
+ # Desktop row
+ f''
+ f'#{order.id} '
+ f'{created} '
+ f'{order.description or ""} '
+ f'{total} '
+ f'{status} '
+ f'View '
+ # Mobile row
+ f''
+ f'
#{order.id} '
+ f'{status}
'
+ f'
{created}
'
+ f'
'
+ )
+
+
+def _orders_rows_html(orders: list, page: int, total_pages: int,
+ url_for_fn: Any, qs_fn: Any) -> str:
+ """Render order rows + infinite scroll sentinel."""
+ from shared.utils import route_prefix
+ pfx = route_prefix()
+
+ parts = [
+ _order_row_html(o, pfx + url_for_fn("orders.order.order_detail", order_id=o.id))
+ for o in orders
+ ]
+
+ if page < total_pages:
+ next_url = pfx + url_for_fn("orders.list_orders") + qs_fn(page=page + 1)
+ parts.append(sexp(
+ '(~infinite-scroll :url u :page p :total-pages tp :id-prefix "orders" :colspan 5)',
+ u=next_url, p=page, **{"total-pages": total_pages},
+ ))
+ else:
+ parts.append('End of results ')
+
+ return "".join(parts)
+
+
+def _orders_main_panel_html(orders: list, rows_html: str) -> str:
+ """Main panel with table or empty state."""
+ if not orders:
+ return (
+ ''
+ )
+ return (
+ ''
+ '
'
+ '
'
+ ''
+ 'Order '
+ 'Created '
+ 'Description '
+ 'Total '
+ 'Status '
+ ' '
+ f' {rows_html}
'
+ )
+
+
+def _orders_summary_html(ctx: dict) -> str:
+ """Filter section for orders list."""
+ return (
+ ''
+ )
+
+
+# ---------------------------------------------------------------------------
+# Public API: orders list
+# ---------------------------------------------------------------------------
+
+async def render_orders_page(ctx: dict, orders: list, page: int,
+ total_pages: int, search: str | None,
+ search_count: int, url_for_fn: Any,
+ qs_fn: Any) -> str:
+ """Full page: orders list."""
+ from shared.utils import route_prefix
+
+ ctx["search"] = search
+ ctx["search_count"] = search_count
+ list_url = route_prefix() + url_for_fn("orders.list_orders")
+
+ rows = _orders_rows_html(orders, page, total_pages, url_for_fn, qs_fn)
+ main = _orders_main_panel_html(orders, rows)
+
+ hdr = root_header_html(ctx)
+ hdr += sexp(
+ '(div :id "root-header-child" :class "flex flex-col w-full items-center" (raw! a) (raw! o))',
+ a=_auth_header_html(ctx), o=_orders_header_html(ctx, list_url),
+ )
+
+ return full_page(ctx, header_rows_html=hdr,
+ filter_html=_orders_summary_html(ctx),
+ aside_html=search_desktop_html(ctx),
+ content_html=main)
+
+
+async def render_orders_rows(ctx: dict, orders: list, page: int,
+ total_pages: int, url_for_fn: Any,
+ qs_fn: Any) -> str:
+ """Pagination: just the table rows."""
+ return _orders_rows_html(orders, page, total_pages, url_for_fn, qs_fn)
+
+
+async def render_orders_oob(ctx: dict, orders: list, page: int,
+ total_pages: int, search: str | None,
+ search_count: int, url_for_fn: Any,
+ qs_fn: Any) -> str:
+ """OOB response for HTMX navigation to orders list."""
+ from shared.utils import route_prefix
+
+ ctx["search"] = search
+ ctx["search_count"] = search_count
+ list_url = route_prefix() + url_for_fn("orders.list_orders")
+
+ rows = _orders_rows_html(orders, page, total_pages, url_for_fn, qs_fn)
+ main = _orders_main_panel_html(orders, rows)
+
+ oobs = (
+ _auth_header_html(ctx, oob=True)
+ + sexp(
+ '(div :id "auth-header-child" :hx-swap-oob "outerHTML"'
+ ' :class "flex flex-col w-full items-center" (raw! o))',
+ o=_orders_header_html(ctx, list_url),
+ )
+ + root_header_html(ctx, oob=True)
+ )
+
+ return oob_page(ctx, oobs_html=oobs,
+ filter_html=_orders_summary_html(ctx),
+ aside_html=search_desktop_html(ctx),
+ content_html=main)
+
+
+# ---------------------------------------------------------------------------
+# Single order detail
+# ---------------------------------------------------------------------------
+
+def _order_items_html(order: Any) -> str:
+ """Render order items list."""
+ if not order or not order.items:
+ return ""
+ items = []
+ for item in order.items:
+ prod_url = market_product_url(item.product_slug)
+ img = (
+ f' '
+ if item.product_image else
+ 'No image
'
+ )
+ items.append(
+ f''
+ f'{img}
'
+ f''
+ f'
{item.product_title or "Unknown product"}
'
+ f'
Product ID: {item.product_id}
'
+ f'
Qty: {item.quantity}
'
+ f'
{item.currency or order.currency or "GBP"} {item.unit_price or 0:.2f}
'
+ f'
'
+ )
+ return (
+ ''
+ )
+
+
+def _calendar_items_html(calendar_entries: list | None) -> str:
+ """Render calendar bookings for an order."""
+ if not calendar_entries:
+ return ""
+ items = []
+ for e in calendar_entries:
+ st = e.state or ""
+ pill = (
+ "bg-emerald-100 text-emerald-800" if st == "confirmed"
+ else "bg-amber-100 text-amber-800" if st == "provisional"
+ else "bg-blue-100 text-blue-800" if st == "ordered"
+ else "bg-stone-100 text-stone-700"
+ )
+ ds = e.start_at.strftime("%-d %b %Y, %H:%M") if e.start_at else ""
+ if e.end_at:
+ ds += f" \u2013 {e.end_at.strftime('%-d %b %Y, %H:%M')}"
+ items.append(
+ f''
+ f'{e.name}'
+ f''
+ f'{st.capitalize()}
'
+ f'
{ds}
'
+ f'\u00a3{e.cost or 0:.2f}
'
+ )
+ return (
+ ''
+ 'Calendar bookings in this order '
+ f' '
+ )
+
+
+def _order_main_html(order: Any, calendar_entries: list | None) -> str:
+ """Main panel for single order detail."""
+ summary = sexp(
+ '(~order-summary-card :order-id oid :created-at ca :description d :status s :currency c :total-amount ta)',
+ oid=order.id,
+ ca=order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else None,
+ d=order.description, s=order.status, c=order.currency,
+ ta=f"{order.total_amount:.2f}" if order.total_amount else None,
+ )
+ return f'{summary}{_order_items_html(order)}{_calendar_items_html(calendar_entries)}
'
+
+
+def _order_filter_html(order: Any, list_url: str, recheck_url: str,
+ pay_url: str, csrf_token: str) -> str:
+ """Filter section for single order detail."""
+ created = order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else "\u2014"
+ status = order.status or "pending"
+ pay = (
+ f''
+ f' Open payment page '
+ ) if status != "paid" else ""
+
+ return (
+ ''
+ )
+
+
+async def render_order_page(ctx: dict, order: Any,
+ calendar_entries: list | None,
+ url_for_fn: Any) -> str:
+ """Full page: single order detail."""
+ from shared.utils import route_prefix
+ from shared.browser.app.csrf import generate_csrf_token
+
+ pfx = route_prefix()
+ detail_url = pfx + url_for_fn("orders.order.order_detail", order_id=order.id)
+ list_url = pfx + url_for_fn("orders.list_orders")
+ recheck_url = pfx + url_for_fn("orders.order.order_recheck", order_id=order.id)
+ pay_url = pfx + url_for_fn("orders.order.order_pay", order_id=order.id)
+
+ main = _order_main_html(order, calendar_entries)
+ filt = _order_filter_html(order, list_url, recheck_url, pay_url, generate_csrf_token())
+
+ # Header stack: root -> auth -> orders -> order
+ hdr = root_header_html(ctx)
+ order_row = sexp(
+ '(~menu-row :id "order-row" :level 3 :colour "sky" :link-href lh :link-label "Order" :icon "fa fa-gbp")',
+ lh=detail_url,
+ )
+ hdr += sexp(
+ '(div :id "root-header-child" :class "flex flex-col w-full items-center" (raw! a)'
+ ' (div :id "auth-header-child" :class "flex flex-col w-full items-center" (raw! b)'
+ ' (div :id "orders-header-child" :class "flex flex-col w-full items-center" (raw! c))))',
+ a=_auth_header_html(ctx),
+ b=_orders_header_html(ctx, list_url),
+ c=order_row,
+ )
+
+ return full_page(ctx, header_rows_html=hdr, filter_html=filt, content_html=main)
+
+
+async def render_order_oob(ctx: dict, order: Any,
+ calendar_entries: list | None,
+ url_for_fn: Any) -> str:
+ """OOB response for single order detail."""
+ from shared.utils import route_prefix
+ from shared.browser.app.csrf import generate_csrf_token
+
+ pfx = route_prefix()
+ detail_url = pfx + url_for_fn("orders.order.order_detail", order_id=order.id)
+ list_url = pfx + url_for_fn("orders.list_orders")
+ recheck_url = pfx + url_for_fn("orders.order.order_recheck", order_id=order.id)
+ pay_url = pfx + url_for_fn("orders.order.order_pay", order_id=order.id)
+
+ main = _order_main_html(order, calendar_entries)
+ filt = _order_filter_html(order, list_url, recheck_url, pay_url, generate_csrf_token())
+
+ order_row_oob = sexp(
+ '(~menu-row :id "order-row" :level 3 :colour "sky" :link-href lh :link-label "Order" :icon "fa fa-gbp" :oob true)',
+ lh=detail_url,
+ )
+ oobs = (
+ sexp('(div :id "orders-header-child" :hx-swap-oob "outerHTML" :class "flex flex-col w-full items-center" (raw! o))', o=order_row_oob)
+ + root_header_html(ctx, oob=True)
+ )
+
+ return oob_page(ctx, oobs_html=oobs, filter_html=filt, content_html=main)
diff --git a/shared/infrastructure/context.py b/shared/infrastructure/context.py
index a98227c..09f268b 100644
--- a/shared/infrastructure/context.py
+++ b/shared/infrastructure/context.py
@@ -16,6 +16,51 @@ from shared.utils import host_url
from shared.browser.app.utils import current_route_relative_path
+def _qs_filter_fn():
+ """Build a qs_filter(dict) wrapper for sexp components, or None.
+
+ Sexp components call ``qs_fn({"page": 2})``, ``qs_fn({"sort": "az"})``,
+ ``qs_fn({"labels": ["organic", "local"]})``, etc.
+
+ Simple keys (page, sort, search, liked, clear_filters) are forwarded
+ to ``makeqs(**kwargs)``. List-valued keys (labels, stickers, brands)
+ represent *replacement* sets, so we rebuild the querystring from the
+ current base with those overridden.
+ """
+ factory = getattr(g, "makeqs_factory", None)
+ if not factory:
+ return None
+ makeqs = factory()
+
+ def _qs(d: dict) -> str:
+ from shared.browser.app.filters.qs_base import build_qs
+
+ # Collect list-valued overrides
+ list_overrides = {}
+ for plural, singular in (("labels", "label"), ("stickers", "sticker"), ("brands", "brand")):
+ if plural in d:
+ list_overrides[singular] = list(d[plural] or [])
+
+ simple = {k: v for k, v in d.items()
+ if k in ("page", "sort", "search", "liked", "clear_filters")}
+
+ if not list_overrides:
+ return makeqs(**simple)
+
+ # For list overrides: get the base qs, parse out the overridden keys,
+ # then rebuild with the new values.
+ base_qs = makeqs(**simple)
+ from urllib.parse import parse_qsl, urlencode
+ params = [(k, v) for k, v in parse_qsl(base_qs.lstrip("?"))
+ if k not in list_overrides]
+ for singular, vals in list_overrides.items():
+ for v in vals:
+ params.append((singular, v))
+ return ("?" + urlencode(params)) if params else ""
+
+ return _qs
+
+
async def base_context() -> dict:
"""
Common template variables available in every app.
@@ -50,6 +95,7 @@ async def base_context() -> dict:
("price-desc", "\u00a3 high\u2192low", "order/h-l.svg"),
],
"zap_filter": zap_filter,
+ "qs_filter": _qs_filter_fn(),
"print": print,
"base_url": base_url,
"base_title": config()["title"],
diff --git a/shared/sexp/components.py b/shared/sexp/components.py
index 7513f49..92ac65e 100644
--- a/shared/sexp/components.py
+++ b/shared/sexp/components.py
@@ -23,6 +23,18 @@ def load_shared_components() -> None:
register_components(_POST_CARD)
register_components(_BASE_SHELL)
register_components(_ERROR_PAGE)
+ # Phase 6: layout infrastructure
+ register_components(_APP_SHELL)
+ register_components(_APP_LAYOUT)
+ register_components(_OOB_RESPONSE)
+ register_components(_HEADER_ROW)
+ register_components(_MENU_ROW)
+ register_components(_NAV_LINK)
+ register_components(_INFINITE_SCROLL)
+ register_components(_STATUS_PILL)
+ register_components(_SEARCH_MOBILE)
+ register_components(_SEARCH_DESKTOP)
+ register_components(_ORDER_SUMMARY_CARD)
# ---------------------------------------------------------------------------
@@ -298,3 +310,425 @@ _ERROR_PAGE = '''
(div :class "flex justify-center"
(img :src image :width "300" :height "300"))))))
'''
+
+
+# ===================================================================
+# Phase 6: Layout infrastructure components
+# ===================================================================
+
+# ---------------------------------------------------------------------------
+# ~app-shell — full HTML document with all required CSS/JS assets
+# ---------------------------------------------------------------------------
+# Replaces: _types/root/index.html ... shell
+#
+# This includes htmx, hyperscript, tailwind, fontawesome, prism, and
+# all shared CSS/JS. ``~base-shell`` remains the lightweight error-page
+# shell; ``~app-shell`` is for real app pages.
+#
+# Usage:
+# sexp('(~app-shell :title t :asset-url a :meta-html m :body-html b)', **ctx)
+# ---------------------------------------------------------------------------
+
+_APP_SHELL = r'''
+(defcomp ~app-shell (&key title asset-url meta-html body-html body-end-html)
+ (<>
+ (raw! "")
+ (html :lang "en"
+ (head
+ (meta :charset "utf-8")
+ (meta :name "viewport" :content "width=device-width, initial-scale=1")
+ (meta :name "robots" :content "index,follow")
+ (meta :name "theme-color" :content "#ffffff")
+ (title title)
+ (when meta-html (raw! meta-html))
+ (style "@media (min-width: 768px) { .js-mobile-sentinel { display:none !important; } }")
+ (link :rel "stylesheet" :type "text/css" :href (str asset-url "/styles/basics.css"))
+ (link :rel "stylesheet" :type "text/css" :href (str asset-url "/styles/cards.css"))
+ (link :rel "stylesheet" :type "text/css" :href (str asset-url "/styles/blog-content.css"))
+ (script :src "https://unpkg.com/htmx.org@2.0.8")
+ (script :src "https://unpkg.com/hyperscript.org@0.9.12")
+ (script :src "https://cdn.tailwindcss.com")
+ (link :rel "stylesheet" :href (str asset-url "/fontawesome/css/all.min.css"))
+ (link :rel "stylesheet" :href (str asset-url "/fontawesome/css/v4-shims.min.css"))
+ (link :href "https://unpkg.com/prismjs/themes/prism.css" :rel "stylesheet")
+ (script :src "https://unpkg.com/prismjs/prism.js")
+ (script :src "https://unpkg.com/prismjs/components/prism-javascript.min.js")
+ (script :src "https://unpkg.com/prismjs/components/prism-python.min.js")
+ (script :src "https://unpkg.com/prismjs/components/prism-bash.min.js")
+ (script :src "https://cdn.jsdelivr.net/npm/sweetalert2@11")
+ (script "if(matchMedia('(hover:hover) and (pointer:fine)').matches){document.documentElement.classList.add('hover-capable')}")
+ (script "document.addEventListener('click',function(e){var t=e.target.closest('[data-close-details]');if(!t)return;var d=t.closest('details');if(d)d.removeAttribute('open')})")
+ (style
+ "details[data-toggle-group=\"mobile-panels\"]>summary{list-style:none}"
+ "details[data-toggle-group=\"mobile-panels\"]>summary::-webkit-details-marker{display:none}"
+ "@media(min-width:768px){.nav-group:focus-within .submenu,.nav-group:hover .submenu{display:block}}"
+ "img{max-width:100%;height:auto}"
+ ".clamp-2{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}"
+ ".clamp-3{display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}"
+ ".no-scrollbar::-webkit-scrollbar{display:none}.no-scrollbar{-ms-overflow-style:none;scrollbar-width:none}"
+ "details.group{overflow:hidden}details.group>summary{list-style:none}details.group>summary::-webkit-details-marker{display:none}"
+ ".htmx-indicator{display:none}.htmx-request .htmx-indicator{display:inline-flex}"
+ ".js-wrap.open .js-pop{display:block}.js-wrap.open .js-backdrop{display:block}"))
+ (body :class "bg-stone-50 text-stone-900"
+ (raw! body-html)
+ (when body-end-html (raw! body-end-html))
+ (script :src (str asset-url "/scripts/body.js"))))))
+'''
+
+
+# ---------------------------------------------------------------------------
+# ~app-layout — page body layout (header + filter + aside + main-panel)
+# ---------------------------------------------------------------------------
+# Replaces: _types/root/index.html body structure
+#
+# The header uses a / pattern for mobile menu toggle.
+# All content sections are passed as pre-rendered HTML strings.
+#
+# Usage:
+# sexp('(~app-layout :title t :asset-url a :header-rows-html h
+# :menu-html m :filter-html f :aside-html a :content-html c)', **ctx)
+# ---------------------------------------------------------------------------
+
+_APP_LAYOUT = r'''
+(defcomp ~app-layout (&key title asset-url meta-html menu-colour
+ header-rows-html menu-html
+ filter-html aside-html content-html
+ body-end-html)
+ (let* ((colour (or menu-colour "sky")))
+ (~app-shell :title (or title "Rose Ash") :asset-url asset-url
+ :meta-html meta-html :body-end-html body-end-html
+ :body-html (str
+ ""
+ "
"
+ ""
+ ""
+ ""
+ " "
+ ""
+ " "
+ "
"
+ "
"
+ (or filter-html "")
+ "
"
+ "
"
+ ""
+ "
"
+ "
"
+ (or aside-html "")
+ " "
+ "
"
+ (or content-html "")
+ "
"
+ " "
+ "
"
+ "
"
+ " "
+ "
"))))
+'''
+
+
+# ---------------------------------------------------------------------------
+# ~oob-response — HTMX OOB multi-target swap wrapper
+# ---------------------------------------------------------------------------
+# Replaces: oob_elements.html base template
+#
+# Each named region gets hx-swap-oob="outerHTML" on its wrapper div.
+# The oobs-html param contains any extra OOB elements (header row swaps).
+#
+# Usage:
+# sexp('(~oob-response :oobs-html oh :filter-html fh :aside-html ah
+# :menu-html mh :content-html ch)', **ctx)
+# ---------------------------------------------------------------------------
+
+_OOB_RESPONSE = '''
+(defcomp ~oob-response (&key oobs-html filter-html aside-html menu-html content-html)
+ (<>
+ (when oobs-html (raw! oobs-html))
+ (div :id "filter" :hx-swap-oob "outerHTML"
+ (when filter-html (raw! filter-html)))
+ (aside :id "aside" :hx-swap-oob "outerHTML"
+ :class "hidden md:flex md:flex-col max-w-xs md:h-full md:min-h-0 mr-3"
+ (when aside-html (raw! aside-html)))
+ (div :id "root-menu" :hx-swap-oob "outerHTML" :class "md:hidden"
+ (when menu-html (raw! menu-html)))
+ (section :id "main-panel"
+ :class "flex-1 md:h-full md:min-h-0 overflow-y-auto overscroll-contain js-grid-viewport"
+ (when content-html (raw! content-html)))))
+'''
+
+
+# ---------------------------------------------------------------------------
+# ~header-row — root header bar (cart-mini, title, nav-tree, auth-menu)
+# ---------------------------------------------------------------------------
+# Replaces: _types/root/header/_header.html header_row macro
+#
+# Usage:
+# sexp('(~header-row :cart-mini-html cm :blog-url bu :site-title st
+# :nav-tree-html nh :auth-menu-html ah :nav-panel-html np
+# :settings-url su :is-admin ia)', **ctx)
+# ---------------------------------------------------------------------------
+
+_HEADER_ROW = '''
+(defcomp ~header-row (&key cart-mini-html blog-url site-title
+ nav-tree-html auth-menu-html nav-panel-html
+ settings-url is-admin oob hamburger-html)
+ (<>
+ (div :id "root-row"
+ :hx-swap-oob (if oob "outerHTML" nil)
+ :class "flex flex-col items-center md:flex-row justify-center md:justify-between w-full p-1 bg-sky-500"
+ (div :class "w-full flex flex-row items-top"
+ (when cart-mini-html (raw! cart-mini-html))
+ (div :class "font-bold text-5xl flex-1"
+ (a :href (str (or blog-url "") "/") :class "flex justify-center md:justify-start"
+ (h1 (or site-title ""))))
+ (nav :class "hidden md:flex gap-4 text-sm ml-2 justify-end items-center flex-0"
+ (when nav-tree-html (raw! nav-tree-html))
+ (when auth-menu-html (raw! auth-menu-html))
+ (when nav-panel-html (raw! nav-panel-html))
+ (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"))))
+ (when hamburger-html (raw! hamburger-html))))
+ (div :class "block md:hidden text-md font-bold"
+ (when auth-menu-html (raw! auth-menu-html)))))
+'''
+
+
+# ---------------------------------------------------------------------------
+# ~menu-row — section header row (wraps in colored bar)
+# ---------------------------------------------------------------------------
+# Replaces: macros/links.html menu_row macro
+#
+# Each nested header row gets a progressively lighter background.
+# The route handler passes the level (0-based depth after root).
+#
+# Usage:
+# sexp('(~menu-row :id "auth-row" :level 1 :colour "sky"
+# :link-href url :link-label "account" :icon "fa-solid fa-user"
+# :nav-html nh :child-id "auth-header-child" :child-html ch)', **ctx)
+# ---------------------------------------------------------------------------
+
+_MENU_ROW = '''
+(defcomp ~menu-row (&key id level colour link-href link-label link-label-html icon
+ hx-select nav-html child-id child-html oob)
+ (let* ((c (or colour "sky"))
+ (lv (or level 1))
+ (shade (str (- 500 (* lv 100)))))
+ (<>
+ (div :id id
+ :hx-swap-oob (if oob "outerHTML" nil)
+ :class (str "flex flex-col items-center md:flex-row justify-center md:justify-between w-full p-1 bg-" c "-" shade)
+ (div :class "relative nav-group"
+ (a :href link-href
+ :hx-get link-href
+ :hx-target "#main-panel"
+ :hx-select (or hx-select "#main-panel")
+ :hx-swap "outerHTML"
+ :hx-push-url "true"
+ :class "w-full whitespace-normal flex items-center gap-2 font-bold text-2xl px-3 py-2"
+ (when icon (i :class icon :aria-hidden "true"))
+ (if link-label-html (raw! link-label-html)
+ (when link-label (div link-label)))))
+ (when nav-html
+ (nav :class "hidden md:flex gap-4 text-sm ml-2 justify-end items-center flex-0"
+ (raw! nav-html))))
+ (when child-id
+ (div :id child-id :class "flex flex-col w-full items-center"
+ (when child-html (raw! child-html)))))))
+'''
+
+
+# ---------------------------------------------------------------------------
+# ~nav-link — HTMX navigation link (replaces macros/links.html link macro)
+# ---------------------------------------------------------------------------
+
+_NAV_LINK = '''
+(defcomp ~nav-link (&key href hx-select label icon aclass select-colours)
+ (div :class "relative nav-group"
+ (a :href href
+ :hx-get href
+ :hx-target "#main-panel"
+ :hx-select (or hx-select "#main-panel")
+ :hx-swap "outerHTML"
+ :hx-push-url "true"
+ :class (or aclass
+ (str "justify-center cursor-pointer flex flex-row items-center gap-2 rounded bg-stone-200 text-black p-3 "
+ (or select-colours "")))
+ (when icon (i :class icon :aria-hidden "true"))
+ (when label (span label)))))
+'''
+
+
+# ---------------------------------------------------------------------------
+# ~infinite-scroll — pagination sentinel for table-based lists
+# ---------------------------------------------------------------------------
+# Replaces: sentinel pattern in _rows.html templates
+#
+# For table rows (orders, etc.): renders with intersection observer.
+# Uses hyperscript for retry with exponential backoff.
+#
+# Usage:
+# sexp('(~infinite-scroll :url next-url :page p :total-pages tp
+# :id-prefix "orders" :colspan 5)', **ctx)
+# ---------------------------------------------------------------------------
+
+_INFINITE_SCROLL = r'''
+(defcomp ~infinite-scroll (&key url page total-pages id-prefix colspan)
+ (if (< page total-pages)
+ (raw! (str
+ " htmx.trigger(me, 'sentinel:retry'), myMs) "
+ "end "
+ "on htmx:beforeRequest "
+ "set me.style.pointerEvents to 'none' "
+ "set me.style.opacity to '0' "
+ "end "
+ "on htmx:afterSwap set me.dataset.retryMs to 1000 end "
+ "on htmx:sendError call backoff() "
+ "on htmx:responseError call backoff() "
+ "on htmx:timeout call backoff()"
+ "\""
+ " role=\"status\" aria-live=\"polite\" aria-hidden=\"true\">"
+ ""
+ ""
+ "
loading… " page " / " total-pages "
"
+ "
"
+ "
"
+ ""
+ "
loading… " page " / " total-pages "
"
+ "
"
+ "
"
+ " "))
+ (raw! (str
+ "End of results "))))
+'''
+
+
+# ---------------------------------------------------------------------------
+# ~status-pill — colored status indicator
+# ---------------------------------------------------------------------------
+# Replaces: inline Jinja status pill patterns across templates
+#
+# Usage:
+# sexp('(~status-pill :status s :size "sm")', status="paid")
+# ---------------------------------------------------------------------------
+
+_STATUS_PILL = '''
+(defcomp ~status-pill (&key status size)
+ (let* ((s (or status "pending"))
+ (lower (lower s))
+ (sz (or size "xs"))
+ (colours (cond
+ (= lower "paid") "border-emerald-300 bg-emerald-50 text-emerald-700"
+ (= lower "confirmed") "border-emerald-300 bg-emerald-50 text-emerald-700"
+ (= lower "checked_in") "border-blue-300 bg-blue-50 text-blue-700"
+ (or (= lower "failed") (= lower "cancelled")) "border-rose-300 bg-rose-50 text-rose-700"
+ (= lower "provisional") "border-amber-300 bg-amber-50 text-amber-700"
+ (= lower "ordered") "border-blue-300 bg-blue-50 text-blue-700"
+ true "border-stone-300 bg-stone-50 text-stone-700")))
+ (span :class (str "inline-flex items-center rounded-full border px-2 py-0.5 text-" sz " font-medium " colours)
+ s)))
+'''
+
+
+# ---------------------------------------------------------------------------
+# ~search-mobile — mobile search input with htmx
+# ---------------------------------------------------------------------------
+
+_SEARCH_MOBILE = '''
+(defcomp ~search-mobile (&key current-local-href search search-count hx-select search-headers-mobile)
+ (div :id "search-mobile-wrapper"
+ :class "flex flex-row gap-2 items-center flex-1 min-w-0 pr-2"
+ (input :id "search-mobile"
+ :type "text" :name "search" :aria-label "search"
+ :class "text-base md:text-sm col-span-5 rounded-md px-3 py-2 mb-2 w-full min-w-0 max-w-full border-2 border-stone-200 placeholder-shown:border-stone-200 [&:not(:placeholder-shown)]:border-yellow-200"
+ :hx-preserve true
+ :value (or search "")
+ :placeholder "search"
+ :hx-trigger "input changed delay:300ms"
+ :hx-target "#main-panel"
+ :hx-select (str (or hx-select "#main-panel") ", #search-mobile-wrapper, #search-desktop-wrapper")
+ :hx-get current-local-href
+ :hx-swap "outerHTML"
+ :hx-push-url "true"
+ :hx-headers search-headers-mobile
+ :hx-sync "this:replace"
+ :autocomplete "off")
+ (div :id "search-count-mobile" :aria-label "search count"
+ :class (if (not search-count) "text-xl text-red-500" "")
+ (when search (raw! (str search-count))))))
+'''
+
+
+# ---------------------------------------------------------------------------
+# ~search-desktop — desktop search input with htmx
+# ---------------------------------------------------------------------------
+
+_SEARCH_DESKTOP = '''
+(defcomp ~search-desktop (&key current-local-href search search-count hx-select search-headers-desktop)
+ (div :id "search-desktop-wrapper"
+ :class "flex flex-row gap-2 items-center"
+ (input :id "search-desktop"
+ :type "text" :name "search" :aria-label "search"
+ :class "w-full mx-1 my-3 px-3 py-2 text-md rounded-xl border-2 shadow-sm border-white placeholder-shown:border-white [&:not(:placeholder-shown)]:border-yellow-200"
+ :hx-preserve true
+ :value (or search "")
+ :placeholder "search"
+ :hx-trigger "input changed delay:300ms"
+ :hx-target "#main-panel"
+ :hx-select (str (or hx-select "#main-panel") ", #search-mobile-wrapper, #search-desktop-wrapper")
+ :hx-get current-local-href
+ :hx-swap "outerHTML"
+ :hx-push-url "true"
+ :hx-headers search-headers-desktop
+ :hx-sync "this:replace"
+ :autocomplete "off")
+ (div :id "search-count-desktop" :aria-label "search count"
+ :class (if (not search-count) "text-xl text-red-500" "")
+ (when search (raw! (str search-count))))))
+'''
+
+
+# ---------------------------------------------------------------------------
+# ~order-summary-card — reusable order summary card
+# ---------------------------------------------------------------------------
+_ORDER_SUMMARY_CARD = r'''
+(defcomp ~order-summary-card (&key order-id created-at description status currency total-amount)
+ (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" "Currency:") " " (or currency "GBP"))
+ (p (span :class "font-medium" "Total:") " "
+ (if total-amount
+ (str (or currency "GBP") " " total-amount)
+ "\u2013"))))
+'''
diff --git a/shared/sexp/helpers.py b/shared/sexp/helpers.py
new file mode 100644
index 0000000..66b9216
--- /dev/null
+++ b/shared/sexp/helpers.py
@@ -0,0 +1,108 @@
+"""
+Shared helper functions for s-expression page rendering.
+
+These are used by per-service sexp_components.py files to build common
+page elements (headers, search, etc.) from template context.
+"""
+from __future__ import annotations
+
+from typing import Any
+
+from .jinja_bridge import sexp
+from .page import HAMBURGER_HTML, SEARCH_HEADERS_MOBILE, SEARCH_HEADERS_DESKTOP
+
+
+def call_url(ctx: dict, key: str, path: str = "/") -> str:
+ """Call a URL helper from context (e.g., blog_url, account_url)."""
+ fn = ctx.get(key)
+ if callable(fn):
+ return fn(path)
+ return str(fn or "") + path
+
+
+def get_asset_url(ctx: dict) -> str:
+ """Extract the asset URL base from context."""
+ au = ctx.get("asset_url")
+ if callable(au):
+ result = au("")
+ return result.rsplit("/", 1)[0] if "/" in result else result
+ return au or ""
+
+
+def root_header_html(ctx: dict, *, oob: bool = False) -> str:
+ """Build the root header row HTML."""
+ return sexp(
+ '(~header-row :cart-mini-html cmi :blog-url bu :site-title st'
+ ' :nav-tree-html nth :auth-menu-html amh :nav-panel-html nph'
+ ' :hamburger-html hh :oob oob)',
+ cmi=ctx.get("cart_mini_html", ""),
+ bu=call_url(ctx, "blog_url", ""),
+ st=ctx.get("base_title", ""),
+ nth=ctx.get("nav_tree_html", ""),
+ amh=ctx.get("auth_menu_html", ""),
+ nph=ctx.get("nav_panel_html", ""),
+ hh=HAMBURGER_HTML,
+ oob=oob,
+ )
+
+
+def search_mobile_html(ctx: dict) -> str:
+ """Build mobile search input HTML."""
+ return sexp(
+ '(~search-mobile :current-local-href clh :search s :search-count sc'
+ ' :hx-select hs :search-headers-mobile shm)',
+ clh=ctx.get("current_local_href", "/"),
+ s=ctx.get("search", ""),
+ sc=ctx.get("search_count", ""),
+ hs=ctx.get("hx_select", "#main-panel"),
+ shm=SEARCH_HEADERS_MOBILE,
+ )
+
+
+def search_desktop_html(ctx: dict) -> str:
+ """Build desktop search input HTML."""
+ return sexp(
+ '(~search-desktop :current-local-href clh :search s :search-count sc'
+ ' :hx-select hs :search-headers-desktop shd)',
+ clh=ctx.get("current_local_href", "/"),
+ s=ctx.get("search", ""),
+ sc=ctx.get("search_count", ""),
+ hs=ctx.get("hx_select", "#main-panel"),
+ shd=SEARCH_HEADERS_DESKTOP,
+ )
+
+
+def full_page(ctx: dict, *, header_rows_html: str,
+ filter_html: str = "", aside_html: str = "",
+ content_html: str = "", menu_html: str = "",
+ body_end_html: str = "", meta_html: str = "") -> str:
+ """Render a full app page with the standard layout."""
+ return sexp(
+ '(~app-layout :title t :asset-url au :meta-html mh'
+ ' :header-rows-html hrh :menu-html muh :filter-html fh'
+ ' :aside-html ash :content-html ch :body-end-html beh)',
+ t=ctx.get("base_title", "Rose Ash"),
+ au=get_asset_url(ctx),
+ mh=meta_html,
+ hrh=header_rows_html,
+ muh=menu_html,
+ fh=filter_html,
+ ash=aside_html,
+ ch=content_html,
+ beh=body_end_html,
+ )
+
+
+def oob_page(ctx: dict, *, oobs_html: str = "",
+ filter_html: str = "", aside_html: str = "",
+ content_html: str = "", menu_html: str = "") -> str:
+ """Render an OOB response with standard swap targets."""
+ return sexp(
+ '(~oob-response :oobs-html oh :filter-html fh :aside-html ash'
+ ' :menu-html mh :content-html ch)',
+ oh=oobs_html,
+ fh=filter_html,
+ ash=aside_html,
+ mh=menu_html,
+ ch=content_html,
+ )
diff --git a/shared/sexp/page.py b/shared/sexp/page.py
index 29b038c..a0c05e5 100644
--- a/shared/sexp/page.py
+++ b/shared/sexp/page.py
@@ -5,15 +5,23 @@ Provides ``render_page()`` for rendering a complete HTML page from an
s-expression, bypassing Jinja entirely. Used by error handlers and
(eventually) by route handlers for fully-migrated pages.
+``render_sexp_response()`` is the main entry point for GET route handlers:
+it calls the app's context processor, merges in route-specific kwargs,
+renders the s-expression to HTML, and returns a Quart ``Response``.
+
Usage::
- from shared.sexp.page import render_page
+ from shared.sexp.page import render_page, render_sexp_response
+ # Error pages (no context needed)
html = render_page(
'(~error-page :title "Not Found" :message "NOT FOUND" :image img :asset-url aurl)',
image="/static/errors/404.gif",
asset_url="/static",
)
+
+ # GET route handlers (auto-injects app context)
+ resp = await render_sexp_response('(~orders-page :orders orders)', orders=orders)
"""
from __future__ import annotations
@@ -22,6 +30,22 @@ from typing import Any
from .jinja_bridge import sexp
+# HTML constants used by layout components — kept here to avoid
+# s-expression parser issues with embedded quotes in SVG.
+HAMBURGER_HTML = (
+ ''
+)
+
+SEARCH_HEADERS_MOBILE = '{"X-Origin":"search-mobile","X-Search":"true"}'
+SEARCH_HEADERS_DESKTOP = '{"X-Origin":"search-desktop","X-Search":"true"}'
+
def render_page(source: str, **kwargs: Any) -> str:
"""Render a full HTML page from an s-expression string.
@@ -30,3 +54,52 @@ def render_page(source: str, **kwargs: Any) -> str:
intent explicit in call sites (rendering a whole page, not a fragment).
"""
return sexp(source, **kwargs)
+
+
+async def get_template_context(**kwargs: Any) -> dict[str, Any]:
+ """Gather the full template context from all registered context processors.
+
+ Returns a dict with all context variables that would normally be
+ available in a Jinja template, merged with any extra kwargs.
+ """
+ import asyncio
+ from quart import current_app, request
+
+ ctx: dict[str, Any] = {}
+
+ # App-level context processors
+ for proc in current_app.template_context_processors.get(None, []):
+ rv = proc()
+ if asyncio.iscoroutine(rv):
+ rv = await rv
+ ctx.update(rv)
+
+ # Blueprint-scoped context processors
+ for bp_name in (request.blueprints or []):
+ for proc in current_app.template_context_processors.get(bp_name, []):
+ rv = proc()
+ if asyncio.iscoroutine(rv):
+ rv = await rv
+ ctx.update(rv)
+
+ # Inject Jinja globals that s-expression components need (URL helpers,
+ # asset_url, site, etc.) — these aren't provided by context processors.
+ for key, val in current_app.jinja_env.globals.items():
+ if key not in ctx and callable(val):
+ ctx[key] = val
+
+ ctx.update(kwargs)
+ return ctx
+
+
+async def render_sexp_response(source: str, **kwargs: Any) -> str:
+ """Render an s-expression with the full app template context.
+
+ Calls the app's registered context processors (which provide
+ cart_mini_html, auth_menu_html, nav_tree_html, asset_url, etc.)
+ and merges them with the caller's kwargs before rendering.
+
+ Returns the rendered HTML string (caller wraps in Response as needed).
+ """
+ ctx = await get_template_context(**kwargs)
+ return sexp(source, **ctx)