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,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>
|
||||
Reference in New Issue
Block a user