Add widget registry for universal UI decoupling
Introduces a widget system where domains register UI fragments into named slots (container_nav, container_card, account_page, account_link). Host apps iterate widgets generically without naming any domain. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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 %}
|
||||
<div class="relative nav-group">
|
||||
<a href="{{ cart_url('/orders/') }}" class="{{styles.nav_button}}" data-hx-disable>
|
||||
orders
|
||||
</a>
|
||||
</div>
|
||||
{% for link in account_nav_links %}
|
||||
{% if link.external %}
|
||||
<div class="relative nav-group">
|
||||
<a href="{{ link.href_fn() }}" class="{{styles.nav_button}}" data-hx-disable>
|
||||
{{ link.label }}
|
||||
</a>
|
||||
</div>
|
||||
{% else %}
|
||||
{% call links.link(link.href_fn(), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
|
||||
{{ link.label }}
|
||||
{% endcall %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
@@ -69,41 +69,10 @@
|
||||
{% endif %}
|
||||
</a>
|
||||
|
||||
{# Associated Entries - Scrollable list #}
|
||||
{% if post.associated_entries %}
|
||||
<div class="mt-4 mb-2">
|
||||
<h3 class="text-sm font-semibold text-stone-700 mb-2 px-2">Events:</h3>
|
||||
<div class="overflow-x-auto scrollbar-hide" style="scroll-behavior: smooth;">
|
||||
<div class="flex gap-2 px-2">
|
||||
{% 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 + '/' %}
|
||||
<a
|
||||
href="{{ events_url(_entry_path) }}"
|
||||
class="flex flex-col gap-1 px-3 py-2 bg-stone-50 hover:bg-stone-100 rounded border border-stone-200 transition text-sm whitespace-nowrap flex-shrink-0 min-w-[180px]">
|
||||
<div class="font-medium text-stone-900 truncate">{{ entry.name }}</div>
|
||||
<div class="text-xs text-stone-600">
|
||||
{{ entry.start_at.strftime('%a, %b %d') }}
|
||||
</div>
|
||||
<div class="text-xs text-stone-500">
|
||||
{{ entry.start_at.strftime('%H:%M') }}
|
||||
{% if entry.end_at %} – {{ entry.end_at.strftime('%H:%M') }}{% endif %}
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
</style>
|
||||
{% endif %}
|
||||
{# Widget-driven card decorations #}
|
||||
{% for w in widgets.container_cards %}
|
||||
{% include w.template with context %}
|
||||
{% endfor %}
|
||||
|
||||
{% include '_types/blog/_card/at_bar.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 %}
|
||||
<div class="flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"
|
||||
id="entries-calendars-nav-wrapper">
|
||||
{% include '_types/post/admin/_nav_entries.html' %}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<i class="fa fa-chevron-left"></i>
|
||||
</button>
|
||||
|
||||
{# Entries and Calendars container #}
|
||||
{# Widget-driven nav items container #}
|
||||
<div id="associated-items-container"
|
||||
class="overflow-y-auto sm:overflow-x-auto sm:overflow-y-visible scrollbar-hide max-h-[50vh] sm:max-h-none"
|
||||
style="scroll-behavior: smooth;"
|
||||
@@ -22,30 +22,10 @@
|
||||
remove .flex from .entries-nav-arrow
|
||||
end">
|
||||
<div class="flex flex-col sm:flex-row gap-1">
|
||||
{# 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 + '/') %}
|
||||
<a
|
||||
href="{{ local_href }}"
|
||||
class="{{styles.nav_button_less_pad}}">
|
||||
<i class="fa fa-calendar" aria-hidden="true"></i>
|
||||
<div>{{calendar.name}}</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
|
||||
{# Markets #}
|
||||
{% for m in markets %}
|
||||
<a
|
||||
href="{{ market_url('/' + post.slug + '/' + m.slug + '/') }}"
|
||||
class="{{styles.nav_button_less_pad}}">
|
||||
<i class="fa fa-shopping-bag" aria-hidden="true"></i>
|
||||
<div>{{m.name}}</div>
|
||||
</a>
|
||||
{% for wdata in container_nav_widgets %}
|
||||
{% with ctx=wdata.ctx %}
|
||||
{% include wdata.widget.template with context %}
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 %}
|
||||
<div class="mt-4 mb-2">
|
||||
<h3 class="text-sm font-semibold text-stone-700 mb-2 px-2">Events:</h3>
|
||||
<div class="overflow-x-auto scrollbar-hide" style="scroll-behavior: smooth;">
|
||||
<div class="flex gap-2 px-2">
|
||||
{% 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 + '/' %}
|
||||
<a
|
||||
href="{{ events_url(_entry_path) }}"
|
||||
class="flex flex-col gap-1 px-3 py-2 bg-stone-50 hover:bg-stone-100 rounded border border-stone-200 transition text-sm whitespace-nowrap flex-shrink-0 min-w-[180px]">
|
||||
<div class="font-medium text-stone-900 truncate">{{ entry.name }}</div>
|
||||
<div class="text-xs text-stone-600">
|
||||
{{ entry.start_at.strftime('%a, %b %d') }}
|
||||
</div>
|
||||
<div class="text-xs text-stone-500">
|
||||
{{ entry.start_at.strftime('%H:%M') }}
|
||||
{% if entry.end_at %} – {{ entry.end_at.strftime('%H:%M') }}{% endif %}
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
</style>
|
||||
{% endif %}
|
||||
@@ -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 + '/' %}
|
||||
<a
|
||||
href="{{ events_url(_entry_path) }}"
|
||||
class="{{styles.nav_button_less_pad}}"
|
||||
>
|
||||
{% if post.feature_image %}
|
||||
<img src="{{ post.feature_image }}"
|
||||
alt="{{ post.title }}"
|
||||
class="w-8 h-8 rounded object-cover flex-shrink-0" />
|
||||
{% else %}
|
||||
<div class="w-8 h-8 rounded bg-stone-200 flex-shrink-0"></div>
|
||||
{% endif %}
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium truncate">{{ entry.name }}</div>
|
||||
<div class="text-xs text-stone-600 truncate">
|
||||
{{ entry.start_at.strftime('%b %d, %Y at %H:%M') }}
|
||||
{% if entry.end_at %} – {{ entry.end_at.strftime('%H:%M') }}{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
|
||||
{# Load more entries one at a time until container is full #}
|
||||
{% if has_more_entries %}
|
||||
<div id="entries-load-sentinel-{{ current_page }}"
|
||||
hx-get="{{ url_for('blog.post.widget_paginate', slug=post.slug, widget_domain='calendar', page=current_page + 1) }}"
|
||||
hx-trigger="intersect once"
|
||||
hx-swap="beforebegin"
|
||||
_="on htmx:afterRequest trigger scroll on #associated-entries-container"
|
||||
class="flex-shrink-0 w-1">
|
||||
</div>
|
||||
{% endif %}
|
||||
10
browser/templates/_widgets/container_nav/calendar_links.html
Normal file
10
browser/templates/_widgets/container_nav/calendar_links.html
Normal file
@@ -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 + '/') %}
|
||||
<a
|
||||
href="{{ local_href }}"
|
||||
class="{{styles.nav_button_less_pad}}">
|
||||
<i class="fa fa-calendar" aria-hidden="true"></i>
|
||||
<div>{{calendar.name}}</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
@@ -0,0 +1,9 @@
|
||||
{# Market link nav items — loaded via widget registry #}
|
||||
{% for m in ctx.markets %}
|
||||
<a
|
||||
href="{{ market_url('/' + post.slug + '/' + m.slug + '/') }}"
|
||||
class="{{styles.nav_button_less_pad}}">
|
||||
<i class="fa fa-shopping-bag" aria-hidden="true"></i>
|
||||
<div>{{m.name}}</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
@@ -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):
|
||||
|
||||
49
contracts/widgets.py
Normal file
49
contracts/widgets.py
Normal file
@@ -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/<slug>/."""
|
||||
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
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
90
services/widget_registry.py
Normal file
90
services/widget_registry.py
Normal file
@@ -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()
|
||||
22
services/widgets/__init__.py
Normal file
22
services/widgets/__init__.py
Normal file
@@ -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()
|
||||
91
services/widgets/calendar_widgets.py
Normal file
91
services/widgets/calendar_widgets.py
Normal file
@@ -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",
|
||||
))
|
||||
15
services/widgets/cart_widgets.py
Normal file
15
services/widgets/cart_widgets.py
Normal file
@@ -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,
|
||||
))
|
||||
24
services/widgets/market_widgets.py
Normal file
24
services/widgets/market_widgets.py
Normal file
@@ -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",
|
||||
))
|
||||
Reference in New Issue
Block a user