diff --git a/browser/templates/_types/auth/_nav.html b/browser/templates/_types/auth/_nav.html
index 0324fdf..3d1bd3d 100644
--- a/browser/templates/_types/auth/_nav.html
+++ b/browser/templates/_types/auth/_nav.html
@@ -2,14 +2,16 @@
{% call links.link(coop_url('/auth/newsletters/'), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
newsletters
{% endcall %}
-{% call links.link(coop_url('/auth/tickets/'), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
- tickets
-{% endcall %}
-{% call links.link(coop_url('/auth/bookings/'), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
- bookings
-{% endcall %}
-
{% include '_types/post/admin/_nav_entries.html' %}
diff --git a/browser/templates/_types/post/admin/_nav_entries.html b/browser/templates/_types/post/admin/_nav_entries.html
index 2268243..47290d4 100644
--- a/browser/templates/_types/post/admin/_nav_entries.html
+++ b/browser/templates/_types/post/admin/_nav_entries.html
@@ -8,7 +8,7 @@
- {# Entries and Calendars container #}
+ {# Widget-driven nav items container #}
diff --git a/browser/templates/_widgets/container_card/calendar_entries.html b/browser/templates/_widgets/container_card/calendar_entries.html
new file mode 100644
index 0000000..52da849
--- /dev/null
+++ b/browser/templates/_widgets/container_card/calendar_entries.html
@@ -0,0 +1,36 @@
+{# Associated entries on blog listing cards — loaded via widget registry #}
+{% set widget_entries = post[w.context_key] if post[w.context_key] is defined else [] %}
+{% if widget_entries %}
+
+
+
+{% endif %}
diff --git a/browser/templates/_widgets/container_nav/calendar_entries.html b/browser/templates/_widgets/container_nav/calendar_entries.html
new file mode 100644
index 0000000..0156a8f
--- /dev/null
+++ b/browser/templates/_widgets/container_nav/calendar_entries.html
@@ -0,0 +1,38 @@
+{# Calendar entries nav items — loaded via widget registry #}
+{% set entry_list = ctx.entries if ctx.entries is defined else [] %}
+{% set current_page = ctx.page if ctx.page is defined else 1 %}
+{% set has_more_entries = ctx.has_more if ctx.has_more is defined else False %}
+
+{% for entry in entry_list %}
+ {% set _entry_path = '/' + post.slug + '/calendars/' + entry.calendar_slug + '/' + entry.start_at.year|string + '/' + entry.start_at.month|string + '/' + entry.start_at.day|string + '/entries/' + entry.id|string + '/' %}
+
+ {% if post.feature_image %}
+
+ {% else %}
+
+ {% endif %}
+
+
{{ entry.name }}
+
+ {{ entry.start_at.strftime('%b %d, %Y at %H:%M') }}
+ {% if entry.end_at %} – {{ entry.end_at.strftime('%H:%M') }}{% endif %}
+
+
+
+{% endfor %}
+
+{# Load more entries one at a time until container is full #}
+{% if has_more_entries %}
+
+
+{% endif %}
diff --git a/browser/templates/_widgets/container_nav/calendar_links.html b/browser/templates/_widgets/container_nav/calendar_links.html
new file mode 100644
index 0000000..f0c3022
--- /dev/null
+++ b/browser/templates/_widgets/container_nav/calendar_links.html
@@ -0,0 +1,10 @@
+{# Calendar link nav items — loaded via widget registry #}
+{% for calendar in ctx.calendars %}
+ {% set local_href=events_url('/' + post.slug + '/calendars/' + calendar.slug + '/') %}
+
+
+ {{calendar.name}}
+
+{% endfor %}
diff --git a/browser/templates/_widgets/container_nav/market_links.html b/browser/templates/_widgets/container_nav/market_links.html
new file mode 100644
index 0000000..0419cfd
--- /dev/null
+++ b/browser/templates/_widgets/container_nav/market_links.html
@@ -0,0 +1,9 @@
+{# Market link nav items — loaded via widget registry #}
+{% for m in ctx.markets %}
+
+
+ {{m.name}}
+
+{% endfor %}
diff --git a/contracts/protocols.py b/contracts/protocols.py
index e514ea5..d5b2370 100644
--- a/contracts/protocols.py
+++ b/contracts/protocols.py
@@ -78,6 +78,10 @@ class CalendarService(Protocol):
self, session: AsyncSession, *, user_id: int,
) -> list[CalendarEntryDTO]: ...
+ async def confirmed_entries_for_posts(
+ self, session: AsyncSession, post_ids: list[int],
+ ) -> dict[int, list[CalendarEntryDTO]]: ...
+
@runtime_checkable
class MarketService(Protocol):
diff --git a/contracts/widgets.py b/contracts/widgets.py
new file mode 100644
index 0000000..b5aef0f
--- /dev/null
+++ b/contracts/widgets.py
@@ -0,0 +1,49 @@
+"""Widget descriptors for cross-domain UI composition.
+
+Each widget type describes a UI fragment that one domain contributes to
+another domain's page. Host apps iterate widgets generically — they never
+name the contributing domain.
+"""
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Callable
+
+
+@dataclass(frozen=True, slots=True)
+class NavWidget:
+ """Renders nav items on a container page (entries, calendars, markets)."""
+ domain: str
+ order: int
+ context_fn: Callable # async (session, *, container_type, container_id, **kw) -> dict
+ template: str
+
+
+@dataclass(frozen=True, slots=True)
+class CardWidget:
+ """Decorates content cards in listings with domain data."""
+ domain: str
+ order: int
+ batch_fn: Callable # async (session, post_ids) -> dict[int, list]
+ context_key: str # key injected into each post dict
+ template: str
+
+
+@dataclass(frozen=True, slots=True)
+class AccountPageWidget:
+ """Sub-page under /auth/
/."""
+ domain: str
+ slug: str
+ label: str
+ order: int
+ context_fn: Callable # async (session, *, user_id, **kw) -> dict
+ template: str
+
+
+@dataclass(frozen=True, slots=True)
+class AccountNavLink:
+ """Nav link on account page (internal or external)."""
+ label: str
+ order: int
+ href_fn: Callable # () -> str
+ external: bool = False
diff --git a/infrastructure/factory.py b/infrastructure/factory.py
index 94d54a6..06aca64 100644
--- a/infrastructure/factory.py
+++ b/infrastructure/factory.py
@@ -65,6 +65,10 @@ def create_base_app(
"""
if domain_services_fn is not None:
domain_services_fn()
+
+ from shared.services.widgets import register_all_widgets
+ register_all_widgets()
+
app = Quart(
name,
static_folder=STATIC_DIR,
diff --git a/infrastructure/jinja_setup.py b/infrastructure/jinja_setup.py
index 3852dfc..305d820 100644
--- a/infrastructure/jinja_setup.py
+++ b/infrastructure/jinja_setup.py
@@ -101,5 +101,9 @@ def setup_jinja(app: Quart) -> None:
app.jinja_env.globals["page_cart_url"] = page_cart_url
app.jinja_env.globals["market_product_url"] = market_product_url
+ # widget registry available in all templates
+ from shared.services.widget_registry import widgets as _widget_registry
+ app.jinja_env.globals["widgets"] = _widget_registry
+
# register jinja filters
register_filters(app)
diff --git a/services/stubs.py b/services/stubs.py
index 3284797..1f4c9f7 100644
--- a/services/stubs.py
+++ b/services/stubs.py
@@ -93,6 +93,11 @@ class StubCalendarService:
) -> list[CalendarEntryDTO]:
return []
+ async def confirmed_entries_for_posts(
+ self, session: AsyncSession, post_ids: list[int],
+ ) -> dict[int, list[CalendarEntryDTO]]:
+ return {}
+
class StubMarketService:
async def marketplaces_for_container(
diff --git a/services/widget_registry.py b/services/widget_registry.py
new file mode 100644
index 0000000..45f2e2a
--- /dev/null
+++ b/services/widget_registry.py
@@ -0,0 +1,90 @@
+"""Singleton widget registry for cross-domain UI composition.
+
+Usage::
+
+ from shared.services.widget_registry import widgets
+
+ # Register at app startup (after domain services)
+ widgets.add_container_nav(NavWidget(...))
+
+ # Query in templates / context processors
+ for w in widgets.container_nav:
+ ctx = await w.context_fn(session, container_type="page", ...)
+"""
+from __future__ import annotations
+
+from shared.contracts.widgets import (
+ NavWidget,
+ CardWidget,
+ AccountPageWidget,
+ AccountNavLink,
+)
+
+
+class _WidgetRegistry:
+ """Central registry holding all widget descriptors.
+
+ Widgets are added at startup and read at request time.
+ Properties return sorted-by-order copies.
+ """
+
+ def __init__(self) -> None:
+ self._container_nav: list[NavWidget] = []
+ self._container_card: list[CardWidget] = []
+ self._account_pages: list[AccountPageWidget] = []
+ self._account_nav: list[AccountNavLink] = []
+
+ # -- registration ---------------------------------------------------------
+
+ def add_container_nav(self, w: NavWidget) -> None:
+ self._container_nav.append(w)
+
+ def add_container_card(self, w: CardWidget) -> None:
+ self._container_card.append(w)
+
+ def add_account_page(self, w: AccountPageWidget) -> None:
+ self._account_pages.append(w)
+ # Auto-create a matching internal nav link
+ slug = w.slug
+
+ def _href(s=slug):
+ from shared.infrastructure.urls import coop_url
+ return coop_url(f"/auth/{s}/")
+
+ self._account_nav.append(AccountNavLink(
+ label=w.label,
+ order=w.order,
+ href_fn=_href,
+ external=False,
+ ))
+
+ def add_account_link(self, link: AccountNavLink) -> None:
+ self._account_nav.append(link)
+
+ # -- read access (sorted copies) ------------------------------------------
+
+ @property
+ def container_nav(self) -> list[NavWidget]:
+ return sorted(self._container_nav, key=lambda w: w.order)
+
+ @property
+ def container_cards(self) -> list[CardWidget]:
+ return sorted(self._container_card, key=lambda w: w.order)
+
+ @property
+ def account_pages(self) -> list[AccountPageWidget]:
+ return sorted(self._account_pages, key=lambda w: w.order)
+
+ @property
+ def account_nav(self) -> list[AccountNavLink]:
+ return sorted(self._account_nav, key=lambda w: w.order)
+
+ def account_page_by_slug(self, slug: str) -> AccountPageWidget | None:
+ for w in self._account_pages:
+ if w.slug == slug:
+ return w
+ return None
+
+
+# Module-level singleton — import this everywhere.
+widgets = _WidgetRegistry()
diff --git a/services/widgets/__init__.py b/services/widgets/__init__.py
new file mode 100644
index 0000000..d063a76
--- /dev/null
+++ b/services/widgets/__init__.py
@@ -0,0 +1,22 @@
+"""Per-domain widget registration.
+
+Called once at startup after domain services are registered.
+Only registers widgets for domains that are actually available.
+"""
+from __future__ import annotations
+
+
+def register_all_widgets() -> None:
+ from shared.services.registry import services
+
+ if services.has("calendar"):
+ from .calendar_widgets import register_calendar_widgets
+ register_calendar_widgets()
+
+ if services.has("market"):
+ from .market_widgets import register_market_widgets
+ register_market_widgets()
+
+ if services.has("cart"):
+ from .cart_widgets import register_cart_widgets
+ register_cart_widgets()
diff --git a/services/widgets/calendar_widgets.py b/services/widgets/calendar_widgets.py
new file mode 100644
index 0000000..4c0939f
--- /dev/null
+++ b/services/widgets/calendar_widgets.py
@@ -0,0 +1,91 @@
+"""Calendar-domain widgets: entries nav, calendar links, card entries, account pages."""
+from __future__ import annotations
+
+from shared.contracts.widgets import NavWidget, CardWidget, AccountPageWidget
+from shared.services.widget_registry import widgets
+from shared.services.registry import services
+
+
+# -- container_nav: associated entries ----------------------------------------
+
+async def _nav_entries_context(
+ session, *, container_type, container_id, post_slug, page=1, **kw,
+):
+ entries, has_more = await services.calendar.associated_entries(
+ session, container_type, container_id, page,
+ )
+ return {
+ "entries": entries,
+ "has_more": has_more,
+ "page": page,
+ "post_slug": post_slug,
+ }
+
+
+# -- container_nav: calendar links -------------------------------------------
+
+async def _nav_calendars_context(
+ session, *, container_type, container_id, post_slug, **kw,
+):
+ calendars = await services.calendar.calendars_for_container(
+ session, container_type, container_id,
+ )
+ return {"calendars": calendars, "post_slug": post_slug}
+
+
+# -- container_card: confirmed entries for post listings ----------------------
+
+async def _card_entries_batch(session, post_ids):
+ return await services.calendar.confirmed_entries_for_posts(session, post_ids)
+
+
+# -- account pages: tickets & bookings ---------------------------------------
+
+async def _tickets_context(session, *, user_id, **kw):
+ tickets = await services.calendar.user_tickets(session, user_id=user_id)
+ return {"tickets": tickets}
+
+
+async def _bookings_context(session, *, user_id, **kw):
+ bookings = await services.calendar.user_bookings(session, user_id=user_id)
+ return {"bookings": bookings}
+
+
+# -- registration entry point ------------------------------------------------
+
+def register_calendar_widgets() -> None:
+ widgets.add_container_nav(NavWidget(
+ domain="calendar",
+ order=10,
+ context_fn=_nav_entries_context,
+ template="_widgets/container_nav/calendar_entries.html",
+ ))
+ widgets.add_container_nav(NavWidget(
+ domain="calendar_links",
+ order=20,
+ context_fn=_nav_calendars_context,
+ template="_widgets/container_nav/calendar_links.html",
+ ))
+ widgets.add_container_card(CardWidget(
+ domain="calendar",
+ order=10,
+ batch_fn=_card_entries_batch,
+ context_key="associated_entries",
+ template="_widgets/container_card/calendar_entries.html",
+ ))
+ widgets.add_account_page(AccountPageWidget(
+ domain="calendar",
+ slug="tickets",
+ label="tickets",
+ order=20,
+ context_fn=_tickets_context,
+ template="_types/auth/_tickets_panel.html",
+ ))
+ widgets.add_account_page(AccountPageWidget(
+ domain="calendar",
+ slug="bookings",
+ label="bookings",
+ order=30,
+ context_fn=_bookings_context,
+ template="_types/auth/_bookings_panel.html",
+ ))
diff --git a/services/widgets/cart_widgets.py b/services/widgets/cart_widgets.py
new file mode 100644
index 0000000..f48f5ee
--- /dev/null
+++ b/services/widgets/cart_widgets.py
@@ -0,0 +1,15 @@
+"""Cart-domain widgets: orders link on account page."""
+from __future__ import annotations
+
+from shared.contracts.widgets import AccountNavLink
+from shared.services.widget_registry import widgets
+from shared.infrastructure.urls import cart_url
+
+
+def register_cart_widgets() -> None:
+ widgets.add_account_link(AccountNavLink(
+ label="orders",
+ order=100,
+ href_fn=lambda: cart_url("/orders/"),
+ external=True,
+ ))
diff --git a/services/widgets/market_widgets.py b/services/widgets/market_widgets.py
new file mode 100644
index 0000000..8138391
--- /dev/null
+++ b/services/widgets/market_widgets.py
@@ -0,0 +1,24 @@
+"""Market-domain widgets: marketplace links on container pages."""
+from __future__ import annotations
+
+from shared.contracts.widgets import NavWidget
+from shared.services.widget_registry import widgets
+from shared.services.registry import services
+
+
+async def _nav_markets_context(
+ session, *, container_type, container_id, post_slug, **kw,
+):
+ markets = await services.market.marketplaces_for_container(
+ session, container_type, container_id,
+ )
+ return {"markets": markets, "post_slug": post_slug}
+
+
+def register_market_widgets() -> None:
+ widgets.add_container_nav(NavWidget(
+ domain="market",
+ order=30,
+ context_fn=_nav_markets_context,
+ template="_widgets/container_nav/market_links.html",
+ ))