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 %} - +{% for link in account_nav_links %} + {% if link.external %} + + {% else %} + {% call links.link(link.href_fn(), hx_select_search, select_colours, True, aclass=styles.nav_button) %} + {{ link.label }} + {% endcall %} + {% endif %} +{% endfor %} diff --git a/browser/templates/_types/blog/_card.html b/browser/templates/_types/blog/_card.html index fd3743a..f075af1 100644 --- a/browser/templates/_types/blog/_card.html +++ b/browser/templates/_types/blog/_card.html @@ -69,41 +69,10 @@ {% endif %} - {# Associated Entries - Scrollable list #} - {% if post.associated_entries %} -
-

Events:

-
-
- {% for entry in post.associated_entries %} - {% 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 + '/' %} - -
{{ entry.name }}
-
- {{ entry.start_at.strftime('%a, %b %d') }} -
-
- {{ entry.start_at.strftime('%H:%M') }} - {% if entry.end_at %} – {{ entry.end_at.strftime('%H:%M') }}{% endif %} -
-
- {% endfor %} -
-
-
- - - {% endif %} + {# Widget-driven card decorations #} + {% for w in widgets.container_cards %} + {% include w.template with context %} + {% endfor %} {% include '_types/blog/_card/at_bar.html' %} diff --git a/browser/templates/_types/post/_nav.html b/browser/templates/_types/post/_nav.html index a124ae2..db8cdc4 100644 --- a/browser/templates/_types/post/_nav.html +++ b/browser/templates/_types/post/_nav.html @@ -1,6 +1,6 @@ {% import 'macros/links.html' as links %} - {# Associated Entries and Calendars - vertical on mobile, horizontal with arrows on desktop #} - {% if (associated_entries and associated_entries.entries) or calendars %} + {# Widget-driven container nav — entries, calendars, markets #} + {% if container_nav_widgets %}
{% 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 #}
- {# Associated Entries #} - {% if associated_entries and associated_entries.entries %} - {% include '_types/post/_entry_items.html' with context %} - {% endif %} - - {# Calendars #} - {% for calendar in calendars %} - {% set local_href=events_url('/' + post.slug + '/calendars/' + calendar.slug + '/') %} - - -
{{calendar.name}}
-
- {% endfor %} - - {# Markets #} - {% for m in markets %} - - -
{{m.name}}
-
+ {% for wdata in container_nav_widgets %} + {% with ctx=wdata.ctx %} + {% include wdata.widget.template with context %} + {% endwith %} {% endfor %}
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 %} +
+

Events:

+
+
+ {% for entry in widget_entries %} + {% 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 + '/' %} + +
{{ entry.name }}
+
+ {{ entry.start_at.strftime('%a, %b %d') }} +
+
+ {{ entry.start_at.strftime('%H:%M') }} + {% if entry.end_at %} – {{ entry.end_at.strftime('%H:%M') }}{% endif %} +
+
+ {% endfor %} +
+
+
+ + +{% 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 %} + {{ post.title }} + {% 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", + ))