Compare commits

...

3 Commits

Author SHA1 Message Date
f9a15deed7 feat(arch-rules): TPL-016 flags large persona templates that skip shared/
Some checks failed
CI / ruff (push) Successful in 17s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has started running
Architecture rule that warns on any template under
app/modules/<m>/templates/<m>/{admin,merchant,store}/*.html that
exceeds 75 LOC AND does not {% include %} a `*/shared/*` partial.

Catches new persona-specific templates that inline body content rather
than sharing it with sibling personas (the project-wide pain point that
prompted the persona-template-consolidation work).

- Rule definition in .architecture-rules/frontend.yaml at warning
  severity. Suppressible per-file with `{# noqa: TPL-016 #}`.
- Check function `_check_persona_template_shared_include` in
  scripts/validate/validate_architecture.py, wired at both template
  validation sites (full scan + per-file -f mode).
- Loyalty was migrated under this rule and reports clean (5 legit
  exceptions carry noqa with reason).
- First run surfaces ~110 warnings across other modules — the
  migration backlog. Severity stays at warning until at least one
  non-loyalty module is migrated, then escalate to error.

See docs/architecture/persona-template-consolidation.md for the
pattern this rule guards.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 23:11:21 +02:00
f82dce30ca docs(architecture): persona template consolidation pattern + proposal
Document how admin/merchant/store templates share a single shared/ body
partial while keeping their three separate base templates. Covers:

- The wrapper/partial split and why the three base templates must stay
  separate (nav + permissions isolation).
- The scope contract: pass strings + booleans only, no macro objects,
  no `persona` enum.
- The backend mirror: services scope-agnostic, routes inject scope via
  auth deps, same Pydantic shape across personas.
- Legit exceptions and the heuristic for when to keep a template
  standalone (multi-tenant aggregators, persona-unique features).
- Forward reference to the TPL-016 architecture rule.

Adds both docs to mkdocs nav under Architecture and Proposals
sections.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 23:10:29 +02:00
c661c1e394 refactor(loyalty): migrate store cards + card-detail to shared partials
The store frontend was inlining two CRUD bodies that already had shared
equivalents under loyalty/templates/loyalty/shared/. Migrate them to the
established pattern (thin per-persona wrapper + shared body partial).

- store/cards.html: 171 -> 56 LOC. Now sets cards_api_prefix /
  cards_base_url / show_store_filter=false and includes
  shared/cards-list.html (same partial merchant already uses).
- store/card-detail.html: 205 -> 55 LOC. Includes
  shared/card-detail-view.html with new flags show_copy_buttons,
  show_category_column, show_pagination so its extras survive.
- shared/card-detail-view.html: gain those three boolean flags plus
  reads txLabels/txNotes from the Alpine factory (empty defaults so
  admin/merchant callers still get raw values).
- shared/loyalty-card-detail-view.js: factory accepts txLabels, txNotes,
  paginate config; exposes pagination state unconditionally so the
  partial's pagination macro resolves; fix latent bug where
  formatDateTime called toLocaleDateString with ignored hour/minute
  opts.
- store/loyalty-cards.js + loyalty-card-detail.js: now thin wrappers
  calling the shared factories.
- locales/{en,fr,de,lb}.json: add loyalty.shared.card_detail.col_category
  for the new optional column.
- Add `noqa: TPL-016` on the 5 legit-exception loyalty templates
  (admin/programs aggregator, admin/merchant-settings, admin/wallet-debug,
  store/enroll, store/terminal) ahead of the rule landing in a follow-up
  commit. Note the per-file reason inline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 23:09:29 +02:00
20 changed files with 4259 additions and 4415 deletions

View File

@@ -644,6 +644,37 @@ template_rules:
exceptions:
- "shared/macros/headers.html"
- id: "TPL-016"
name: "Persona templates >75 LOC must include a shared/ partial"
severity: "warning"
description: |
Any persona template (under app/modules/<m>/templates/<m>/{admin,merchant,store}/*.html)
that exceeds 75 LOC AND does not {% include %} a `*/shared/*` partial is likely
duplicating body content that should live in a shared partial used by all three
personas. See docs/architecture/persona-template-consolidation.md for the pattern.
RIGHT (thin wrapper + shared body):
{% extends "store/base.html" %}
...page-header + loading/error...
{% set cards_api_prefix = '/store/loyalty' %}
{% set cards_base_url = '/store/' ~ store_code ~ '/loyalty/cards' %}
{% include 'loyalty/shared/cards-list.html' %}
WRONG (inlined table + filters that already exist in shared/):
{% extends "store/base.html" %}
...200 lines of inline <table>/filters/pagination identical to merchant's...
Suppress for legit exceptions (admin multi-merchant aggregator, store-only
hardware UI, persona-unique tabbed dashboard) with `{# noqa: TPL-016 #}`
anywhere in the file.
pattern:
file_pattern: "app/modules/*/templates/*/{admin,merchant,store}/*.html"
threshold_loc: 75
required_pattern: "{% include .*/shared/.*"
exceptions:
- "base.html"
- "partials/"
- id: "TPL-014"
name: "Use new modal_simple macro API with call block"
severity: "error"

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -8,9 +8,12 @@ const loyaltyCardDetailViewLog = window.LogConfig.loggers.loyaltyCardDetailView
* Factory that returns an Alpine.js data object for a single loyalty card detail view.
*
* @param {Object} config
* @param {string} config.apiPrefix - API path prefix, e.g. '/store/loyalty'
* @param {string} config.backUrl - URL for the "back to list" navigation
* @param {string} config.currentPage - Alpine currentPage identifier
* @param {string} config.apiPrefix - API path prefix, e.g. '/store/loyalty'
* @param {string} config.backUrl - URL for the "back to list" navigation
* @param {string} config.currentPage - Alpine currentPage identifier
* @param {boolean} [config.paginate] - Enable paginated transaction history (default false, loads up to 50)
* @param {Object} [config.txLabels] - Map of transaction_type -> translated label (default {})
* @param {Object} [config.txNotes] - Map of raw_note -> translated note (default {})
*/
function loyaltyCardDetailView(config) {
const guardKey = '_loyaltyCardDetailView_' + config.currentPage + '_initialized';
@@ -24,6 +27,14 @@ function loyaltyCardDetailView(config) {
card: null,
transactions: [],
// Translation maps (defaults to empty so admin/merchant get raw values)
txLabels: config.txLabels || {},
txNotes: config.txNotes || {},
// Pagination (always present so the shared template's pagination macro
// resolves; only the wrapper that sets show_pagination renders it)
pagination: { page: 1, per_page: 20, total: 0 },
// State
loading: false,
error: null,
@@ -41,7 +52,6 @@ function loyaltyCardDetailView(config) {
await parentInit.call(this);
}
// Extract cardId from URL: find 'cards' segment and take the next segment
this.cardId = this._extractCardIdFromUrl();
if (!this.cardId) {
this.error = I18n.t('loyalty.errors.card_not_found');
@@ -49,6 +59,12 @@ function loyaltyCardDetailView(config) {
return;
}
if (config.paginate && window.PlatformSettings) {
try {
this.pagination.per_page = await window.PlatformSettings.getRowsPerPage();
} catch (e) { /* keep default */ }
}
loyaltyCardDetailViewLog.info('Card ID extracted:', this.cardId);
await this.loadData();
loyaltyCardDetailViewLog.info('=== LOYALTY CARD DETAIL VIEW INITIALIZATION COMPLETE ===');
@@ -100,21 +116,65 @@ function loyaltyCardDetailView(config) {
}
},
async loadTransactions() {
async loadTransactions(page = 1) {
try {
const response = await apiClient.get(
config.apiPrefix + '/cards/' + this.cardId + '/transactions?limit=50'
);
let url;
if (config.paginate) {
const skip = (page - 1) * this.pagination.per_page;
url = config.apiPrefix + '/cards/' + this.cardId
+ '/transactions?skip=' + skip + '&limit=' + this.pagination.per_page;
} else {
url = config.apiPrefix + '/cards/' + this.cardId + '/transactions?limit=50';
}
const response = await apiClient.get(url);
if (response) {
this.transactions = Array.isArray(response)
? response
: (response.transactions || []);
if (config.paginate) {
this.pagination.total = response.total || 0;
this.pagination.page = page;
}
}
} catch (error) {
loyaltyCardDetailViewLog.warn('Failed to load transactions:', error.message);
}
},
// Standard pagination interface (matches shared pagination macro).
// Always present; only rendered when the template sets show_pagination.
get totalPages() {
return Math.max(1, Math.ceil(this.pagination.total / this.pagination.per_page));
},
get startIndex() {
return this.pagination.total === 0
? 0
: (this.pagination.page - 1) * this.pagination.per_page + 1;
},
get endIndex() {
return Math.min(this.pagination.page * this.pagination.per_page, this.pagination.total);
},
get pageNumbers() {
const pages = [];
const total = this.totalPages;
const current = this.pagination.page;
if (total <= 7) {
for (let i = 1; i <= total; i++) pages.push(i);
} else {
pages.push(1);
if (current > 3) pages.push('...');
const start = Math.max(2, current - 1);
const end = Math.min(total - 1, current + 1);
for (let i = start; i <= end; i++) pages.push(i);
if (current < total - 2) pages.push('...');
pages.push(total);
}
return pages;
},
previousPage() { if (this.pagination.page > 1) this.loadTransactions(this.pagination.page - 1); },
nextPage() { if (this.pagination.page < this.totalPages) this.loadTransactions(this.pagination.page + 1); },
goToPage(p) { if (p !== '...' && p >= 1 && p <= this.totalPages) this.loadTransactions(p); },
// Formatting helpers
formatNumber(num) {
return num == null ? '0' : new Intl.NumberFormat('en-US').format(num);
@@ -136,7 +196,7 @@ function loyaltyCardDetailView(config) {
formatDateTime(dateString) {
if (!dateString) return 'Never';
try {
return new Date(dateString).toLocaleDateString('en-US', {
return new Date(dateString).toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',

View File

@@ -1,148 +1,17 @@
// app/modules/loyalty/static/store/js/loyalty-card-detail.js
// noqa: js-006 - async init pattern is safe, loadData has try/catch
// Store wrapper around the shared loyaltyCardDetailView factory.
const loyaltyCardDetailLog = window.LogConfig.loggers.loyaltyCardDetail || window.LogConfig.createLogger('loyaltyCardDetail');
function storeLoyaltyCardDetail() {
return {
...data(),
return loyaltyCardDetailView({
apiPrefix: '/store/loyalty',
backUrl: '/store/' + (window.STORE_CODE || '') + '/loyalty/cards',
currentPage: 'cards',
cardId: null,
card: null,
transactions: [],
pagination: { page: 1, per_page: 20, total: 0 },
txLabels: window._cardDetailLabels?.txLabels || {},
txNotes: window._cardDetailLabels?.txNotes || {},
loading: false,
error: null,
async init() {
loyaltyCardDetailLog.info('=== LOYALTY CARD DETAIL PAGE INITIALIZING ===');
if (window._loyaltyCardDetailInitialized) return;
window._loyaltyCardDetailInitialized = true;
// IMPORTANT: Call parent init first to set storeCode from URL
const parentInit = data().init;
if (parentInit) {
await parentInit.call(this);
}
// Extract card ID from URL
const pathParts = window.location.pathname.split('/');
const cardsIndex = pathParts.indexOf('cards');
if (cardsIndex !== -1 && pathParts[cardsIndex + 1]) {
this.cardId = parseInt(pathParts[cardsIndex + 1]);
}
if (!this.cardId) {
this.error = 'Invalid card ID';
return;
}
// Use platform pagination setting if available
if (window.PlatformSettings) {
try {
this.pagination.per_page = await window.PlatformSettings.getRowsPerPage();
} catch (e) { /* use default */ }
}
await this.loadData();
loyaltyCardDetailLog.info('=== LOYALTY CARD DETAIL PAGE INITIALIZATION COMPLETE ===');
},
async loadData() {
this.loading = true;
this.error = null;
try {
await Promise.all([
this.loadCard(),
this.loadTransactions()
]);
} catch (error) {
loyaltyCardDetailLog.error('Failed to load data:', error);
this.error = error.message;
} finally {
this.loading = false;
}
},
async loadCard() {
const response = await apiClient.get(`/store/loyalty/cards/${this.cardId}`);
if (response) {
this.card = response;
loyaltyCardDetailLog.info('Card loaded:', this.card.card_number);
}
},
async loadTransactions(page = 1) {
try {
const skip = (page - 1) * this.pagination.per_page;
const response = await apiClient.get(
`/store/loyalty/cards/${this.cardId}/transactions?skip=${skip}&limit=${this.pagination.per_page}`
);
if (response && response.transactions) {
this.transactions = response.transactions;
this.pagination.total = response.total || 0;
this.pagination.page = page;
loyaltyCardDetailLog.info(`Loaded ${this.transactions.length} of ${this.pagination.total} transactions (page ${page})`);
}
} catch (error) {
loyaltyCardDetailLog.warn('Failed to load transactions:', error.message);
}
},
// Standard pagination interface (matches shared pagination macro)
get totalPages() {
return Math.max(1, Math.ceil(this.pagination.total / this.pagination.per_page));
},
get startIndex() {
if (this.pagination.total === 0) return 0;
return (this.pagination.page - 1) * this.pagination.per_page + 1;
},
get endIndex() {
return Math.min(this.pagination.page * this.pagination.per_page, this.pagination.total);
},
get pageNumbers() {
const pages = [];
for (let i = 1; i <= this.totalPages; i++) {
if (i === 1 || i === this.totalPages || Math.abs(i - this.pagination.page) <= 1) {
pages.push(i);
} else if (pages[pages.length - 1] !== '...') {
pages.push('...');
}
}
return pages;
},
previousPage() { if (this.pagination.page > 1) this.loadTransactions(this.pagination.page - 1); },
nextPage() { if (this.pagination.page < this.totalPages) this.loadTransactions(this.pagination.page + 1); },
goToPage(p) { if (p >= 1 && p <= this.totalPages) this.loadTransactions(p); },
formatNumber(num) {
return num == null ? '0' : new Intl.NumberFormat('en-US').format(num);
},
formatDate(dateString) {
if (!dateString) return '-';
try {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric', month: 'short', day: 'numeric'
});
} catch (e) { return dateString; }
},
formatDateTime(dateString) {
if (!dateString) return '-';
try {
return new Date(dateString).toLocaleString('en-US', {
year: 'numeric', month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit'
});
} catch (e) { return dateString; }
}
};
paginate: true,
txLabels: (window._cardDetailLabels && window._cardDetailLabels.txLabels) || {},
txNotes: (window._cardDetailLabels && window._cardDetailLabels.txNotes) || {},
});
}
if (!window.LogConfig.loggers.loyaltyCardDetail) {

View File

@@ -1,162 +1,15 @@
// app/modules/loyalty/static/store/js/loyalty-cards.js
// noqa: js-006 - async init pattern is safe, loadData has try/catch
// Store wrapper around the shared loyaltyCardsList factory.
const loyaltyCardsLog = window.LogConfig.loggers.loyaltyCards || window.LogConfig.createLogger('loyaltyCards');
function storeLoyaltyCards() {
return {
...data(),
return loyaltyCardsList({
apiPrefix: '/store/loyalty',
baseUrl: '/store/' + (window.STORE_CODE || '') + '/loyalty/cards',
showStoreFilter: false,
currentPage: 'cards',
// Data
cards: [],
program: null,
stats: {
total_cards: 0,
active_cards: 0,
new_this_month: 0,
total_points_balance: 0
},
// Filters
filters: {
search: '',
status: ''
},
// Pagination
pagination: {
page: 1,
per_page: 20,
total: 0,
pages: 0
},
// State
loading: false,
error: null,
async init() {
loyaltyCardsLog.info('=== LOYALTY CARDS PAGE INITIALIZING ===');
if (window._loyaltyCardsInitialized) return;
window._loyaltyCardsInitialized = true;
// IMPORTANT: Call parent init first to set storeCode from URL
const parentInit = data().init;
if (parentInit) {
await parentInit.call(this);
}
if (window.PlatformSettings) {
this.pagination.per_page = await window.PlatformSettings.getRowsPerPage();
}
await this.loadData();
loyaltyCardsLog.info('=== LOYALTY CARDS PAGE INITIALIZATION COMPLETE ===');
},
async loadData() {
this.loading = true;
this.error = null;
try {
await this.loadProgram();
if (this.program) {
await Promise.all([this.loadCards(), this.loadStats()]);
}
} catch (error) {
loyaltyCardsLog.error('Failed to load data:', error);
this.error = error.message;
} finally {
this.loading = false;
}
},
async loadProgram() {
try {
const response = await apiClient.get('/store/loyalty/program');
if (response) this.program = response;
} catch (error) {
if (error.status !== 404) throw error;
}
},
async loadCards() {
const params = new URLSearchParams();
params.append('skip', (this.pagination.page - 1) * this.pagination.per_page);
params.append('limit', this.pagination.per_page);
if (this.filters.search) params.append('search', this.filters.search);
if (this.filters.status) params.append('is_active', this.filters.status === 'active');
const response = await apiClient.get(`/store/loyalty/cards?${params}`);
if (response) {
this.cards = response.cards || [];
this.pagination.total = response.total || 0;
this.pagination.pages = Math.ceil(this.pagination.total / this.pagination.per_page);
}
},
async loadStats() {
try {
const response = await apiClient.get('/store/loyalty/stats');
if (response) {
this.stats = {
total_cards: response.total_cards || 0,
active_cards: response.active_cards || 0,
new_this_month: response.new_this_month || 0,
total_points_balance: response.total_points_balance || 0
};
}
} catch (error) {
loyaltyCardsLog.warn('Failed to load stats:', error.message);
}
},
debouncedSearch() {
if (this._searchTimeout) clearTimeout(this._searchTimeout);
this._searchTimeout = setTimeout(() => {
this.pagination.page = 1;
this.loadCards();
}, 300);
},
applyFilter() {
this.pagination.page = 1;
this.loadCards();
},
get totalPages() { return this.pagination.pages; },
get startIndex() { return this.pagination.total === 0 ? 0 : (this.pagination.page - 1) * this.pagination.per_page + 1; },
get endIndex() { const end = this.pagination.page * this.pagination.per_page; return end > this.pagination.total ? this.pagination.total : end; },
get pageNumbers() {
const pages = [];
const total = this.totalPages;
const current = this.pagination.page;
if (total <= 7) { for (let i = 1; i <= total; i++) pages.push(i); }
else {
pages.push(1);
if (current > 3) pages.push('...');
const start = Math.max(2, current - 1);
const end = Math.min(total - 1, current + 1);
for (let i = start; i <= end; i++) pages.push(i);
if (current < total - 2) pages.push('...');
pages.push(total);
}
return pages;
},
previousPage() { if (this.pagination.page > 1) { this.pagination.page--; this.loadCards(); } },
nextPage() { if (this.pagination.page < this.totalPages) { this.pagination.page++; this.loadCards(); } },
goToPage(num) { if (num !== '...' && num >= 1 && num <= this.totalPages) { this.pagination.page = num; this.loadCards(); } },
formatNumber(num) { return num == null ? '0' : new Intl.NumberFormat('en-US').format(num); },
formatDate(dateString) {
if (!dateString) return 'Never';
try {
return new Date(dateString).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
} catch (e) { return dateString; }
}
};
});
}
if (!window.LogConfig.loggers.loyaltyCards) {

View File

@@ -1,4 +1,5 @@
{# app/modules/loyalty/templates/loyalty/admin/merchant-settings.html #}
{# noqa: TPL-016 — admin-only merchant config aggregator, no per-persona equivalent #}
{% extends "admin/base.html" %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% from 'shared/macros/headers.html' import detail_page_header %}

View File

@@ -1,4 +1,5 @@
{# app/modules/loyalty/templates/loyalty/admin/programs.html #}
{# noqa: TPL-016 — admin multi-merchant programs aggregator: different shape from the single-program merchant/store views #}
{% extends "admin/base.html" %}
{% from 'shared/macros/pagination.html' import pagination %}
{% from 'shared/macros/headers.html' import page_header %}

View File

@@ -1,4 +1,5 @@
{# app/modules/loyalty/templates/loyalty/admin/wallet-debug.html #}
{# noqa: TPL-016 — admin-only Apple/Google wallet diagnostic tool, no persona equivalent #}
{% extends "admin/base.html" %}
{% from 'shared/macros/headers.html' import page_header %}
{% from 'shared/macros/tables.html' import table_wrapper, table_header %}

View File

@@ -3,9 +3,17 @@
Shared loyalty card detail view partial. Set these variables before including:
- card_detail_api_prefix (str): API base URL for card data
- card_detail_back_url (str): URL for the back button
- show_copy_buttons (bool, default false): Show clipboard-copy buttons next to customer name/email/phone/card_number
- show_category_column (bool, default false): Add a "Category" column to the transaction history table
- show_pagination (bool, default false): Render pagination after the transaction history table
The factory exposes `txLabels` (object: transaction_type -> translated label) and
`txNotes` (object: raw_note -> translated note) — defaults to {} so admin/merchant
callers that don't provide translations get the raw values shown.
#}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% from 'shared/macros/tables.html' import table_wrapper, table_header %}
{% from 'shared/macros/pagination.html' import pagination %}
<!-- Quick Stats -->
<div class="grid gap-6 mb-8 md:grid-cols-4">
@@ -58,15 +66,42 @@
<div class="space-y-3">
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.shared.card_detail.name') }}</p>
{% if show_copy_buttons %}
<div class="flex items-center gap-2">
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="card?.customer_name || '-'">-</p>
<button x-show="card?.customer_name" @click="Utils.copyToClipboard(card.customer_name)" type="button" aria-label="Copy" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<span x-html="$icon('clipboard-copy', 'w-3.5 h-3.5')"></span>
</button>
</div>
{% else %}
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="card?.customer_name || '-'">-</p>
{% endif %}
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.shared.card_detail.email') }}</p>
{% if show_copy_buttons %}
<div class="flex items-center gap-2">
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="card?.customer_email || '-'">-</p>
<button x-show="card?.customer_email" @click="Utils.copyToClipboard(card.customer_email)" type="button" aria-label="Copy" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<span x-html="$icon('clipboard-copy', 'w-3.5 h-3.5')"></span>
</button>
</div>
{% else %}
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="card?.customer_email || '-'">-</p>
{% endif %}
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.shared.card_detail.phone') }}</p>
{% if show_copy_buttons %}
<div class="flex items-center gap-2">
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="card?.customer_phone || '-'">-</p>
<button x-show="card?.customer_phone" @click="Utils.copyToClipboard(card.customer_phone)" type="button" aria-label="Copy" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<span x-html="$icon('clipboard-copy', 'w-3.5 h-3.5')"></span>
</button>
</div>
{% else %}
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="card?.customer_phone || '-'">-</p>
{% endif %}
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.shared.card_detail.birthday') }}</p>
@@ -84,7 +119,16 @@
<div class="space-y-3">
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.shared.card_detail.card_number') }}</p>
{% if show_copy_buttons %}
<div class="flex items-center gap-2">
<p class="text-sm font-mono text-gray-700 dark:text-gray-300" x-text="card?.card_number">-</p>
<button x-show="card?.card_number" @click="Utils.copyToClipboard(card.card_number)" type="button" aria-label="Copy" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<span x-html="$icon('clipboard-copy', 'w-3.5 h-3.5')"></span>
</button>
</div>
{% else %}
<p class="text-sm font-mono text-gray-700 dark:text-gray-300" x-text="card?.card_number">-</p>
{% endif %}
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.shared.card_detail.status') }}</p>
@@ -110,12 +154,17 @@
<span x-html="$icon('clock', 'inline w-5 h-5 mr-2')"></span>
{{ _('loyalty.shared.card_detail.transaction_history') }}
</h3>
{% set _tx_colspan = 6 if show_category_column else 5 %}
{% call table_wrapper() %}
{% if show_category_column %}
{{ table_header([_('loyalty.shared.card_detail.col_date'), _('loyalty.shared.card_detail.col_type'), _('loyalty.shared.card_detail.col_points'), _('loyalty.shared.card_detail.col_category'), _('loyalty.shared.card_detail.col_location'), _('loyalty.shared.card_detail.col_notes')]) }}
{% else %}
{{ table_header([_('loyalty.shared.card_detail.col_date'), _('loyalty.shared.card_detail.col_type'), _('loyalty.shared.card_detail.col_points'), _('loyalty.shared.card_detail.col_location'), _('loyalty.shared.card_detail.col_notes')]) }}
{% endif %}
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<template x-if="transactions.length === 0">
<tr>
<td colspan="5" class="px-4 py-6 text-center text-gray-500 dark:text-gray-400">
<td colspan="{{ _tx_colspan }}" class="px-4 py-6 text-center text-gray-500 dark:text-gray-400">
<div class="flex flex-col items-center">
<span x-html="$icon('clock', 'w-12 h-12 mb-2 text-gray-300')"></span>
<p class="font-medium">{{ _('loyalty.shared.card_detail.no_transactions') }}</p>
@@ -132,15 +181,22 @@
'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100': tx.points_delta > 0,
'text-orange-700 bg-orange-100 dark:bg-orange-700 dark:text-orange-100': tx.points_delta < 0
}"
x-text="tx.transaction_type.replace(/_/g, ' ')"></span>
x-text="txLabels[tx.transaction_type] || tx.transaction_type.replace(/_/g, ' ')"></span>
</td>
<td class="px-4 py-3 text-sm font-medium"
:class="tx.points_delta > 0 ? 'text-green-600' : 'text-orange-600'"
x-text="(tx.points_delta > 0 ? '+' : '') + formatNumber(tx.points_delta)"></td>
{% if show_category_column %}
<td class="px-4 py-3 text-sm text-gray-500" x-text="tx.category_names?.join(', ') || '-'"></td>
{% endif %}
<td class="px-4 py-3 text-sm" x-text="tx.store_name || '-'"></td>
<td class="px-4 py-3 text-sm text-gray-500" x-text="tx.notes || '-'"></td>
<td class="px-4 py-3 text-sm text-gray-500" x-text="txNotes[tx.notes] || tx.notes || '-'"></td>
</tr>
</template>
</tbody>
{% endcall %}
{% if show_pagination %}
{{ pagination() }}
{% endif %}
</div>

View File

@@ -2,8 +2,6 @@
{% extends "store/base.html" %}
{% from 'shared/macros/headers.html' import detail_page_header %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% from 'shared/macros/tables.html' import table_wrapper, table_header %}
{% from 'shared/macros/pagination.html' import pagination %}
{% block title %}{{ _('loyalty.store.card_detail.title') }}{% endblock %}
@@ -20,168 +18,19 @@
{{ error_state(_('loyalty.store.card_detail.error_loading')) }}
<div x-show="!loading && card">
<!-- Quick Stats -->
<div class="grid gap-6 mb-8 md:grid-cols-4">
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-purple-500 bg-purple-100 rounded-full dark:text-purple-100 dark:bg-purple-500">
<span x-html="$icon('currency-dollar', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">{{ _('loyalty.store.card_detail.points_balance') }}</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="formatNumber(card?.points_balance)">0</p>
</div>
</div>
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-green-500 bg-green-100 rounded-full dark:text-green-100 dark:bg-green-500">
<span x-html="$icon('trending-up', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">{{ _('loyalty.store.card_detail.total_earned') }}</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="formatNumber(card?.total_points_earned)">0</p>
</div>
</div>
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-orange-500 bg-orange-100 rounded-full dark:text-orange-100 dark:bg-orange-500">
<span x-html="$icon('gift', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">{{ _('loyalty.store.card_detail.total_redeemed') }}</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="formatNumber(card?.total_points_redeemed)">0</p>
</div>
</div>
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-blue-500 bg-blue-100 rounded-full dark:text-blue-100 dark:bg-blue-500">
<span x-html="$icon('calendar', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">{{ _('loyalty.store.card_detail.member_since') }}</p>
<p class="text-sm font-semibold text-gray-700 dark:text-gray-200" x-text="formatDate(card?.created_at)">-</p>
</div>
</div>
</div>
<div class="grid gap-6 mb-8 md:grid-cols-2">
<!-- Customer Info -->
<div class="px-4 py-5 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
<span x-html="$icon('user', 'inline w-5 h-5 mr-2')"></span>
{{ _('loyalty.store.card_detail.customer_information') }}
</h3>
<div class="space-y-3">
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.store.card_detail.name') }}</p>
<div class="flex items-center gap-2">
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="card?.customer_name || '-'">-</p>
<button x-show="card?.customer_name" @click="Utils.copyToClipboard(card.customer_name)" type="button" aria-label="Copy" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<span x-html="$icon('clipboard-copy', 'w-3.5 h-3.5')"></span>
</button>
</div>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.store.card_detail.email') }}</p>
<div class="flex items-center gap-2">
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="card?.customer_email || '-'">-</p>
<button x-show="card?.customer_email" @click="Utils.copyToClipboard(card.customer_email)" type="button" aria-label="Copy" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<span x-html="$icon('clipboard-copy', 'w-3.5 h-3.5')"></span>
</button>
</div>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.store.card_detail.phone') }}</p>
<div class="flex items-center gap-2">
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="card?.customer_phone || '-'">-</p>
<button x-show="card?.customer_phone" @click="Utils.copyToClipboard(card.customer_phone)" type="button" aria-label="Copy" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<span x-html="$icon('clipboard-copy', 'w-3.5 h-3.5')"></span>
</button>
</div>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.store.card_detail.birthday') }}</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="card?.customer_birthday || '-'">-</p>
</div>
</div>
</div>
<!-- Card Info -->
<div class="px-4 py-5 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
<span x-html="$icon('credit-card', 'inline w-5 h-5 mr-2')"></span>
{{ _('loyalty.store.card_detail.card_details') }}
</h3>
<div class="space-y-3">
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.store.card_detail.card_number') }}</p>
<div class="flex items-center gap-2">
<p class="text-sm font-mono text-gray-700 dark:text-gray-300" x-text="card?.card_number">-</p>
<button x-show="card?.card_number" @click="Utils.copyToClipboard(card.card_number)" type="button" aria-label="Copy" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<span x-html="$icon('clipboard-copy', 'w-3.5 h-3.5')"></span>
</button>
</div>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.store.card_detail.status') }}</p>
<span class="px-2 py-1 text-xs font-semibold rounded-full"
:class="card?.is_active ? 'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100' : 'text-gray-700 bg-gray-100 dark:bg-gray-700 dark:text-gray-100'"
x-text="card?.is_active ? $t('loyalty.common.active') : $t('loyalty.common.inactive')"></span>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.store.card_detail.last_activity') }}</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="formatDate(card?.last_activity_at) || 'Never'">-</p>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.store.card_detail.enrolled_at') }}</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="card?.enrolled_at_store_name || 'Unknown'">-</p>
</div>
</div>
</div>
</div>
<!-- Transaction History -->
<div class="px-4 py-5 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
<span x-html="$icon('clock', 'inline w-5 h-5 mr-2')"></span>
{{ _('loyalty.store.card_detail.transaction_history') }}
</h3>
{% call table_wrapper() %}
{{ table_header([_('loyalty.store.card_detail.col_date'), _('loyalty.store.card_detail.col_type'), _('loyalty.store.card_detail.col_points'), _('loyalty.store.terminal.select_category'), _('loyalty.store.card_detail.col_location'), _('loyalty.store.card_detail.col_notes')]) }}
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<template x-if="transactions.length === 0">
<tr>
<td colspan="6" class="px-4 py-6 text-center text-gray-500 dark:text-gray-400">
{{ _('loyalty.store.card_detail.no_transactions') }}
</td>
</tr>
</template>
<template x-for="tx in transactions" :key="tx.id">
<tr class="text-gray-700 dark:text-gray-400">
<td class="px-4 py-3 text-sm" x-text="formatDateTime(tx.transaction_at)"></td>
<td class="px-4 py-3">
<span class="px-2 py-1 text-xs font-semibold rounded-full"
:class="{
'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100': tx.points_delta > 0,
'text-orange-700 bg-orange-100 dark:bg-orange-700 dark:text-orange-100': tx.points_delta < 0
}"
x-text="txLabels[tx.transaction_type] || tx.transaction_type.replace(/_/g, ' ')"></span>
</td>
<td class="px-4 py-3 text-sm font-medium"
:class="tx.points_delta > 0 ? 'text-green-600' : 'text-orange-600'"
x-text="(tx.points_delta > 0 ? '+' : '') + formatNumber(tx.points_delta)"></td>
<td class="px-4 py-3 text-sm text-gray-500" x-text="tx.category_names?.join(', ') || '-'"></td>
<td class="px-4 py-3 text-sm" x-text="tx.store_name || '-'"></td>
<td class="px-4 py-3 text-sm text-gray-500" x-text="txNotes[tx.notes] || tx.notes || '-'"></td>
</tr>
</template>
</tbody>
{% endcall %}
{{ pagination() }}
</div>
{% set card_detail_api_prefix = '/store/loyalty' %}
{% set card_detail_back_url = '/store/' ~ store_code ~ '/loyalty/cards' %}
{% set show_copy_buttons = true %}
{% set show_category_column = true %}
{% set show_pagination = true %}
{% include 'loyalty/shared/card-detail-view.html' %}
</div>
{% endblock %}
{% block extra_scripts %}
<script>
// Server-rendered transaction type labels + system note translations
// Server-rendered transaction type labels + system note translations.
// The shared factory reads these via window._cardDetailLabels.
window._cardDetailLabels = {
txLabels: {
card_created: {{ _('loyalty.transactions.card_created')|tojson }},
@@ -201,5 +50,6 @@ window._cardDetailLabels = {
}
};
</script>
<script defer src="{{ static_v(request, 'loyalty_static', path='shared/js/loyalty-card-detail-view.js') }}"></script>
<script defer src="{{ static_v(request, 'loyalty_static', path='store/js/loyalty-card-detail.js') }}"></script>
{% endblock %}

View File

@@ -1,9 +1,7 @@
{# app/modules/loyalty/templates/loyalty/store/cards.html #}
{% extends "store/base.html" %}
{% from 'shared/macros/pagination.html' import pagination %}
{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% from 'shared/macros/tables.html' import table_wrapper, table_header %}
{% block title %}{{ _('loyalty.store.cards.title') }}{% endblock %}
@@ -12,7 +10,6 @@
{% block alpine_data %}storeLoyaltyCards(){% endblock %}
{% block content %}
<!-- Page Header -->
{% call page_header_flex(title=_('loyalty.store.cards.title'), subtitle=_('loyalty.store.cards.subtitle')) %}
<div class="flex items-center gap-3">
{{ refresh_button(loading_var='loading', onclick='loadCards()', variant='secondary') }}
@@ -25,7 +22,6 @@
{% endcall %}
{{ loading_state(_('loyalty.store.cards.loading')) }}
{{ error_state(_('loyalty.store.cards.error_loading')) }}
<!-- No Program Setup Notice -->
@@ -48,124 +44,13 @@
</div>
</div>
<!-- Stats Cards -->
<div x-show="!loading && program" class="grid gap-6 mb-8 md:grid-cols-4">
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-purple-500 bg-purple-100 rounded-full dark:text-purple-100 dark:bg-purple-500">
<span x-html="$icon('users', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">{{ _('loyalty.store.cards.total_members') }}</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="formatNumber(stats.total_cards)">0</p>
</div>
</div>
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-green-500 bg-green-100 rounded-full dark:text-green-100 dark:bg-green-500">
<span x-html="$icon('check-circle', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">{{ _('loyalty.store.cards.active_30d') }}</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="formatNumber(stats.active_cards)">0</p>
</div>
</div>
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-blue-500 bg-blue-100 rounded-full dark:text-blue-100 dark:bg-blue-500">
<span x-html="$icon('user-plus', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">{{ _('loyalty.store.cards.new_this_month') }}</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="formatNumber(stats.new_this_month)">0</p>
</div>
</div>
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-orange-500 bg-orange-100 rounded-full dark:text-orange-100 dark:bg-orange-500">
<span x-html="$icon('currency-dollar', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">{{ _('loyalty.store.cards.total_points_balance') }}</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="formatNumber(stats.total_points_balance)">0</p>
</div>
</div>
</div>
<!-- Search and Filters -->
<div x-show="!loading && program" class="mb-6 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="flex flex-wrap items-center gap-4">
<div class="flex-1 min-w-[200px]">
<div class="relative">
<span class="absolute inset-y-0 left-0 flex items-center pl-3">
<span x-html="$icon('search', 'w-5 h-5 text-gray-400')"></span>
</span>
<input type="text"
x-model="filters.search"
@input="debouncedSearch()"
placeholder="{{ _('loyalty.store.cards.search_placeholder') }}"
class="w-full pl-10 pr-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
</div>
</div>
<select x-model="filters.status" @change="applyFilter()"
class="px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
<option value="">{{ _('loyalty.store.cards.all_status') }}</option>
<option value="active">{{ _('loyalty.common.active') }}</option>
<option value="inactive">{{ _('loyalty.common.inactive') }}</option>
</select>
</div>
</div>
<!-- Cards Table -->
<div x-show="!loading && program">
{% call table_wrapper() %}
{{ table_header([_('loyalty.store.cards.col_member'), _('loyalty.store.cards.col_card_number'), _('loyalty.store.cards.col_points_balance'), _('loyalty.store.cards.col_last_activity'), _('loyalty.store.cards.col_status'), _('loyalty.store.cards.col_actions')]) }}
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<template x-if="cards.length === 0">
<tr>
<td colspan="6" class="px-4 py-8 text-center text-gray-600 dark:text-gray-400">
<div class="flex flex-col items-center">
<span x-html="$icon('users', 'w-12 h-12 mb-2 text-gray-300')"></span>
<p class="font-medium">{{ _('loyalty.store.cards.no_members') }}</p>
<p class="text-xs mt-1" x-text="filters.search ? '{{ _('loyalty.store.cards.adjust_search')|replace("'", "\\'") }}' : '{{ _('loyalty.store.cards.enroll_first')|replace("'", "\\'") }}'"></p>
</div>
</td>
</tr>
</template>
<template x-for="card in cards" :key="card.id">
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700">
<td class="px-4 py-3">
<div class="flex items-center text-sm">
<div class="relative hidden w-8 h-8 mr-3 rounded-full md:block"
:style="'background-color: ' + (program?.card_color || '#4F46E5') + '20'">
<span class="absolute inset-0 flex items-center justify-center text-xs font-semibold"
:style="'color: ' + (program?.card_color || '#4F46E5')"
x-text="card.customer_name?.charAt(0).toUpperCase() || '?'"></span>
</div>
<div>
<p class="font-semibold" x-text="card.customer_name || 'Unknown'"></p>
<p class="text-xs text-gray-600 dark:text-gray-400" x-text="card.customer_email || card.customer_phone || '-'"></p>
</div>
</div>
</td>
<td class="px-4 py-3 text-sm font-mono" x-text="card.card_number"></td>
<td class="px-4 py-3 text-sm font-semibold" x-text="formatNumber(card.points_balance)"></td>
<td class="px-4 py-3 text-sm" x-text="formatDate(card.last_activity_at)"></td>
<td class="px-4 py-3 text-xs">
<span class="px-2 py-1 font-semibold leading-tight rounded-full"
:class="card.is_active ? 'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100' : 'text-gray-700 bg-gray-100 dark:bg-gray-700 dark:text-gray-100'"
x-text="card.is_active ? '{{ _('loyalty.common.active')|replace("'", "\\'") }}' : '{{ _('loyalty.common.inactive')|replace("'", "\\'") }}'"></span>
</td>
<td class="px-4 py-3">
<a :href="'/store/{{ store_code }}/loyalty/cards/' + card.id"
class="text-purple-600 hover:text-purple-700 dark:text-purple-400"
>{{ _('loyalty.common.view') }}</a>
</td>
</tr>
</template>
</tbody>
{% endcall %}
{{ pagination() }}
</div>
{% set cards_api_prefix = '/store/loyalty' %}
{% set cards_base_url = '/store/' ~ store_code ~ '/loyalty/cards' %}
{% set show_store_filter = false %}
{% include 'loyalty/shared/cards-list.html' %}
{% endblock %}
{% block extra_scripts %}
<script defer src="{{ static_v(request, 'loyalty_static', path='shared/js/loyalty-cards-list.js') }}"></script>
<script defer src="{{ static_v(request, 'loyalty_static', path='store/js/loyalty-cards.js') }}"></script>
{% endblock %}

View File

@@ -1,4 +1,5 @@
{# app/modules/loyalty/templates/loyalty/store/enroll.html #}
{# noqa: TPL-016 — store-only counter-staff enrollment flow, no merchant/admin equivalent #}
{% extends "store/base.html" %}
{% from 'shared/macros/headers.html' import detail_page_header %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}

View File

@@ -1,4 +1,5 @@
{# app/modules/loyalty/templates/loyalty/store/terminal.html #}
{# noqa: TPL-016 — store-only POS hardware terminal UI, no persona equivalent #}
{% extends "store/base.html" %}
{% from 'shared/macros/headers.html' import page_header_flex %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}

View File

@@ -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: **2060 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 `<module>/templates/<module>/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 |
| --- | --- | --- |
| `<feature>_api_prefix` | `'/store/loyalty'` | API base for AJAX |
| `<feature>_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/<module>/services/`) accept simple ids and stay scope-agnostic. `program_service.list_programs(db, skip, limit, ...)` — no `persona` argument.
- **Routes** (`app/modules/<module>/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/<module>/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/<m>/services/<f>_service.py # scope-agnostic
app/modules/<m>/routes/api/{admin,merchant,store}.py # scope-injecting
app/modules/<m>/schemas/<f>.py # same shape per persona
app/modules/<m>/templates/<m>/shared/<f>-list.html # shared body
app/modules/<m>/templates/<m>/{admin,merchant,store}/<f>.html # thin wrappers
app/modules/<m>/static/shared/js/<m>-<f>-list.js # shared Alpine factory
app/modules/<m>/static/{admin,merchant,store}/js/<m>-<f>.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/<m>/templates/<m>/{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.

View File

@@ -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.

View File

@@ -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

View File

@@ -871,6 +871,10 @@ class ArchitectureValidator:
if not is_macro:
self._check_static_v_usage(file_path, content, lines)
# TPL-016: Persona templates >75 LOC should include a shared/ partial
if not is_macro:
self._check_persona_template_shared_include(file_path, content, lines)
# TPL-004: Check x-text usage for dynamic content
self._check_xtext_usage(file_path, content, lines)
@@ -1832,6 +1836,55 @@ class ArchitectureValidator:
suggestion="Replace url_for(...) with static_v(request, ...) so the URL carries ?v=<commit>",
)
_TPL_016_THRESHOLD = 75
_TPL_016_PERSONA_RE = re.compile(
r"app/modules/[^/]+/templates/[^/]+/(?:admin|merchant|store)/[^/]+\.html$"
)
_TPL_016_SHARED_INCLUDE_RE = re.compile(
r"""\{%\s*include\s+['"][^'"]*/shared/[^'"]+['"]"""
)
def _check_persona_template_shared_include(
self, file_path: Path, content: str, lines: list[str]
):
"""TPL-016: Persona templates >75 LOC should include a shared/ partial.
Catches new persona-specific templates that inline body content already
living (or worth living) in `<module>/templates/<module>/shared/*.html`.
See docs/architecture/persona-template-consolidation.md.
Skip on file-level `noqa: TPL-016` comment.
"""
if not self._TPL_016_PERSONA_RE.search(str(file_path).replace("\\", "/")):
return
if len(lines) <= self._TPL_016_THRESHOLD:
return
if any(
"noqa: tpl-016" in line.lower() or "noqa: tpl016" in line.lower()
for line in lines
):
return
if self._TPL_016_SHARED_INCLUDE_RE.search(content):
return
self._add_violation(
rule_id="TPL-016",
rule_name="Persona templates >75 LOC must include a shared/ partial",
severity=Severity.WARNING,
file_path=file_path,
line_number=1,
message=(
f"Persona template is {len(lines)} LOC and doesn't include a shared/ partial — "
"likely duplicates body content already in a sibling persona"
),
context=file_path.name,
suggestion=(
"Extract the body into app/modules/<m>/templates/<m>/shared/<feature>-(list|form|view).html "
"and {% include %} it from this wrapper. Or add {# noqa: TPL-016 #} if intentionally standalone "
"(see docs/architecture/persona-template-consolidation.md for the heuristic)."
),
)
def _validate_api_endpoints(self, target_path: Path):
"""Validate API endpoint rules (API-001, API-002, API-003, API-004)"""
print("📡 Validating API endpoints...")
@@ -3551,6 +3604,10 @@ class ArchitectureValidator:
if not is_base_or_partial and not is_macro:
self._check_static_v_usage(file_path, content, lines)
# TPL-016: Persona templates >75 LOC should include a shared/ partial
if not is_base_or_partial and not is_macro:
self._check_persona_template_shared_include(file_path, content, lines)
# TPL-008: Check for call table_header() pattern
self._check_table_header_call_pattern(file_path, content, lines)