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:
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
34
app/modules/loyalty/templates/loyalty/merchant/cards.html
Normal file
34
app/modules/loyalty/templates/loyalty/merchant/cards.html
Normal 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 %}
|
||||
38
app/modules/loyalty/templates/loyalty/merchant/pins.html
Normal file
38
app/modules/loyalty/templates/loyalty/merchant/pins.html
Normal 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 %}
|
||||
89
app/modules/loyalty/templates/loyalty/merchant/settings.html
Normal file
89
app/modules/loyalty/templates/loyalty/merchant/settings.html
Normal 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 %}
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
138
app/modules/loyalty/templates/loyalty/shared/cards-list.html
Normal file
138
app/modules/loyalty/templates/loyalty/shared/cards-list.html
Normal 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>
|
||||
242
app/modules/loyalty/templates/loyalty/shared/pins-list.html
Normal file
242
app/modules/loyalty/templates/loyalty/shared/pins-list.html
Normal 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 %}
|
||||
@@ -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>
|
||||
49
app/modules/loyalty/templates/loyalty/store/pins.html
Normal file
49
app/modules/loyalty/templates/loyalty/store/pins.html
Normal 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 %}
|
||||
Reference in New Issue
Block a user