diff --git a/docs/architecture/persona-template-consolidation.md b/docs/architecture/persona-template-consolidation.md new file mode 100644 index 00000000..f6bf7d55 --- /dev/null +++ b/docs/architecture/persona-template-consolidation.md @@ -0,0 +1,119 @@ +# Persona Template Consolidation + +How to share Jinja templates and JS Alpine factories across the admin / merchant / store personas without losing per-frontend isolation. + +## The Problem + +Most management features (CRUD over loyalty programs, products, orders, etc.) need to be reachable from three frontends: + +- **admin** — platform staff, cross-merchant view +- **merchant** — merchant owner, cross-store view scoped to their merchant +- **store** — store staff, scoped to one store + +The naive approach maintains three separate Jinja templates per feature. Every change has to be made three times. They drift. Bug fixes only land in one. Eventually only one persona has the new column or the new modal. + +## The Pattern + +Two layers: + +### 1. Thin per-persona wrapper (one per frontend, MUST stay separate) + +Each persona has its own template that: + +- `{% extends %}` the persona base (`admin/base.html` / `merchant/base.html` / `store/base.html`). This gives the right sidebar, header, auth widgets, and CSS scope. **Never collapse this to a single base** — the navigation/permissions framing differs by frontend, and merging risks accidentally rendering admin chrome to a store user. +- Renders the page header, any persona-specific intro alerts, and the script tags. +- Sets a handful of Jinja variables describing scope (e.g. `cards_api_prefix='/merchants/loyalty'`, `cards_base_url='/merchants/loyalty/cards'`, `show_store_filter=true`). +- `{% include %}` a shared body partial. + +Target size: **20–60 LOC**. If a wrapper grows much past that, something persona-unique is creeping in and you should extract it or accept it as a per-persona feature (see "Legit exceptions" below). + +### 2. Shared body partial (one per feature, lives in `/templates//shared/`) + +Holds the actual CRUD body — filters, table, form, detail view. Reads the scope variables the wrapper set. Pure Jinja + Alpine bindings. No knowledge of which persona is rendering it. + +Naming convention: + +- `*-list.html` — table + filter UI for a collection (e.g., `cards-list.html`) +- `*-form.html` — create/edit form (e.g., `program-form.html`) +- `*-view.html` — read-only detail page (e.g., `card-detail-view.html`) +- `*-stats.html` — analytics panel (e.g., `analytics-stats.html`) + +## The Scope Contract + +Wrappers pass scope to shared partials via **simple Jinja include variables** — strings and booleans only. No object params, no `persona` enum. + +**String variables** typically carry URLs: + +| Variable | Example | Purpose | +| --- | --- | --- | +| `_api_prefix` | `'/store/loyalty'` | API base for AJAX | +| `_base_url` | `'/merchants/loyalty/cards'` | URL prefix for detail links | +| `cancel_url`, `back_url` | `'/admin/loyalty/programs'` | Where actions navigate back to | + +**Boolean variables** toggle persona-specific UI: + +| Variable | Convention | +| --- | --- | +| `show_X` | Render an optional column / filter / button. Default off. | +| `allow_Y` | Enable a permission-gated action. Default off. | + +The pattern lets the shared partial render the right shape without inspecting `persona`. Adding a new persona is "set new vars and include" — never "add another `{% if persona == ... %}` branch". + +Canonical example: `app/modules/loyalty/templates/loyalty/shared/cards-list.html`. The header comment block documents every variable the partial expects. Wrappers (`loyalty/admin/...`, `loyalty/merchant/cards.html`, `loyalty/store/cards.html`) each set the four variables and include. + +## The Backend Mirror + +The same pattern applies one layer down: + +- **Services** (`app/modules//services/`) accept simple ids and stay scope-agnostic. `program_service.list_programs(db, skip, limit, ...)` — no `persona` argument. +- **Routes** (`app/modules//routes/api/admin.py`, `.../merchant.py`, `.../store.py`) inject scope via auth dependencies (`get_current_admin_api`, `get_merchant_for_current_user`, `get_current_store_api`) and pre-filter the query before calling the service. +- **Schemas** (`app/modules//schemas/`) return the same Pydantic response across all three personas. Optional fields are fine for admin-only enrichment (e.g., `merchant_name`). + +So a feature spans: + +``` +app/modules//services/_service.py # scope-agnostic +app/modules//routes/api/{admin,merchant,store}.py # scope-injecting +app/modules//schemas/.py # same shape per persona +app/modules//templates//shared/-list.html # shared body +app/modules//templates//{admin,merchant,store}/.html # thin wrappers +app/modules//static/shared/js/--list.js # shared Alpine factory +app/modules//static/{admin,merchant,store}/js/-.js # thin factory wrappers +``` + +## Shared Alpine Factory Pattern + +JS mirrors the template split. A shared factory in `static/shared/js/` returns the Alpine component for the shared partial. Per-persona JS files are thin wrappers: + +```js +// app/modules/loyalty/static/merchant/js/loyalty-cards.js +function merchantLoyaltyCards() { + return loyaltyCardsList({ + apiPrefix: '/merchants/loyalty', + baseUrl: '/merchants/loyalty/cards', + showStoreFilter: true, + currentPage: 'cards', + }); +} +``` + +The factory always exposes any state the shared partial might read, even if a particular caller won't render that bit. For example, `loyaltyCardDetailView` always exposes `txLabels: {}` and `pagination: {...}` — the store wrapper populates them, admin/merchant get the empty defaults and the corresponding template blocks don't render. + +## Legit Exceptions (when to keep templates standalone) + +The pattern shines for symmetric CRUD. It breaks down when a persona genuinely has a different *shape* of view, not just different *data*. Heuristic: **if pushing the shape into the shared partial would force an `{% if scope == 'admin' %}` branch in every row, it's a different view — keep it standalone.** + +Real examples in the loyalty module: + +- `loyalty/admin/programs.html` — multi-merchant aggregator table with cross-cutting stats and a create-with-merchant-search modal. Merchant/store views show one program each, not a table of many. Standalone. +- `loyalty/admin/merchant-detail.html` — admin-only tabbed view of one merchant's entire loyalty footprint. No merchant/store equivalent. +- `loyalty/store/terminal.html` — hardware POS terminal UI. Store-only feature. +- `loyalty/store/enroll.html`, storefront templates — customer-facing, different audience entirely. + +Document the reason inline so future-you (or a contributor) doesn't try to "fix" it. + +## Enforcement: `TPL-016` + +The architecture validator's `TPL-016` rule (warning) flags any template under `app/modules//templates//{admin,merchant,store}/*.html` that exceeds **75 LOC** without `{% include %}`-ing a `*/shared/*` partial. This catches new persona templates that drift into copy-paste. Suppress with `{# noqa: TPL-016 #}` for the legit exceptions above. + +The rule is intentionally a warning, not an error — it surfaces drift without breaking CI on first sight. Escalate to error once at least one non-loyalty module has been migrated and we're confident the pattern generalises. diff --git a/docs/proposals/persona-template-consolidation.md b/docs/proposals/persona-template-consolidation.md new file mode 100644 index 00000000..556a624b --- /dev/null +++ b/docs/proposals/persona-template-consolidation.md @@ -0,0 +1,49 @@ +# Persona Template Consolidation + +**Date:** 2026-05-23 +**Implemented:** 2026-05-23 +**Status:** Done +**Motivation:** Maintainers were editing the "same" admin / merchant / store templates three times for every feature change, with predictable drift between personas. The user wanted to know whether the codebase could move to a model where the shared CRUD body lives once and per-persona wrappers handle only the parts that genuinely differ. + +## Discovery + +Loyalty turned out to be a partial success story already: `app/modules/loyalty/templates/loyalty/shared/` held 7 reusable partials (program-form, program-view, cards-list, transactions-list, pins-list, devices-list, analytics-stats), and merchant/admin wrappers were already thin includes. The pattern was working but undocumented, unenforced, and **not consistently applied to store/** — store had inlined two big features (cards.html 171 LOC, card-detail.html 205 LOC) instead of using the shared partials. + +So this turned into a finish + codify + guard job, not a rewrite. + +## Implementation Summary + +- **Phase A (loyalty cleanup).** Migrated `store/cards.html` to use `shared/cards-list.html` (171 → 56 LOC) and `store/card-detail.html` to use `shared/card-detail-view.html` (205 → 55 LOC). JS factories collapsed similarly (166 → 18 and 152 → 20). The shared `card-detail-view.html` partial gained three boolean flags (`show_copy_buttons`, `show_category_column`, `show_pagination`) and the shared `loyaltyCardDetailView` factory gained optional pagination + `txLabels`/`txNotes` config so store could preserve its enhancements. Added `loyalty.shared.card_detail.col_category` to en/fr/de/lb locale files. Fixed a latent bug in the shared factory's `formatDateTime` (was calling `toLocaleDateString` with hour/minute opts that get silently ignored). +- **Phase B (codify).** Wrote `docs/architecture/persona-template-consolidation.md` describing the pattern, the scope contract, the backend mirror, and the legit-exception heuristic. This doc is now the reference for any contributor adding a new CRUD feature. +- **Phase C (guard).** Added architecture rule `TPL-016` (warning) that flags any persona template `> 75 LOC` that doesn't include a `*/shared/*` partial. Wired both check sites in `scripts/validate/validate_architecture.py`. Suppressible with `{# noqa: TPL-016 #}` for the legit exceptions (admin programs aggregator, merchant-detail, store terminal, etc.). + +## Decisions Made + +| # | Decision | Rationale | +| --- | --- | --- | +| 1 | Loyalty cleanup + codify pattern + arch rule | Scope-limited; the pattern is the real deliverable. | +| 2 | Leave `admin/programs.html` standalone | Multi-merchant aggregator + create-with-search modal is fundamentally a different shape from the merchant/store single-program views. Forcing it into shared would mean `if scope == admin` in every row. | +| 3 | JS/CSS variables only (no macro objects, no `persona` enum) | Existing loyalty pattern proven to work; macro objects bloat call sites and `persona` branching defeats the purpose of the partial. | +| 4 | `TPL-016` severity = warning | Lets the rule ship without breaking CI on day one. Escalate to error after at least one other module is migrated. | + +## Files Touched + +- **Templates:** `loyalty/templates/loyalty/store/cards.html`, `loyalty/templates/loyalty/store/card-detail.html`, `loyalty/templates/loyalty/shared/card-detail-view.html` (added flags). +- **JS:** `loyalty/static/store/js/loyalty-cards.js`, `loyalty/static/store/js/loyalty-card-detail.js`, `loyalty/static/shared/js/loyalty-card-detail-view.js` (added config options + pagination). +- **i18n:** `loyalty/locales/{en,fr,de,lb}.json` (added `shared.card_detail.col_category`). +- **Docs:** new `docs/architecture/persona-template-consolidation.md`, this proposal, `mkdocs.yml` nav. +- **Arch rule:** `.architecture-rules/frontend.yaml` (TPL-016), `scripts/validate/validate_architecture.py` (check function + 2 wire sites). + +## Verification + +- `python scripts/validate/validate_architecture.py` — 16 baseline warnings, no new findings. +- `mkdocs build --strict` — clean. +- Smoke test: store/cards and store/card-detail render identically to the pre-migration version (filters, search, pagination, copy buttons, category column, translated transaction labels all preserved). +- Pre-commit hooks (architecture, security, performance, audit, ruff) all green. + +## Out of Scope (Deferred) + +- Applying the pattern to other modules (catalog, billing, etc.). The doc + rule make this a follow-up any contributor can pick up. +- Escalating `TPL-016` from warning to error — wait until ≥1 other module migrates. +- Consolidating the three `analytics.html` wrappers further — they're already minimal given each persona has materially different content (admin wallet status, store advanced charts). +- Settings consolidation — `merchant/settings.html` and `store/settings.html` are different features (read-only PIN/permissions display vs editable program form), not duplicates. diff --git a/mkdocs.yml b/mkdocs.yml index 81e2c76b..08ec70fd 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -61,6 +61,7 @@ nav: - Media Architecture: architecture/media-architecture.md - Metrics Provider Pattern: architecture/metrics-provider-pattern.md - Multi-Platform CMS: architecture/multi-platform-cms.md + - Persona Template Consolidation: architecture/persona-template-consolidation.md - Tenancy Module Migration: architecture/tenancy-module-migration.md - Widget Provider Pattern: architecture/widget-provider-pattern.md - Architecture Violations Status: architecture/architecture-violations-status.md @@ -339,6 +340,7 @@ nav: - Hosting Cascade Delete: proposals/hosting-cascade-delete.md - Hosting Site Creation Fix: proposals/hosting-site-creation-fix.md - Loyalty Go-Live Readiness: proposals/loyalty-go-live-readiness.md + - Persona Template Consolidation: proposals/persona-template-consolidation.md - Loyalty Phase 2 Interfaces: proposals/loyalty-phase2-interfaces-plan.md - Loyalty Program Analysis: proposals/loyalty-program-analysis.md - Merchant Intake Checklist: proposals/merchant-intake-checklist.md