feat(loyalty): cross-persona page alignment with shared components

Align loyalty pages across admin, merchant, and store personas so each
sees the same page set scoped to their access level. Admin acts as a
superset of merchant with "on behalf" capabilities.

New pages:
- Store: Staff PINs management (CRUD)
- Merchant: Cards, Card Detail, Transactions, Staff PINs (CRUD), Settings (read-only)
- Admin: Merchant Cards, Card Detail, Transactions, PINs (read-only)

Architecture:
- 4 shared Jinja2 partials (cards-list, card-detail, transactions, pins)
- 4 shared JS factory modules parameterized by apiPrefix/scope
- Persona templates are thin wrappers including shared partials
- PinDetailResponse schema for cross-store PIN listings

API: 17 new endpoints (11 merchant, 6 admin on-behalf)
Tests: 38 new integration tests, arch-check green
i18n: ~130 new keys across en/fr/de/lb
Docs: pages-and-navigation.md with full page matrix

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-22 19:28:07 +01:00
parent f41f72b86f
commit 6161d69ba2
49 changed files with 4385 additions and 14 deletions

View File

@@ -0,0 +1,26 @@
{# app/modules/loyalty/templates/loyalty/admin/merchant-card-detail.html #}
{% extends "admin/base.html" %}
{% from 'shared/macros/headers.html' import detail_page_header %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% block title %}{{ _('loyalty.admin.merchant_card_detail.title') }}{% endblock %}
{% block i18n_modules %}['loyalty']{% endblock %}
{% block alpine_data %}adminMerchantCardDetail(){% endblock %}
{% block content %}
{% call detail_page_header("card?.card_number || 'Card Detail'", '/admin/loyalty/merchants/' + merchant_id|string + '/cards', subtitle_show='card') %}
<span x-text="card ? (card.customer_name || card.customer_email || '') : ''"></span>
{% endcall %}
{{ loading_state(_('loyalty.admin.merchant_card_detail.loading')) }}
{{ error_state(_('loyalty.admin.merchant_card_detail.error_loading')) }}
{% set card_detail_api_prefix = '/admin/loyalty/merchants/' + merchant_id|string %}
{% set card_detail_back_url = '/admin/loyalty/merchants/' + merchant_id|string + '/cards' %}
{% include 'loyalty/shared/card-detail-view.html' %}
{% endblock %}
{% block extra_scripts %}
<script defer src="{{ url_for('loyalty_static', path='shared/js/loyalty-card-detail-view.js') }}"></script>
<script defer src="{{ url_for('loyalty_static', path='admin/js/loyalty-merchant-card-detail.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,35 @@
{# app/modules/loyalty/templates/loyalty/admin/merchant-cards.html #}
{% extends "admin/base.html" %}
{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% block title %}{{ _('loyalty.admin.merchant_cards.title') }}{% endblock %}
{% block i18n_modules %}['loyalty']{% endblock %}
{% block alpine_data %}adminMerchantCards(){% endblock %}
{% block content %}
{% call page_header_flex(title=_('loyalty.admin.merchant_cards.title'), subtitle=_('loyalty.admin.merchant_cards.subtitle')) %}
<div class="flex items-center gap-3">
{{ refresh_button(loading_var='loading', onclick='loadCards()', variant='secondary') }}
<a href="/admin/loyalty/merchants/{{ merchant_id }}"
class="flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600">
<span x-html="$icon('arrow-left', 'w-4 h-4 mr-2')"></span>
{{ _('loyalty.common.back') }}
</a>
</div>
{% endcall %}
{{ loading_state(_('loyalty.admin.merchant_cards.loading')) }}
{{ error_state(_('loyalty.admin.merchant_cards.error_loading')) }}
{% set cards_api_prefix = '/admin/loyalty/merchants/' + merchant_id|string %}
{% set cards_base_url = '/admin/loyalty/merchants/' + merchant_id|string + '/cards' %}
{% set show_store_filter = true %}
{% set show_enroll_button = false %}
{% include 'loyalty/shared/cards-list.html' %}
{% endblock %}
{% block extra_scripts %}
<script defer src="{{ url_for('loyalty_static', path='shared/js/loyalty-cards-list.js') }}"></script>
<script defer src="{{ url_for('loyalty_static', path='admin/js/loyalty-merchant-cards.js') }}"></script>
{% endblock %}

View File

@@ -46,6 +46,24 @@
<span x-html="$icon('building-office', 'w-4 h-4 mr-2')"></span>
{{ _('loyalty.admin.merchant_detail.view_merchant') }}
</a>
<a x-show="program"
:href="`/admin/loyalty/merchants/${merchantId}/cards`"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-gray-700 transition-colors duration-150 bg-gray-100 border border-gray-300 rounded-lg hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-600">
<span x-html="$icon('identification', 'w-4 h-4 mr-2')"></span>
{{ _('loyalty.admin.merchant_detail.view_cards') }}
</a>
<a x-show="program"
:href="`/admin/loyalty/merchants/${merchantId}/transactions`"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-gray-700 transition-colors duration-150 bg-gray-100 border border-gray-300 rounded-lg hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-600">
<span x-html="$icon('clock', 'w-4 h-4 mr-2')"></span>
{{ _('loyalty.admin.merchant_detail.view_transactions') }}
</a>
<a
:href="`/admin/loyalty/merchants/${merchantId}/pins`"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-gray-700 transition-colors duration-150 bg-gray-100 border border-gray-300 rounded-lg hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-600">
<span x-html="$icon('key', 'w-4 h-4 mr-2')"></span>
{{ _('loyalty.admin.merchant_detail.view_pins') }}
</a>
</div>
</div>

View File

@@ -0,0 +1,34 @@
{# app/modules/loyalty/templates/loyalty/admin/merchant-pins.html #}
{% extends "admin/base.html" %}
{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% block title %}{{ _('loyalty.admin.merchant_pins.title') }}{% endblock %}
{% block i18n_modules %}['loyalty']{% endblock %}
{% block alpine_data %}adminMerchantPins(){% endblock %}
{% block content %}
{% call page_header_flex(title=_('loyalty.admin.merchant_pins.title'), subtitle=_('loyalty.admin.merchant_pins.subtitle')) %}
<div class="flex items-center gap-3">
{{ refresh_button(loading_var='loading', onclick='loadPins()', variant='secondary') }}
<a href="/admin/loyalty/merchants/{{ merchant_id }}"
class="flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600">
<span x-html="$icon('arrow-left', 'w-4 h-4 mr-2')"></span>
{{ _('loyalty.common.back') }}
</a>
</div>
{% endcall %}
{{ loading_state(_('loyalty.admin.merchant_pins.loading')) }}
{{ error_state(_('loyalty.admin.merchant_pins.error_loading')) }}
{% set pins_api_prefix = '/admin/loyalty/merchants/' + merchant_id|string %}
{% set show_store_filter = true %}
{% set show_crud = false %}
{% include 'loyalty/shared/pins-list.html' %}
{% endblock %}
{% block extra_scripts %}
<script defer src="{{ url_for('loyalty_static', path='shared/js/loyalty-pins-list.js') }}"></script>
<script defer src="{{ url_for('loyalty_static', path='admin/js/loyalty-merchant-pins.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,33 @@
{# app/modules/loyalty/templates/loyalty/admin/merchant-transactions.html #}
{% extends "admin/base.html" %}
{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% block title %}{{ _('loyalty.admin.merchant_transactions.title') }}{% endblock %}
{% block i18n_modules %}['loyalty']{% endblock %}
{% block alpine_data %}adminMerchantTransactions(){% endblock %}
{% block content %}
{% call page_header_flex(title=_('loyalty.admin.merchant_transactions.title'), subtitle=_('loyalty.admin.merchant_transactions.subtitle')) %}
<div class="flex items-center gap-3">
{{ refresh_button(loading_var='loading', onclick='loadTransactions()', variant='secondary') }}
<a href="/admin/loyalty/merchants/{{ merchant_id }}"
class="flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600">
<span x-html="$icon('arrow-left', 'w-4 h-4 mr-2')"></span>
{{ _('loyalty.common.back') }}
</a>
</div>
{% endcall %}
{{ loading_state(_('loyalty.admin.merchant_transactions.loading')) }}
{{ error_state(_('loyalty.admin.merchant_transactions.error_loading')) }}
{% set transactions_api_prefix = '/admin/loyalty/merchants/' + merchant_id|string %}
{% set show_store_filter = true %}
{% include 'loyalty/shared/transactions-list.html' %}
{% endblock %}
{% block extra_scripts %}
<script defer src="{{ url_for('loyalty_static', path='shared/js/loyalty-transactions-list.js') }}"></script>
<script defer src="{{ url_for('loyalty_static', path='admin/js/loyalty-merchant-transactions.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,30 @@
{# app/modules/loyalty/templates/loyalty/merchant/card-detail.html #}
{% extends "merchant/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 %}
{% block title %}{{ _('loyalty.merchant.card_detail.title') }}{% endblock %}
{% block i18n_modules %}['loyalty']{% endblock %}
{% block alpine_data %}merchantLoyaltyCardDetail(){% endblock %}
{% block content %}
{% call detail_page_header("card?.customer_name || '" + _('loyalty.merchant.card_detail.title') + "'", '/merchants/loyalty/cards', subtitle_show='card') %}
{{ _('loyalty.merchant.card_detail.card_label') }}: <span x-text="card?.card_number"></span>
{% endcall %}
{{ loading_state(_('loyalty.merchant.card_detail.loading')) }}
{{ error_state(_('loyalty.merchant.card_detail.error_loading')) }}
{% set card_detail_api_prefix = '/merchants/loyalty' %}
{% set card_detail_back_url = '/merchants/loyalty/cards' %}
{% include 'loyalty/shared/card-detail-view.html' %}
{% endblock %}
{% block extra_scripts %}
<script defer src="{{ url_for('loyalty_static', path='shared/js/loyalty-card-detail-view.js') }}"></script>
<script defer src="{{ url_for('loyalty_static', path='merchant/js/loyalty-card-detail.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,34 @@
{# app/modules/loyalty/templates/loyalty/merchant/cards.html #}
{% extends "merchant/base.html" %}
{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% block title %}{{ _('loyalty.merchant.cards.title') }}{% endblock %}
{% block i18n_modules %}['loyalty']{% endblock %}
{% block alpine_data %}merchantLoyaltyCards(){% endblock %}
{% block content %}
{% call page_header_flex(title=_('loyalty.merchant.cards.title'), subtitle=_('loyalty.merchant.cards.subtitle')) %}
<div class="flex items-center gap-3">
{{ refresh_button(loading_var='loading', onclick='loadCards()', variant='secondary') }}
</div>
{% endcall %}
{{ loading_state(_('loyalty.merchant.cards.loading')) }}
{{ error_state(_('loyalty.merchant.cards.error_loading')) }}
{% set cards_api_prefix = '/merchants/loyalty' %}
{% set cards_base_url = '/merchants/loyalty/cards' %}
{% set show_store_filter = true %}
{% set show_enroll_button = false %}
{% include 'loyalty/shared/cards-list.html' %}
{% endblock %}
{% block extra_scripts %}
<script defer src="{{ url_for('loyalty_static', path='shared/js/loyalty-cards-list.js') }}"></script>
<script defer src="{{ url_for('loyalty_static', path='merchant/js/loyalty-cards.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,38 @@
{# app/modules/loyalty/templates/loyalty/merchant/pins.html #}
{% extends "merchant/base.html" %}
{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% block title %}{{ _('loyalty.merchant.pins.title') }}{% endblock %}
{% block i18n_modules %}['loyalty']{% endblock %}
{% block alpine_data %}merchantLoyaltyPins(){% endblock %}
{% block content %}
{% call page_header_flex(title=_('loyalty.merchant.pins.title'), subtitle=_('loyalty.merchant.pins.subtitle')) %}
<div class="flex items-center gap-3">
{{ refresh_button(loading_var='loading', onclick='loadPins()', variant='secondary') }}
<button @click="openCreateModal()" x-show="program"
class="flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700">
<span x-html="$icon('plus', 'w-4 h-4 mr-2')"></span>
{{ _('loyalty.shared.pins.create_pin') }}
</button>
</div>
{% endcall %}
{{ loading_state(_('loyalty.merchant.pins.loading')) }}
{{ error_state(_('loyalty.merchant.pins.error_loading')) }}
{% set pins_api_prefix = '/merchants/loyalty' %}
{% set show_store_filter = true %}
{% set show_crud = true %}
{% include 'loyalty/shared/pins-list.html' %}
{% endblock %}
{% block extra_scripts %}
<script defer src="{{ url_for('loyalty_static', path='shared/js/loyalty-pins-list.js') }}"></script>
<script defer src="{{ url_for('loyalty_static', path='merchant/js/loyalty-pins.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,89 @@
{# app/modules/loyalty/templates/loyalty/merchant/settings.html #}
{% extends "merchant/base.html" %}
{% from 'shared/macros/headers.html' import page_header_flex %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% block title %}{{ _('loyalty.merchant.settings.title') }}{% endblock %}
{% block i18n_modules %}['loyalty']{% endblock %}
{% block alpine_data %}merchantLoyaltyMerchantSettings(){% endblock %}
{% block content %}
{% call page_header_flex(title=_('loyalty.merchant.settings.title'), subtitle=_('loyalty.merchant.settings.subtitle')) %}
{% endcall %}
{{ loading_state(_('loyalty.merchant.settings.loading')) }}
{{ error_state(_('loyalty.merchant.settings.error_loading')) }}
<!-- Managed by Admin Notice -->
<div x-show="!loading && !error" class="mb-6 px-4 py-3 bg-blue-50 border border-blue-200 rounded-lg dark:bg-blue-900/20 dark:border-blue-800">
<div class="flex items-center">
<span x-html="$icon('information-circle', 'w-5 h-5 text-blue-500 mr-2 flex-shrink-0')"></span>
<p class="text-sm text-blue-700 dark:text-blue-300">{{ _('loyalty.merchant.settings.managed_by_admin') }}</p>
</div>
</div>
<!-- Settings Display -->
<div x-show="!loading && !error && settings" class="space-y-6">
<!-- Staff PIN Policy -->
<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('key', 'inline w-5 h-5 mr-2')"></span>
{{ _('loyalty.merchant.settings.staff_pin_policy') }}
</h3>
<div class="grid gap-4 md:grid-cols-3">
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.merchant.settings.pin_policy') }}</p>
<p class="mt-1 text-sm text-gray-700 dark:text-gray-300">
<span class="px-2 py-1 text-xs font-semibold rounded-full bg-gray-100 dark:bg-gray-700"
x-text="settings?.staff_pin_policy || '-'"></span>
</p>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.merchant.settings.lockout_attempts') }}</p>
<p class="mt-1 text-sm text-gray-700 dark:text-gray-300" x-text="settings?.staff_pin_lockout_attempts || '-'"></p>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.merchant.settings.lockout_minutes') }}</p>
<p class="mt-1 text-sm text-gray-700 dark:text-gray-300" x-text="settings?.staff_pin_lockout_minutes || '-'"></p>
</div>
</div>
</div>
<!-- Enrollment & Permissions -->
<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('shield-check', 'inline w-5 h-5 mr-2')"></span>
{{ _('loyalty.merchant.settings.permissions') }}
</h3>
<div class="grid gap-4 md:grid-cols-2">
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg dark:bg-gray-700">
<span class="text-sm text-gray-700 dark:text-gray-300">{{ _('loyalty.merchant.settings.self_enrollment') }}</span>
<span class="px-2 py-1 text-xs font-semibold rounded-full"
:class="settings?.allow_self_enrollment ? '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="settings?.allow_self_enrollment ? '{{ _('loyalty.common.enabled') }}' : '{{ _('loyalty.common.disabled') }}'"></span>
</div>
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg dark:bg-gray-700">
<span class="text-sm text-gray-700 dark:text-gray-300">{{ _('loyalty.merchant.settings.cross_location') }}</span>
<span class="px-2 py-1 text-xs font-semibold rounded-full"
:class="settings?.allow_cross_location_redemption ? '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="settings?.allow_cross_location_redemption ? '{{ _('loyalty.common.enabled') }}' : '{{ _('loyalty.common.disabled') }}'"></span>
</div>
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg dark:bg-gray-700">
<span class="text-sm text-gray-700 dark:text-gray-300">{{ _('loyalty.merchant.settings.void_transactions') }}</span>
<span class="px-2 py-1 text-xs font-semibold rounded-full"
:class="settings?.allow_void_transactions ? '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="settings?.allow_void_transactions ? '{{ _('loyalty.common.enabled') }}' : '{{ _('loyalty.common.disabled') }}'"></span>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script defer src="{{ url_for('loyalty_static', path='merchant/js/loyalty-merchant-settings.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,32 @@
{# app/modules/loyalty/templates/loyalty/merchant/transactions.html #}
{% extends "merchant/base.html" %}
{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% block title %}{{ _('loyalty.merchant.transactions.title') }}{% endblock %}
{% block i18n_modules %}['loyalty']{% endblock %}
{% block alpine_data %}merchantLoyaltyTransactions(){% endblock %}
{% block content %}
{% call page_header_flex(title=_('loyalty.merchant.transactions.title'), subtitle=_('loyalty.merchant.transactions.subtitle')) %}
<div class="flex items-center gap-3">
{{ refresh_button(loading_var='loading', onclick='loadTransactions()', variant='secondary') }}
</div>
{% endcall %}
{{ loading_state(_('loyalty.merchant.transactions.loading')) }}
{{ error_state(_('loyalty.merchant.transactions.error_loading')) }}
{% set transactions_api_prefix = '/merchants/loyalty' %}
{% set show_store_filter = true %}
{% include 'loyalty/shared/transactions-list.html' %}
{% endblock %}
{% block extra_scripts %}
<script defer src="{{ url_for('loyalty_static', path='shared/js/loyalty-transactions-list.js') }}"></script>
<script defer src="{{ url_for('loyalty_static', path='merchant/js/loyalty-transactions.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,146 @@
{# app/modules/loyalty/templates/loyalty/shared/card-detail-view.html #}
{#
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
#}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% from 'shared/macros/tables.html' import table_wrapper, table_header %}
<!-- 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.shared.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.shared.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.shared.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.shared.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>
<!-- Customer Info + Card Info (2-column) -->
<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.shared.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.shared.card_detail.name') }}</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="card?.customer_name || '-'">-</p>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.shared.card_detail.email') }}</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="card?.customer_email || '-'">-</p>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.shared.card_detail.phone') }}</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="card?.customer_phone || '-'">-</p>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.shared.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.shared.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.shared.card_detail.card_number') }}</p>
<p class="text-sm font-mono text-gray-700 dark:text-gray-300" x-text="card?.card_number">-</p>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.shared.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.shared.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.shared.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.shared.card_detail.transaction_history') }}
</h3>
{% call table_wrapper() %}
{{ 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')]) }}
<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">
<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>
</div>
</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="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" x-text="tx.store_name || '-'"></td>
<td class="px-4 py-3 text-sm text-gray-500" x-text="tx.notes || '-'"></td>
</tr>
</template>
</tbody>
{% endcall %}
</div>

View File

@@ -0,0 +1,138 @@
{# app/modules/loyalty/templates/loyalty/shared/cards-list.html #}
{#
Shared loyalty cards list partial. Set these variables before including:
- cards_api_prefix (str): API base URL (e.g. '/store/loyalty', '/merchants/loyalty', '/admin/loyalty/merchants/5')
- cards_base_url (str): URL prefix for card detail links
- show_store_filter (bool): Show store/location dropdown filter
- show_enroll_button (bool): Show "Enroll New" button (store persona only)
- enroll_url (str): URL for the enroll button (if show_enroll_button)
#}
{% 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 %}
<!-- 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.shared.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.shared.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.shared.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.shared.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.shared.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.shared.cards.all_status') }}</option>
<option value="active">{{ _('loyalty.common.active') }}</option>
<option value="inactive">{{ _('loyalty.common.inactive') }}</option>
</select>
{% if show_store_filter %}
<select x-model="filters.store_id" @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.common.all_stores') }}</option>
<template x-for="loc in locations" :key="loc.store_id">
<option :value="loc.store_id" x-text="loc.store_name"></option>
</template>
</select>
{% endif %}
</div>
</div>
<!-- Cards Table -->
<div x-show="!loading && program">
{% call table_wrapper() %}
{{ table_header([_('loyalty.shared.cards.col_member'), _('loyalty.shared.cards.col_card_number'), _('loyalty.shared.cards.col_points_balance'), _('loyalty.shared.cards.col_last_activity'), _('loyalty.shared.cards.col_status'), _('loyalty.shared.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.shared.cards.no_members') }}</p>
<p class="text-xs mt-1" x-show="filters.search">{{ _('loyalty.shared.cards.adjust_search') }}</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 ? $t('loyalty.common.active') : $t('loyalty.common.inactive')"></span>
</td>
<td class="px-4 py-3">
<a :href="'{{ cards_base_url }}/' + 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>

View File

@@ -0,0 +1,242 @@
{# app/modules/loyalty/templates/loyalty/shared/pins-list.html #}
{#
Shared staff PINs list partial. Set these variables before including:
- pins_api_prefix (str): API base URL for PINs data
- show_store_filter (bool): Show store/location dropdown filter
- show_crud (bool): Show create/edit/delete actions (false for admin read-only)
#}
{% from 'shared/macros/pagination.html' import pagination %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% from 'shared/macros/tables.html' import table_wrapper, table_header %}
{% from 'shared/macros/modals.html' import modal, confirm_modal %}
<!-- Stats Summary -->
<div x-show="!loading" class="mb-6 flex flex-wrap items-center gap-6 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="flex items-center">
<div class="p-2 mr-3 text-purple-500 bg-purple-100 rounded-full dark:text-purple-100 dark:bg-purple-500">
<span x-html="$icon('key', 'w-4 h-4')"></span>
</div>
<div>
<p class="text-xs font-medium text-gray-600 dark:text-gray-400">{{ _('loyalty.shared.pins.total_pins') }}</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="formatNumber(pinStats.total)">0</p>
</div>
</div>
<div class="flex items-center">
<div class="p-2 mr-3 text-green-500 bg-green-100 rounded-full dark:text-green-100 dark:bg-green-500">
<span x-html="$icon('check-circle', 'w-4 h-4')"></span>
</div>
<div>
<p class="text-xs font-medium text-gray-600 dark:text-gray-400">{{ _('loyalty.shared.pins.active') }}</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="formatNumber(pinStats.active)">0</p>
</div>
</div>
<div class="flex items-center">
<div class="p-2 mr-3 text-red-500 bg-red-100 rounded-full dark:text-red-100 dark:bg-red-500">
<span x-html="$icon('lock-closed', 'w-4 h-4')"></span>
</div>
<div>
<p class="text-xs font-medium text-gray-600 dark:text-gray-400">{{ _('loyalty.shared.pins.locked') }}</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="formatNumber(pinStats.locked)">0</p>
</div>
</div>
</div>
<!-- Filter Bar -->
<div x-show="!loading" class="mb-6 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="flex flex-wrap items-center gap-4">
{% if show_store_filter %}
<select x-model="filters.store_id" @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.common.all_stores') }}</option>
<template x-for="loc in locations" :key="loc.store_id">
<option :value="loc.store_id" x-text="loc.store_name"></option>
</template>
</select>
{% endif %}
<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.shared.pins.all_status') }}</option>
<option value="active">{{ _('loyalty.common.active') }}</option>
<option value="inactive">{{ _('loyalty.common.inactive') }}</option>
<option value="locked">{{ _('loyalty.shared.pins.status_locked') }}</option>
</select>
</div>
</div>
<!-- PINs Table -->
<div x-show="!loading">
{% call table_wrapper() %}
{% if show_store_filter %}
{{ table_header([_('loyalty.shared.pins.col_name'), _('loyalty.shared.pins.col_staff_id'), _('loyalty.shared.pins.col_store'), _('loyalty.shared.pins.col_status'), _('loyalty.shared.pins.col_locked'), _('loyalty.shared.pins.col_last_used'), _('loyalty.shared.pins.col_actions')]) }}
{% else %}
{{ table_header([_('loyalty.shared.pins.col_name'), _('loyalty.shared.pins.col_staff_id'), _('loyalty.shared.pins.col_status'), _('loyalty.shared.pins.col_locked'), _('loyalty.shared.pins.col_last_used'), _('loyalty.shared.pins.col_actions')]) }}
{% endif %}
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<template x-if="pins.length === 0">
<tr>
<td :colspan="'{{ '7' if show_store_filter else '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('key', 'w-12 h-12 mb-2 text-gray-300')"></span>
<p class="font-medium">{{ _('loyalty.shared.pins.no_pins') }}</p>
</div>
</td>
</tr>
</template>
<template x-for="pin in pins" :key="pin.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 text-sm font-semibold" x-text="pin.name"></td>
<td class="px-4 py-3 text-sm font-mono" x-text="pin.staff_id"></td>
{% if show_store_filter %}
<td class="px-4 py-3 text-sm" x-text="pin.store_name || '-'"></td>
{% endif %}
<td class="px-4 py-3 text-xs">
<span class="px-2 py-1 font-semibold leading-tight rounded-full"
:class="pin.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="pin.is_active ? $t('loyalty.common.active') : $t('loyalty.common.inactive')"></span>
</td>
<td class="px-4 py-3 text-xs">
<span x-show="pin.is_locked" class="px-2 py-1 font-semibold leading-tight rounded-full text-red-700 bg-red-100 dark:bg-red-700 dark:text-red-100">{{ _('loyalty.shared.pins.status_locked') }}</span>
<span x-show="!pin.is_locked" class="text-sm text-gray-400">-</span>
</td>
<td class="px-4 py-3 text-sm" x-text="pin.last_used_at ? formatDate(pin.last_used_at) : '-'"></td>
<td class="px-4 py-3">
{% if show_crud %}
<div class="flex items-center gap-2">
<button @click="openEditPin(pin)"
class="text-purple-600 hover:text-purple-700 dark:text-purple-400 text-sm">
<span x-html="$icon('pencil', 'w-4 h-4')"></span>
</button>
<button @click="confirmDeletePin(pin)"
class="text-red-600 hover:text-red-700 dark:text-red-400 text-sm">
<span x-html="$icon('trash', 'w-4 h-4')"></span>
</button>
<button x-show="pin.is_locked" @click="unlockPin(pin)"
class="text-orange-600 hover:text-orange-700 dark:text-orange-400 text-sm"
:title="$t('loyalty.shared.pins.unlock')">
<span x-html="$icon('lock-open', 'w-4 h-4')"></span>
</button>
</div>
{% else %}
<span class="text-sm text-gray-400">-</span>
{% endif %}
</td>
</tr>
</template>
</tbody>
{% endcall %}
{{ pagination() }}
</div>
{% if show_crud %}
<!-- Create PIN Modal -->
{% call modal('createPinModal', _('loyalty.shared.pins.create_pin'), 'showCreateModal', size='md', show_footer=false) %}
<form @submit.prevent="createPin()">
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('loyalty.shared.pins.field_name') }}</label>
<input type="text" x-model="pinForm.name" required
class="w-full px-3 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"
placeholder="{{ _('loyalty.shared.pins.name_placeholder') }}">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('loyalty.shared.pins.field_staff_id') }}</label>
<input type="text" x-model="pinForm.staff_id" required
class="w-full px-3 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"
placeholder="{{ _('loyalty.shared.pins.staff_id_placeholder') }}">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('loyalty.shared.pins.field_pin') }}</label>
<input type="password" x-model="pinForm.pin" required minlength="4" maxlength="8"
class="w-full px-3 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"
placeholder="{{ _('loyalty.shared.pins.pin_placeholder') }}">
</div>
{% if show_store_filter %}
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('loyalty.shared.pins.field_store') }}</label>
<select x-model="pinForm.store_id" required
class="w-full px-3 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.shared.pins.select_store') }}</option>
<template x-for="loc in locations" :key="loc.store_id">
<option :value="loc.store_id" x-text="loc.store_name"></option>
</template>
</select>
</div>
{% endif %}
</div>
<div class="flex items-center justify-end gap-3 mt-6">
<button type="button" @click="showCreateModal = false"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors">
{{ _('loyalty.common.cancel') }}
</button>
<button type="submit" :disabled="saving"
class="flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
<span x-show="saving" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
<span x-text="saving ? $t('loyalty.common.saving') : $t('loyalty.shared.pins.create_pin')"></span>
</button>
</div>
</form>
{% endcall %}
<!-- Edit PIN Modal -->
{% call modal('editPinModal', _('loyalty.shared.pins.edit_pin'), 'showEditModal', size='md', show_footer=false) %}
<form @submit.prevent="updatePin()">
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('loyalty.shared.pins.field_name') }}</label>
<input type="text" x-model="pinForm.name" required
class="w-full px-3 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"
placeholder="{{ _('loyalty.shared.pins.name_placeholder') }}">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('loyalty.shared.pins.field_staff_id') }}</label>
<input type="text" x-model="pinForm.staff_id" required
class="w-full px-3 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"
placeholder="{{ _('loyalty.shared.pins.staff_id_placeholder') }}">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('loyalty.shared.pins.field_pin') }}</label>
<input type="password" x-model="pinForm.pin" minlength="4" maxlength="8"
class="w-full px-3 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"
placeholder="{{ _('loyalty.shared.pins.pin_edit_placeholder') }}">
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ _('loyalty.shared.pins.pin_edit_hint') }}</p>
</div>
{% if show_store_filter %}
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('loyalty.shared.pins.field_store') }}</label>
<select x-model="pinForm.store_id" required
class="w-full px-3 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.shared.pins.select_store') }}</option>
<template x-for="loc in locations" :key="loc.store_id">
<option :value="loc.store_id" x-text="loc.store_name"></option>
</template>
</select>
</div>
{% endif %}
</div>
<div class="flex items-center justify-end gap-3 mt-6">
<button type="button" @click="showEditModal = false"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors">
{{ _('loyalty.common.cancel') }}
</button>
<button type="submit" :disabled="saving"
class="flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
<span x-show="saving" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
<span x-text="saving ? $t('loyalty.common.saving') : $t('loyalty.shared.pins.save_changes')"></span>
</button>
</div>
</form>
{% endcall %}
<!-- Delete Confirm Modal -->
{{ confirm_modal(
'deletePinModal',
_('loyalty.shared.pins.delete_pin'),
_('loyalty.shared.pins.delete_confirm_message'),
'deletePin()',
'showDeleteModal',
confirm_text=_('loyalty.common.delete'),
cancel_text=_('loyalty.common.cancel'),
variant='danger'
) }}
{% endif %}

View File

@@ -0,0 +1,86 @@
{# app/modules/loyalty/templates/loyalty/shared/transactions-list.html #}
{#
Shared transactions list partial. Set these variables before including:
- transactions_api_prefix (str): API base URL for transactions data
- show_store_filter (bool): Show store/location dropdown filter
#}
{% from 'shared/macros/pagination.html' import pagination %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% from 'shared/macros/tables.html' import table_wrapper, table_header %}
<!-- Filter Bar -->
<div x-show="!loading" class="mb-6 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="flex flex-wrap items-center gap-4">
<select x-model="filters.type" @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.shared.transactions.all_types') }}</option>
<option value="earn">{{ _('loyalty.shared.transactions.type_earn') }}</option>
<option value="redeem">{{ _('loyalty.shared.transactions.type_redeem') }}</option>
<option value="adjust">{{ _('loyalty.shared.transactions.type_adjust') }}</option>
<option value="expire">{{ _('loyalty.shared.transactions.type_expire') }}</option>
</select>
{% if show_store_filter %}
<select x-model="filters.store_id" @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.common.all_stores') }}</option>
<template x-for="loc in locations" :key="loc.store_id">
<option :value="loc.store_id" x-text="loc.store_name"></option>
</template>
</select>
{% endif %}
</div>
</div>
<!-- Transactions Table -->
<div x-show="!loading">
{% call table_wrapper() %}
{{ table_header([_('loyalty.shared.transactions.col_date'), _('loyalty.shared.transactions.col_customer'), _('loyalty.shared.transactions.col_type'), _('loyalty.shared.transactions.col_points'), _('loyalty.shared.transactions.col_location'), _('loyalty.shared.transactions.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-8 text-center text-gray-600 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.transactions.no_transactions') }}</p>
<p class="text-xs mt-1" x-show="filters.type">{{ _('loyalty.shared.transactions.adjust_filters') }}</p>
</div>
</td>
</tr>
</template>
<template x-for="tx in transactions" :key="tx.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 text-sm" x-text="formatDateTime(tx.transaction_at)"></td>
<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="tx.customer_name?.charAt(0).toUpperCase() || '?'"></span>
</div>
<div>
<p class="font-semibold" x-text="tx.customer_name || 'Unknown'"></p>
<p class="text-xs text-gray-600 dark:text-gray-400" x-text="tx.card_number || '-'"></p>
</div>
</div>
</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="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" x-text="tx.store_name || '-'"></td>
<td class="px-4 py-3 text-sm text-gray-500" x-text="tx.notes || '-'"></td>
</tr>
</template>
</tbody>
{% endcall %}
{{ pagination() }}
</div>

View File

@@ -0,0 +1,49 @@
{# app/modules/loyalty/templates/loyalty/store/pins.html #}
{% extends "store/base.html" %}
{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% block title %}{{ _('loyalty.store.pins.title') }}{% endblock %}
{% block i18n_modules %}['loyalty']{% endblock %}
{% block alpine_data %}storeLoyaltyPins(){% endblock %}
{% block content %}
<!-- Page Header -->
{% call page_header_flex(title=_('loyalty.store.pins.title'), subtitle=_('loyalty.store.pins.subtitle')) %}
<div class="flex items-center gap-3">
{{ refresh_button(loading_var='loading', onclick='loadPins()', variant='secondary') }}
<button @click="openCreateModal()" x-show="program"
class="flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700">
<span x-html="$icon('plus', 'w-4 h-4 mr-2')"></span>
{{ _('loyalty.shared.pins.create_pin') }}
</button>
</div>
{% endcall %}
{{ loading_state(_('loyalty.store.pins.loading')) }}
{{ error_state(_('loyalty.store.pins.error_loading')) }}
<!-- No Program Setup Notice (same pattern as cards.html) -->
<div x-show="!loading && !program" class="mb-6 px-4 py-5 bg-yellow-50 border border-yellow-200 rounded-lg dark:bg-yellow-900/20 dark:border-yellow-800">
<div class="flex items-start">
<span x-html="$icon('exclamation-triangle', 'w-6 h-6 text-yellow-500 mr-3 flex-shrink-0')"></span>
<div>
<h3 class="text-sm font-semibold text-yellow-800 dark:text-yellow-200">{{ _('loyalty.common.program_not_setup') }}</h3>
<p class="mt-1 text-sm text-yellow-700 dark:text-yellow-300">{{ _('loyalty.common.program_not_setup_desc') }}</p>
</div>
</div>
</div>
{% set pins_api_prefix = '/store/loyalty' %}
{% set show_store_filter = false %}
{% set show_crud = true %}
{% include 'loyalty/shared/pins-list.html' %}
{% endblock %}
{% block extra_scripts %}
<script defer src="{{ url_for('loyalty_static', path='shared/js/loyalty-pins-list.js') }}"></script>
<script defer src="{{ url_for('loyalty_static', path='store/js/loyalty-pins.js') }}"></script>
{% endblock %}