feat(loyalty): implement Phase 2 - company-wide points system

Complete implementation of loyalty module Phase 2 features:

Database & Models:
- Add company_id to LoyaltyProgram for chain-wide loyalty
- Add company_id to LoyaltyCard for multi-location support
- Add CompanyLoyaltySettings model for admin-controlled settings
- Add points expiration, welcome bonus, and minimum redemption fields
- Add POINTS_EXPIRED, WELCOME_BONUS transaction types

Services:
- Update program_service for company-based queries
- Update card_service with enrollment and welcome bonus
- Update points_service with void_points for returns
- Update stamp_service for company context
- Update pin_service for company-wide operations

API Endpoints:
- Admin: Program listing with stats, company detail views
- Vendor: Terminal operations, card management, settings
- Storefront: Customer card/transactions, self-enrollment

UI Templates:
- Admin: Programs dashboard, company detail, settings
- Vendor: Terminal, cards list, card detail, settings, stats, enrollment
- Storefront: Dashboard, history, enrollment, success pages

Background Tasks:
- Point expiration task (daily, based on inactivity)
- Wallet sync task (hourly)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-05 22:10:27 +01:00
parent 3bdf1695fd
commit d8f3338bc8
54 changed files with 7252 additions and 186 deletions

View File

@@ -0,0 +1,162 @@
{# app/modules/loyalty/templates/loyalty/admin/analytics.html #}
{% extends "admin/base.html" %}
{% from 'shared/macros/headers.html' import page_header %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% block title %}Loyalty Analytics{% endblock %}
{% block alpine_data %}adminLoyaltyAnalytics(){% endblock %}
{% block content %}
{{ page_header('Loyalty Analytics') }}
{{ loading_state('Loading analytics...') }}
{{ error_state('Error loading analytics') }}
<!-- Analytics Dashboard -->
<div x-show="!loading">
<!-- Summary Stats -->
<div class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-4">
<!-- Total Programs -->
<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('gift', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Total Programs
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="formatNumber(stats.total_programs)">
0
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
<span x-text="stats.active_programs"></span> active
</p>
</div>
</div>
<!-- Total Members -->
<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('users', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Total Members
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="formatNumber(stats.total_cards)">
0
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
<span x-text="formatNumber(stats.active_cards)"></span> active
</p>
</div>
</div>
<!-- Points Issued (30d) -->
<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">
Points Issued (30d)
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="formatNumber(stats.points_issued_30d)">
0
</p>
</div>
</div>
<!-- Points Redeemed (30d) -->
<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('shopping-cart', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Points Redeemed (30d)
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="formatNumber(stats.points_redeemed_30d)">
0
</p>
</div>
</div>
</div>
<!-- Activity Metrics -->
<div class="grid gap-6 mb-8 md:grid-cols-2">
<!-- Transactions Overview -->
<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('chart-bar', 'inline w-5 h-5 mr-2')"></span>
Transaction Activity (30 Days)
</h3>
<div class="space-y-4">
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600 dark:text-gray-400">Total Transactions</span>
<span class="font-semibold text-gray-700 dark:text-gray-200" x-text="formatNumber(stats.transactions_30d)">0</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600 dark:text-gray-400">Companies with Programs</span>
<span class="font-semibold text-gray-700 dark:text-gray-200" x-text="formatNumber(stats.companies_with_programs)">0</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600 dark:text-gray-400">Redemption Rate</span>
<span class="font-semibold text-gray-700 dark:text-gray-200" x-text="redemptionRate + '%'">0%</span>
</div>
</div>
</div>
<!-- Points Balance Overview -->
<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('currency-dollar', 'inline w-5 h-5 mr-2')"></span>
Points Overview
</h3>
<div class="space-y-4">
<div>
<div class="flex items-center justify-between mb-1">
<span class="text-sm text-gray-600 dark:text-gray-400">Points Issued vs Redeemed (30d)</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-4 dark:bg-gray-700">
<div class="h-4 rounded-full flex">
<div class="bg-green-500 rounded-l-full" :style="'width: ' + issuedPercentage + '%'"></div>
<div class="bg-orange-500 rounded-r-full" :style="'width: ' + redeemedPercentage + '%'"></div>
</div>
</div>
<div class="flex justify-between mt-2 text-xs text-gray-500 dark:text-gray-400">
<span><span class="inline-block w-3 h-3 bg-green-500 rounded-full mr-1"></span>Issued: <span x-text="formatNumber(stats.points_issued_30d)"></span></span>
<span><span class="inline-block w-3 h-3 bg-orange-500 rounded-full mr-1"></span>Redeemed: <span x-text="formatNumber(stats.points_redeemed_30d)"></span></span>
</div>
</div>
</div>
</div>
</div>
<!-- Quick Links -->
<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('link', 'inline w-5 h-5 mr-2')"></span>
Quick Links
</h3>
<div class="flex flex-wrap gap-3">
<a href="/admin/loyalty/programs"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-purple-600 bg-purple-100 rounded-lg hover:bg-purple-200 dark:text-purple-300 dark:bg-purple-900/30 dark:hover:bg-purple-900/50">
<span x-html="$icon('gift', 'w-4 h-4 mr-2')"></span>
View All Programs
</a>
<a href="/admin/companies"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-blue-600 bg-blue-100 rounded-lg hover:bg-blue-200 dark:text-blue-300 dark:bg-blue-900/30 dark:hover:bg-blue-900/50">
<span x-html="$icon('building-office', 'w-4 h-4 mr-2')"></span>
Manage Companies
</a>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('loyalty_static', path='admin/js/loyalty-analytics.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,238 @@
{# app/modules/loyalty/templates/loyalty/admin/company-detail.html #}
{% extends "admin/base.html" %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% from 'shared/macros/headers.html' import detail_page_header %}
{% from 'shared/macros/tables.html' import table_wrapper, table_header %}
{% block title %}Company Loyalty Details{% endblock %}
{% block alpine_data %}adminLoyaltyCompanyDetail(){% endblock %}
{% block content %}
{% call detail_page_header("company?.name || 'Company Loyalty'", '/admin/loyalty/programs', subtitle_show='company') %}
<span x-text="program ? 'Loyalty Program Active' : 'No Loyalty Program'"></span>
{% endcall %}
{{ loading_state('Loading company loyalty details...') }}
{{ error_state('Error loading company loyalty') }}
<!-- Company Details -->
<div x-show="!loading && company">
<!-- Quick Actions Card -->
<div class="px-4 py-3 mb-6 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">
Quick Actions
</h3>
<div class="flex flex-wrap items-center gap-3">
<a
:href="`/admin/loyalty/companies/${companyId}/settings`"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple">
<span x-html="$icon('cog', 'w-4 h-4 mr-2')"></span>
Loyalty Settings
</a>
<a
:href="`/admin/companies/${company?.id}`"
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('building-office', 'w-4 h-4 mr-2')"></span>
View Company
</a>
</div>
</div>
<!-- Stats Cards -->
<div class="grid gap-6 mb-8 md:grid-cols-4">
<!-- Total Members -->
<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('users', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
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>
<!-- Active Members -->
<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">
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>
<!-- Points Issued (30d) -->
<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('trending-up', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Points Issued (30d)
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="formatNumber(stats.points_issued_30d)">
0
</p>
</div>
</div>
<!-- Points Redeemed (30d) -->
<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">
Points Redeemed (30d)
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="formatNumber(stats.points_redeemed_30d)">
0
</p>
</div>
</div>
</div>
<!-- Program Configuration -->
<div x-show="program" class="px-4 py-3 mb-8 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('cog', 'inline w-5 h-5 mr-2')"></span>
Program Configuration
</h3>
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase mb-2">Program Name</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="program?.display_name || program?.card_name || '-'">-</p>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase mb-2">Points Per Euro</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="program?.points_per_euro || 1">-</p>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase mb-2">Welcome Bonus</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="program?.welcome_bonus_points ? program.welcome_bonus_points + ' points' : 'None'">-</p>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase mb-2">Minimum Redemption</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="program?.minimum_redemption_points ? program.minimum_redemption_points + ' points' : 'None'">-</p>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase mb-2">Points Expiration</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="program?.points_expiration_days ? program.points_expiration_days + ' days of inactivity' : 'Never'">-</p>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase mb-2">Status</p>
<span class="inline-flex items-center px-2 py-1 text-xs font-semibold leading-tight rounded-full"
:class="program?.is_active ? 'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100' : 'text-gray-700 bg-gray-100 dark:text-gray-100 dark:bg-gray-700'">
<span x-text="program?.is_active ? 'Active' : 'Inactive'"></span>
</span>
</div>
</div>
</div>
<!-- No Program Notice -->
<div x-show="!program" class="px-4 py-3 mb-8 bg-yellow-50 border border-yellow-200 rounded-lg dark:bg-yellow-900/20 dark:border-yellow-800">
<div class="flex items-center">
<span x-html="$icon('exclamation-triangle', 'w-5 h-5 text-yellow-500 mr-3')"></span>
<div>
<h3 class="text-sm font-semibold text-yellow-800 dark:text-yellow-200">No Loyalty Program</h3>
<p class="text-sm text-yellow-700 dark:text-yellow-300">This company has not set up a loyalty program yet. Vendors can set up the program from their dashboard.</p>
</div>
</div>
</div>
<!-- Location Breakdown -->
<div x-show="locations.length > 0" class="px-4 py-3 mb-8 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('map-pin', 'inline w-5 h-5 mr-2')"></span>
Location Breakdown (<span x-text="locations.length"></span>)
</h3>
{% call table_wrapper() %}
{{ table_header(['Location', 'Enrolled', 'Points Earned', 'Points Redeemed', 'Transactions (30d)']) }}
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<template x-for="location in locations" :key="location.vendor_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>
<p class="font-semibold" x-text="location.vendor_name"></p>
<p class="text-xs text-gray-600 dark:text-gray-400" x-text="location.vendor_code"></p>
</div>
</div>
</td>
<td class="px-4 py-3 text-sm" x-text="formatNumber(location.enrolled_count)">0</td>
<td class="px-4 py-3 text-sm" x-text="formatNumber(location.points_earned)">0</td>
<td class="px-4 py-3 text-sm" x-text="formatNumber(location.points_redeemed)">0</td>
<td class="px-4 py-3 text-sm" x-text="formatNumber(location.transactions_30d)">0</td>
</tr>
</template>
<!-- Totals Row -->
<tr class="text-gray-900 dark:text-gray-100 font-semibold bg-gray-50 dark:bg-gray-700">
<td class="px-4 py-3 text-sm">TOTAL</td>
<td class="px-4 py-3 text-sm" x-text="formatNumber(stats.total_cards)">0</td>
<td class="px-4 py-3 text-sm" x-text="formatNumber(stats.total_points_issued)">0</td>
<td class="px-4 py-3 text-sm" x-text="formatNumber(stats.total_points_redeemed)">0</td>
<td class="px-4 py-3 text-sm" x-text="formatNumber(stats.transactions_30d)">0</td>
</tr>
</tbody>
{% endcall %}
</div>
<!-- Company Settings (Admin-controlled) -->
<div class="px-4 py-3 mb-8 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>
Admin Settings
</h3>
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase mb-2">Staff PIN Policy</p>
<span class="inline-flex items-center px-2 py-1 text-xs font-semibold leading-tight rounded-full"
:class="{
'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100': settings?.staff_pin_policy === 'required',
'text-yellow-700 bg-yellow-100 dark:bg-yellow-700 dark:text-yellow-100': settings?.staff_pin_policy === 'optional',
'text-gray-700 bg-gray-100 dark:bg-gray-700 dark:text-gray-100': settings?.staff_pin_policy === 'disabled'
}">
<span x-text="settings?.staff_pin_policy ? settings.staff_pin_policy.charAt(0).toUpperCase() + settings.staff_pin_policy.slice(1) : 'Optional'"></span>
</span>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase mb-2">Self Enrollment</p>
<span class="inline-flex items-center px-2 py-1 text-xs font-semibold leading-tight 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'">
<span x-text="settings?.allow_self_enrollment ? 'Allowed' : 'Disabled'"></span>
</span>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase mb-2">Cross-Location Redemption</p>
<span class="inline-flex items-center px-2 py-1 text-xs font-semibold leading-tight rounded-full"
:class="settings?.allow_cross_location_redemption !== false ? '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'">
<span x-text="settings?.allow_cross_location_redemption !== false ? 'Allowed' : 'Disabled'"></span>
</span>
</div>
</div>
<div class="mt-4">
<a
:href="`/admin/loyalty/companies/${companyId}/settings`"
class="text-sm text-purple-600 hover:text-purple-700 dark:text-purple-400">
<span x-html="$icon('cog', 'inline w-4 h-4 mr-1')"></span>
Modify admin settings
</a>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('loyalty_static', path='admin/js/loyalty-company-detail.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,180 @@
{# app/modules/loyalty/templates/loyalty/admin/company-settings.html #}
{% extends "admin/base.html" %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% from 'shared/macros/headers.html' import detail_page_header %}
{% from 'shared/macros/forms.html' import form_section, form_actions %}
{% block title %}Company Loyalty Settings{% endblock %}
{% block alpine_data %}adminLoyaltyCompanySettings(){% endblock %}
{% block content %}
{% call detail_page_header("'Loyalty Settings: ' + (company?.name || '')", backUrl, subtitle_show='company') %}
Admin-controlled settings for this company's loyalty program
{% endcall %}
{{ loading_state('Loading settings...') }}
{{ error_state('Error loading settings') }}
<!-- Settings Form -->
<div x-show="!loading">
<form @submit.prevent="saveSettings">
<!-- Staff PIN Policy -->
<div class="px-4 py-5 mb-6 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>
Staff PIN Policy
</h3>
<p class="mb-4 text-sm text-gray-600 dark:text-gray-400">
Control whether staff members at this company's locations must enter a PIN to process loyalty transactions.
</p>
<div class="space-y-4">
<label class="flex items-start p-4 border rounded-lg cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700"
:class="settings.staff_pin_policy === 'required' ? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20' : 'border-gray-200 dark:border-gray-600'">
<input type="radio" name="staff_pin_policy" value="required"
x-model="settings.staff_pin_policy"
class="mt-1 text-purple-600 form-radio">
<div class="ml-3">
<p class="font-medium text-gray-700 dark:text-gray-300">Required</p>
<p class="text-sm text-gray-500 dark:text-gray-400">Staff must enter their PIN for every transaction. Recommended for security.</p>
</div>
</label>
<label class="flex items-start p-4 border rounded-lg cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700"
:class="settings.staff_pin_policy === 'optional' ? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20' : 'border-gray-200 dark:border-gray-600'">
<input type="radio" name="staff_pin_policy" value="optional"
x-model="settings.staff_pin_policy"
class="mt-1 text-purple-600 form-radio">
<div class="ml-3">
<p class="font-medium text-gray-700 dark:text-gray-300">Optional</p>
<p class="text-sm text-gray-500 dark:text-gray-400">Vendors can choose whether to require PINs at their locations.</p>
</div>
</label>
<label class="flex items-start p-4 border rounded-lg cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700"
:class="settings.staff_pin_policy === 'disabled' ? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20' : 'border-gray-200 dark:border-gray-600'">
<input type="radio" name="staff_pin_policy" value="disabled"
x-model="settings.staff_pin_policy"
class="mt-1 text-purple-600 form-radio">
<div class="ml-3">
<p class="font-medium text-gray-700 dark:text-gray-300">Disabled</p>
<p class="text-sm text-gray-500 dark:text-gray-400">Staff PINs are not used. Any staff member can process transactions.</p>
</div>
</label>
</div>
<!-- PIN Lockout Settings -->
<div x-show="settings.staff_pin_policy !== 'disabled'" class="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700">
<h4 class="mb-4 text-md font-medium text-gray-700 dark:text-gray-300">
PIN Lockout Settings
</h4>
<div class="grid gap-6 md:grid-cols-2">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Max Failed Attempts
</label>
<input type="number" min="3" max="10"
x-model.number="settings.staff_pin_lockout_attempts"
class="w-full 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">
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Number of wrong attempts before lockout (3-10)</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Lockout Duration (minutes)
</label>
<input type="number" min="5" max="120"
x-model.number="settings.staff_pin_lockout_minutes"
class="w-full 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">
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">How long to lock out after failed attempts (5-120 minutes)</p>
</div>
</div>
</div>
</div>
<!-- Enrollment Settings -->
<div class="px-4 py-5 mb-6 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-plus', 'inline w-5 h-5 mr-2')"></span>
Enrollment Settings
</h3>
<div class="space-y-4">
<label class="flex items-center justify-between p-4 border border-gray-200 dark:border-gray-600 rounded-lg">
<div>
<p class="font-medium text-gray-700 dark:text-gray-300">Allow Self-Service Enrollment</p>
<p class="text-sm text-gray-500 dark:text-gray-400">Customers can sign up via QR code without staff assistance</p>
</div>
<div class="relative">
<input type="checkbox" x-model="settings.allow_self_enrollment"
class="sr-only peer">
<div @click="settings.allow_self_enrollment = !settings.allow_self_enrollment"
class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-purple-300 dark:peer-focus:ring-purple-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 cursor-pointer"
:class="settings.allow_self_enrollment ? 'bg-purple-600' : ''">
</div>
</div>
</label>
</div>
</div>
<!-- Transaction Settings -->
<div class="px-4 py-5 mb-6 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('arrows-right-left', 'inline w-5 h-5 mr-2')"></span>
Transaction Settings
</h3>
<div class="space-y-4">
<label class="flex items-center justify-between p-4 border border-gray-200 dark:border-gray-600 rounded-lg">
<div>
<p class="font-medium text-gray-700 dark:text-gray-300">Allow Cross-Location Redemption</p>
<p class="text-sm text-gray-500 dark:text-gray-400">Customers can redeem points at any company location</p>
</div>
<div class="relative">
<input type="checkbox" x-model="settings.allow_cross_location_redemption"
class="sr-only peer">
<div @click="settings.allow_cross_location_redemption = !settings.allow_cross_location_redemption"
class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-purple-300 dark:peer-focus:ring-purple-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 cursor-pointer"
:class="settings.allow_cross_location_redemption ? 'bg-purple-600' : ''">
</div>
</div>
</label>
<label class="flex items-center justify-between p-4 border border-gray-200 dark:border-gray-600 rounded-lg">
<div>
<p class="font-medium text-gray-700 dark:text-gray-300">Allow Void Transactions</p>
<p class="text-sm text-gray-500 dark:text-gray-400">Staff can void points/stamps for returns</p>
</div>
<div class="relative">
<input type="checkbox" x-model="settings.allow_void_transactions"
class="sr-only peer">
<div @click="settings.allow_void_transactions = !settings.allow_void_transactions"
class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-purple-300 dark:peer-focus:ring-purple-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 cursor-pointer"
:class="settings.allow_void_transactions ? 'bg-purple-600' : ''">
</div>
</div>
</label>
</div>
</div>
<!-- Form Actions -->
<div class="flex items-center justify-end gap-4">
<a :href="backUrl"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-700">
Cancel
</a>
<button type="submit"
:disabled="saving"
class="flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50">
<span x-show="saving" x-html="$icon('spinner', 'w-4 h-4 mr-2 animate-spin')"></span>
<span x-text="saving ? 'Saving...' : 'Save Settings'"></span>
</button>
</div>
</form>
</div>
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('loyalty_static', path='admin/js/loyalty-company-settings.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,241 @@
{# app/modules/loyalty/templates/loyalty/admin/programs.html #}
{% extends "admin/base.html" %}
{% from 'shared/macros/pagination.html' import pagination %}
{% from 'shared/macros/headers.html' import 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 Programs{% endblock %}
{% block alpine_data %}adminLoyaltyPrograms(){% endblock %}
{% block content %}
{{ page_header('Loyalty Programs') }}
{{ loading_state('Loading loyalty programs...') }}
{{ error_state('Error loading loyalty programs') }}
<!-- Stats Cards -->
<div x-show="!loading" class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-4">
<!-- Card: Total Programs -->
<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('gift', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Total Programs
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.total_programs || 0">
0
</p>
</div>
</div>
<!-- Card: Active Programs -->
<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">
Active
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.active_programs || 0">
0
</p>
</div>
</div>
<!-- Card: Total Members -->
<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('users', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Total Members
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="formatNumber(stats.total_cards) || 0">
0
</p>
</div>
</div>
<!-- Card: Transactions (30d) -->
<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('chart-bar', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Transactions (30d)
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="formatNumber(stats.transactions_30d) || 0">
0
</p>
</div>
</div>
</div>
<!-- Search and Filters Bar -->
<div x-show="!loading" class="mb-6 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<!-- Search Input -->
<div class="flex-1 max-w-md">
<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="Search by company name..."
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>
<!-- Filters -->
<div class="flex flex-wrap gap-3">
<!-- Status Filter -->
<select
x-model="filters.is_active"
@change="pagination.page = 1; loadPrograms()"
class="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none"
>
<option value="">All Status</option>
<option value="true">Active</option>
<option value="false">Inactive</option>
</select>
<!-- Refresh Button -->
<button
@click="loadPrograms(); loadStats()"
class="flex items-center px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none transition-colors"
title="Refresh"
>
<span x-html="$icon('refresh', 'w-4 h-4 mr-2')"></span>
Refresh
</button>
</div>
</div>
</div>
<!-- Programs Table -->
<div x-show="!loading">
{% call table_wrapper() %}
{{ table_header(['Company', 'Program Type', 'Members', 'Points Issued', 'Status', 'Created', 'Actions']) }}
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<!-- Empty State -->
<template x-if="programs.length === 0">
<tr>
<td colspan="7" 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('gift', 'w-12 h-12 mb-2 text-gray-300')"></span>
<p class="font-medium">No loyalty programs found</p>
<p class="text-xs mt-1" x-text="filters.search || filters.is_active ? 'Try adjusting your search or filters' : 'No companies have set up loyalty programs yet'"></p>
</div>
</td>
</tr>
</template>
<!-- Program Rows -->
<template x-for="program in programs" :key="program.id">
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<!-- Company Info -->
<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">
<div class="absolute inset-0 rounded-full flex items-center justify-center"
:style="'background-color: ' + (program.card_color || '#4F46E5') + '20'">
<span class="text-xs font-semibold"
:style="'color: ' + (program.card_color || '#4F46E5')"
x-text="program.company_name?.charAt(0).toUpperCase() || '?'"></span>
</div>
</div>
<div>
<p class="font-semibold" x-text="program.company_name || 'Unknown Company'"></p>
<p class="text-xs text-gray-600 dark:text-gray-400" x-text="program.display_name || program.card_name || 'Loyalty Program'"></p>
</div>
</div>
</td>
<!-- Program Type -->
<td class="px-4 py-3 text-sm">
<span class="inline-flex items-center px-2 py-1 font-semibold leading-tight rounded-full"
:class="{
'text-purple-700 bg-purple-100 dark:bg-purple-700 dark:text-purple-100': program.loyalty_type === 'points',
'text-blue-700 bg-blue-100 dark:bg-blue-700 dark:text-blue-100': program.loyalty_type === 'stamps',
'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100': program.loyalty_type === 'hybrid'
}">
<span x-text="program.loyalty_type?.charAt(0).toUpperCase() + program.loyalty_type?.slice(1) || 'Unknown'"></span>
</span>
<p class="text-xs text-gray-500 mt-1" x-show="program.is_points_enabled">
<span x-text="program.points_per_euro"></span> pt/EUR
</p>
</td>
<!-- Members -->
<td class="px-4 py-3 text-sm">
<span class="font-semibold" x-text="formatNumber(program.total_cards) || 0"></span>
<span class="text-xs text-gray-500" x-show="program.active_cards">
(<span x-text="formatNumber(program.active_cards)"></span> active)
</span>
</td>
<!-- Points Issued -->
<td class="px-4 py-3 text-sm">
<span x-text="formatNumber(program.total_points_issued) || 0"></span>
<p class="text-xs text-gray-500" x-show="program.total_points_redeemed">
<span x-text="formatNumber(program.total_points_redeemed)"></span> redeemed
</p>
</td>
<!-- Status -->
<td class="px-4 py-3 text-xs">
<span class="inline-flex items-center px-2 py-1 font-semibold leading-tight rounded-full"
:class="program.is_active ? 'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100' : 'text-gray-700 bg-gray-100 dark:text-gray-100 dark:bg-gray-700'">
<span x-text="program.is_active ? 'Active' : 'Inactive'"></span>
</span>
</td>
<!-- Created Date -->
<td class="px-4 py-3 text-sm" x-text="formatDate(program.created_at)"></td>
<!-- Actions -->
<td class="px-4 py-3">
<div class="flex items-center space-x-2 text-sm">
<!-- View Button -->
<a
:href="'/admin/loyalty/companies/' + program.company_id"
class="flex items-center justify-center p-2 text-blue-600 rounded-lg hover:bg-blue-50 dark:text-blue-400 dark:hover:bg-gray-700 focus:outline-none transition-colors"
title="View company loyalty details"
>
<span x-html="$icon('eye', 'w-5 h-5')"></span>
</a>
<!-- Settings Button -->
<a
:href="'/admin/loyalty/companies/' + program.company_id + '/settings'"
class="flex items-center justify-center p-2 text-purple-600 rounded-lg hover:bg-purple-50 dark:text-purple-400 dark:hover:bg-gray-700 focus:outline-none transition-colors"
title="Company loyalty settings"
>
<span x-html="$icon('cog', 'w-5 h-5')"></span>
</a>
</div>
</td>
</tr>
</template>
</tbody>
{% endcall %}
{{ pagination() }}
</div>
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('loyalty_static', path='admin/js/loyalty-programs.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,226 @@
{# app/modules/loyalty/templates/loyalty/storefront/dashboard.html #}
{% extends "storefront/base.html" %}
{% block title %}My Loyalty - {{ vendor.name }}{% endblock %}
{% block alpine_data %}customerLoyaltyDashboard(){% endblock %}
{% block content %}
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Page Header -->
<div class="mb-8">
<a href="{{ base_url }}shop/account/dashboard" class="inline-flex items-center text-sm text-gray-600 dark:text-gray-400 hover:text-primary mb-4">
<span x-html="$icon('arrow-left', 'w-4 h-4 mr-2')"></span>
Back to Account
</a>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">My Loyalty</h1>
</div>
<!-- Loading State -->
<div x-show="loading" class="flex justify-center py-12">
<span x-html="$icon('spinner', 'w-8 h-8 animate-spin text-primary')" style="color: var(--color-primary)"></span>
</div>
<!-- No Card State -->
<div x-show="!loading && !card" class="text-center py-12">
<span x-html="$icon('gift', 'w-16 h-16 mx-auto text-gray-300 dark:text-gray-600')"></span>
<h2 class="mt-4 text-xl font-semibold text-gray-900 dark:text-white">Join Our Rewards Program!</h2>
<p class="mt-2 text-gray-600 dark:text-gray-400">Earn points on every purchase and redeem for rewards.</p>
<a href="{{ base_url }}shop/loyalty/join"
class="mt-6 inline-flex items-center px-6 py-3 text-sm font-medium text-white rounded-lg"
style="background-color: var(--color-primary)">
<span x-html="$icon('plus', 'w-5 h-5 mr-2')"></span>
Join Now
</a>
</div>
<!-- Loyalty Card Content -->
<div x-show="!loading && card">
<!-- Loyalty Card Display -->
<div class="mb-8 rounded-2xl overflow-hidden shadow-lg"
:style="'background: linear-gradient(135deg, ' + (program?.card_color || '#4F46E5') + ' 0%, ' + (program?.card_secondary_color || program?.card_color || '#4F46E5') + 'cc 100%)'">
<div class="p-6 text-white">
<div class="flex justify-between items-start mb-6">
<div>
<p class="text-sm opacity-80" x-text="program?.display_name || 'Loyalty Program'"></p>
<p class="text-lg font-semibold" x-text="card?.customer_name"></p>
</div>
<template x-if="program?.logo_url">
<img :src="program.logo_url" alt="Logo" class="h-10 w-auto">
</template>
</div>
<div class="text-center py-4">
<p class="text-sm opacity-80">Points Balance</p>
<p class="text-5xl font-bold" x-text="formatNumber(card?.points_balance || 0)"></p>
</div>
<div class="flex justify-between items-end mt-6">
<div>
<p class="text-xs opacity-70">Card Number</p>
<p class="font-mono" x-text="card?.card_number"></p>
</div>
<button @click="showBarcode = true"
class="px-4 py-2 bg-white/20 hover:bg-white/30 rounded-lg text-sm font-medium transition-colors">
<span x-html="$icon('qr-code', 'w-5 h-5 inline mr-1')"></span>
Show Card
</button>
</div>
</div>
</div>
<!-- Quick Stats -->
<div class="grid grid-cols-2 gap-4 mb-8">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border border-gray-200 dark:border-gray-700">
<p class="text-sm text-gray-600 dark:text-gray-400">Total Earned</p>
<p class="text-2xl font-bold text-gray-900 dark:text-white" x-text="formatNumber(card?.total_points_earned || 0)"></p>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border border-gray-200 dark:border-gray-700">
<p class="text-sm text-gray-600 dark:text-gray-400">Total Redeemed</p>
<p class="text-2xl font-bold text-gray-900 dark:text-white" x-text="formatNumber(card?.total_points_redeemed || 0)"></p>
</div>
</div>
<!-- Available Rewards -->
<div class="mb-8">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Available Rewards</h2>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<template x-if="rewards.length === 0">
<div class="col-span-full text-center py-8 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
<p class="text-gray-500 dark:text-gray-400">No rewards available yet</p>
</div>
</template>
<template x-for="reward in rewards" :key="reward.id">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-4">
<div class="flex items-center justify-between mb-3">
<span x-html="$icon('gift', 'w-6 h-6')" :style="'color: ' + (program?.card_color || 'var(--color-primary)')"></span>
<span class="text-sm font-semibold" :style="'color: ' + (program?.card_color || 'var(--color-primary)')"
x-text="reward.points_required + ' pts'"></span>
</div>
<h3 class="font-semibold text-gray-900 dark:text-white" x-text="reward.name"></h3>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1" x-text="reward.description || ''"></p>
<div class="mt-3">
<template x-if="(card?.points_balance || 0) >= reward.points_required">
<span class="inline-flex items-center text-sm font-medium text-green-600">
<span x-html="$icon('check-circle', 'w-4 h-4 mr-1')"></span>
Ready to redeem
</span>
</template>
<template x-if="(card?.points_balance || 0) < reward.points_required">
<span class="text-sm text-gray-500">
<span x-text="reward.points_required - (card?.points_balance || 0)"></span> more to go
</span>
</template>
</div>
</div>
</template>
</div>
<p class="mt-4 text-sm text-gray-500 dark:text-gray-400">
<span x-html="$icon('information-circle', 'w-4 h-4 inline mr-1')"></span>
Show your card to staff to redeem rewards in-store.
</p>
</div>
<!-- Recent Activity -->
<div>
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Recent Activity</h2>
<a href="{{ base_url }}shop/account/loyalty/history"
class="text-sm font-medium hover:underline" style="color: var(--color-primary)">
View All
</a>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 overflow-hidden">
<template x-if="transactions.length === 0">
<div class="p-8 text-center text-gray-500 dark:text-gray-400">
No transactions yet. Make a purchase to start earning points!
</div>
</template>
<template x-if="transactions.length > 0">
<div class="divide-y divide-gray-200 dark:divide-gray-700">
<template x-for="tx in transactions.slice(0, 5)" :key="tx.id">
<div class="flex items-center justify-between p-4">
<div class="flex items-center">
<div class="w-10 h-10 rounded-full flex items-center justify-center"
:class="tx.points_delta > 0 ? 'bg-green-100 dark:bg-green-900/30' : 'bg-orange-100 dark:bg-orange-900/30'">
<span x-html="$icon(tx.points_delta > 0 ? 'plus' : 'gift', 'w-5 h-5')"
:class="tx.points_delta > 0 ? 'text-green-600' : 'text-orange-600'"></span>
</div>
<div class="ml-4">
<p class="font-medium text-gray-900 dark:text-white"
x-text="tx.points_delta > 0 ? 'Points Earned' : 'Reward Redeemed'"></p>
<p class="text-sm text-gray-500 dark:text-gray-400" x-text="formatDate(tx.transaction_at)"></p>
</div>
</div>
<p class="font-semibold"
:class="tx.points_delta > 0 ? 'text-green-600' : 'text-orange-600'"
x-text="(tx.points_delta > 0 ? '+' : '') + formatNumber(tx.points_delta)"></p>
</div>
</template>
</div>
</template>
</div>
</div>
<!-- Locations -->
<div class="mt-8" x-show="locations.length > 0">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">
<span x-html="$icon('map-pin', 'w-5 h-5 inline mr-2')"></span>
Earn & Redeem Locations
</h2>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-4">
<ul class="space-y-2">
<template x-for="loc in locations" :key="loc.id">
<li class="flex items-center text-gray-700 dark:text-gray-300">
<span x-html="$icon('check', 'w-4 h-4 mr-2 text-green-500')"></span>
<span x-text="loc.name"></span>
</li>
</template>
</ul>
</div>
</div>
</div>
</div>
<!-- Barcode Modal -->
<div x-show="showBarcode" x-cloak
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
@click.self="showBarcode = false">
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-xl max-w-sm w-full p-6 text-center">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Your Loyalty Card</h3>
<!-- Barcode Placeholder -->
<div class="bg-white p-4 rounded-lg mb-4">
<div class="h-20 flex items-center justify-center border-2 border-gray-200 rounded">
<span class="font-mono text-2xl tracking-wider" x-text="card?.card_number"></span>
</div>
<p class="text-xs text-gray-500 mt-2" x-text="card?.card_number"></p>
</div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-6">
Show this to staff when making a purchase or redeeming rewards.
</p>
<!-- Wallet Buttons -->
<div class="space-y-2 mb-4">
<button class="w-full flex items-center justify-center px-4 py-3 bg-black text-white rounded-lg hover:bg-gray-800 transition-colors">
<span x-html="$icon('device-mobile', 'w-5 h-5 mr-2')"></span>
Add to Apple Wallet
</button>
<button class="w-full flex items-center justify-center px-4 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
<span x-html="$icon('device-mobile', 'w-5 h-5 mr-2')"></span>
Add to Google Wallet
</button>
</div>
<button @click="showBarcode = false"
class="w-full px-4 py-2 text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200">
Close
</button>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('loyalty_static', path='storefront/js/loyalty-dashboard.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,87 @@
{# app/modules/loyalty/templates/loyalty/storefront/enroll-success.html #}
{% extends "storefront/base.html" %}
{% block title %}Welcome to Rewards! - {{ vendor.name }}{% endblock %}
{% block alpine_data %}customerLoyaltyEnrollSuccess(){% endblock %}
{% block content %}
<div class="min-h-screen flex items-center justify-center px-4 py-12 bg-gray-50 dark:bg-gray-900">
<div class="max-w-md w-full text-center">
<!-- Success Icon -->
<div class="w-20 h-20 mx-auto mb-6 rounded-full flex items-center justify-center"
style="background-color: var(--color-primary); opacity: 0.1">
<div class="w-20 h-20 rounded-full flex items-center justify-center" style="background-color: var(--color-primary)">
<span x-html="$icon('check', 'w-10 h-10 text-white')"></span>
</div>
</div>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-2">Welcome!</h1>
<p class="text-gray-600 dark:text-gray-400 mb-8">You're now a member of our rewards program.</p>
<!-- Card Number Display -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 mb-8">
<p class="text-sm text-gray-500 dark:text-gray-400 mb-2">Your Card Number</p>
<p class="text-2xl font-mono font-bold text-gray-900 dark:text-white">{{ enrolled_card_number or 'Loading...' }}</p>
<div class="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700">
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
Save your card to your phone for easy access:
</p>
<div class="space-y-2">
<button class="w-full flex items-center justify-center px-4 py-3 bg-black text-white rounded-lg hover:bg-gray-800 transition-colors">
<span x-html="$icon('device-mobile', 'w-5 h-5 mr-2')"></span>
Add to Apple Wallet
</button>
<button class="w-full flex items-center justify-center px-4 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
<span x-html="$icon('device-mobile', 'w-5 h-5 mr-2')"></span>
Add to Google Wallet
</button>
</div>
</div>
</div>
<!-- Next Steps -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6 text-left mb-8">
<h2 class="font-semibold text-gray-900 dark:text-white mb-4">What's Next?</h2>
<ul class="space-y-3 text-sm text-gray-600 dark:text-gray-400">
<li class="flex items-start">
<span x-html="$icon('check-circle', 'w-5 h-5 mr-2 text-green-500 flex-shrink-0')"></span>
<span>Show your card number when making purchases to earn points</span>
</li>
<li class="flex items-start">
<span x-html="$icon('check-circle', 'w-5 h-5 mr-2 text-green-500 flex-shrink-0')"></span>
<span>Check your balance online or in the app</span>
</li>
<li class="flex items-start">
<span x-html="$icon('check-circle', 'w-5 h-5 mr-2 text-green-500 flex-shrink-0')"></span>
<span>Redeem points for rewards at any of our locations</span>
</li>
</ul>
</div>
<!-- Actions -->
<div class="space-y-3">
<a href="{{ base_url }}shop/account/loyalty"
class="block w-full py-3 px-4 text-white font-semibold rounded-lg transition-colors text-center"
style="background-color: var(--color-primary)">
View My Loyalty Dashboard
</a>
<a href="{{ base_url }}shop"
class="block w-full py-3 px-4 text-gray-700 dark:text-gray-300 font-medium rounded-lg border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors text-center">
Continue Shopping
</a>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
function customerLoyaltyEnrollSuccess() {
return {
...data()
};
}
</script>
{% endblock %}

View File

@@ -0,0 +1,135 @@
{# app/modules/loyalty/templates/loyalty/storefront/enroll.html #}
{% extends "storefront/base.html" %}
{% block title %}Join Loyalty Program - {{ vendor.name }}{% endblock %}
{% block alpine_data %}customerLoyaltyEnroll(){% endblock %}
{% block content %}
<div class="min-h-screen flex items-center justify-center px-4 py-12 bg-gray-50 dark:bg-gray-900">
<div class="max-w-md w-full">
<!-- Logo/Brand -->
<div class="text-center mb-8">
{% if vendor.logo_url %}
<img src="{{ vendor.logo_url }}" alt="{{ vendor.name }}" class="h-16 w-auto mx-auto mb-4">
{% endif %}
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Join Our Rewards Program!</h1>
<p class="mt-2 text-gray-600 dark:text-gray-400" x-text="'Earn ' + (program?.points_per_euro || 1) + ' point for every EUR you spend'"></p>
</div>
<!-- Loading -->
<div x-show="loading" class="text-center py-8">
<span x-html="$icon('spinner', 'w-8 h-8 animate-spin mx-auto')" style="color: var(--color-primary)"></span>
</div>
<!-- No Program Available -->
<div x-show="!loading && !program" class="bg-white dark:bg-gray-800 rounded-lg shadow p-8 text-center">
<span x-html="$icon('exclamation-circle', 'w-12 h-12 mx-auto text-yellow-500')"></span>
<h2 class="mt-4 text-xl font-semibold text-gray-900 dark:text-white">Program Not Available</h2>
<p class="mt-2 text-gray-600 dark:text-gray-400">This store doesn't have a loyalty program set up yet.</p>
</div>
<!-- Enrollment Form -->
<div x-show="!loading && program && !enrolled" class="bg-white dark:bg-gray-800 rounded-lg shadow-lg overflow-hidden">
<!-- Welcome Bonus Banner -->
<div x-show="program?.welcome_bonus_points > 0"
class="p-4 text-center text-white"
:style="'background-color: ' + (program?.card_color || 'var(--color-primary)')">
<span x-html="$icon('gift', 'w-6 h-6 inline mr-2')"></span>
<span class="font-semibold">Get <span x-text="program?.welcome_bonus_points"></span> bonus points when you join!</span>
</div>
<form @submit.prevent="submitEnrollment" class="p-6 space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Email <span class="text-red-500">*</span>
</label>
<input type="email" x-model="form.email" required
class="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white"
style="--tw-ring-color: var(--color-primary)"
placeholder="your@email.com">
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
First Name <span class="text-red-500">*</span>
</label>
<input type="text" x-model="form.first_name" required
class="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white"
placeholder="John">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Last Name
</label>
<input type="text" x-model="form.last_name"
class="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white"
placeholder="Doe">
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Phone (optional)
</label>
<input type="tel" x-model="form.phone"
class="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white"
placeholder="+352 123 456 789">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Birthday (optional)
</label>
<input type="date" x-model="form.birthday"
class="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
<p class="mt-1 text-xs text-gray-500">For special birthday rewards</p>
</div>
<div class="space-y-3 pt-2">
<label class="flex items-start">
<input type="checkbox" x-model="form.terms_accepted" required
class="mt-1 w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
style="color: var(--color-primary)">
<span class="ml-2 text-sm text-gray-600 dark:text-gray-400">
I agree to the <a href="#" class="underline" style="color: var(--color-primary)">Terms & Conditions</a>
</span>
</label>
<label class="flex items-start">
<input type="checkbox" x-model="form.marketing_consent"
class="mt-1 w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
style="color: var(--color-primary)">
<span class="ml-2 text-sm text-gray-600 dark:text-gray-400">
Send me news and special offers
</span>
</label>
</div>
<button type="submit"
:disabled="enrolling || !form.email || !form.first_name || !form.terms_accepted"
class="w-full py-3 px-4 text-white font-semibold rounded-lg transition-colors disabled:opacity-50"
:style="'background-color: ' + (program?.card_color || 'var(--color-primary)')">
<span x-show="enrolling" x-html="$icon('spinner', 'w-5 h-5 inline animate-spin mr-2')"></span>
<span x-text="enrolling ? 'Joining...' : 'Join & Get ' + (program?.welcome_bonus_points || 0) + ' Points'"></span>
</button>
</form>
<div class="px-6 pb-6 text-center">
<p class="text-xs text-gray-500 dark:text-gray-400">
Already a member? Your points are linked to your email.
</p>
</div>
</div>
<!-- Error Message -->
<div x-show="error" class="mt-4 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<p class="text-sm text-red-600 dark:text-red-400" x-text="error"></p>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('loyalty_static', path='storefront/js/loyalty-enroll.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,107 @@
{# app/modules/loyalty/templates/loyalty/storefront/history.html #}
{% extends "storefront/base.html" %}
{% block title %}Loyalty History - {{ vendor.name }}{% endblock %}
{% block alpine_data %}customerLoyaltyHistory(){% endblock %}
{% block content %}
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Page Header -->
<div class="mb-8">
<a href="{{ base_url }}storefront/account/loyalty" class="inline-flex items-center text-sm text-gray-600 dark:text-gray-400 hover:text-primary mb-4">
<span x-html="$icon('arrow-left', 'w-4 h-4 mr-2')"></span>
Back to Loyalty
</a>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Transaction History</h1>
<p class="mt-2 text-gray-600 dark:text-gray-400">View all your loyalty point transactions</p>
</div>
<!-- Loading State -->
<div x-show="loading" class="flex justify-center py-12">
<span x-html="$icon('spinner', 'w-8 h-8 animate-spin')" style="color: var(--color-primary)"></span>
</div>
<!-- Summary Card -->
<div x-show="!loading && card" class="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-8 border border-gray-200 dark:border-gray-700">
<div class="grid grid-cols-3 gap-4 text-center">
<div>
<p class="text-sm text-gray-500 dark:text-gray-400">Current Balance</p>
<p class="text-2xl font-bold" style="color: var(--color-primary)" x-text="formatNumber(card?.points_balance || 0)"></p>
</div>
<div>
<p class="text-sm text-gray-500 dark:text-gray-400">Total Earned</p>
<p class="text-2xl font-bold text-green-600" x-text="formatNumber(card?.total_points_earned || 0)"></p>
</div>
<div>
<p class="text-sm text-gray-500 dark:text-gray-400">Total Redeemed</p>
<p class="text-2xl font-bold text-orange-600" x-text="formatNumber(card?.total_points_redeemed || 0)"></p>
</div>
</div>
</div>
<!-- Transactions List -->
<div x-show="!loading" class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 overflow-hidden">
<template x-if="transactions.length === 0">
<div class="p-12 text-center">
<span x-html="$icon('receipt-refund', 'w-12 h-12 mx-auto text-gray-300 dark:text-gray-600')"></span>
<p class="mt-4 text-gray-500 dark:text-gray-400">No transactions yet</p>
</div>
</template>
<template x-if="transactions.length > 0">
<div class="divide-y divide-gray-200 dark:divide-gray-700">
<template x-for="tx in transactions" :key="tx.id">
<div class="flex items-center justify-between p-4 hover:bg-gray-50 dark:hover:bg-gray-700/50">
<div class="flex items-center">
<div class="w-12 h-12 rounded-full flex items-center justify-center"
:class="tx.points_delta > 0 ? 'bg-green-100 dark:bg-green-900/30' : 'bg-orange-100 dark:bg-orange-900/30'">
<span x-html="$icon(tx.points_delta > 0 ? 'plus' : 'gift', 'w-6 h-6')"
:class="tx.points_delta > 0 ? 'text-green-600' : 'text-orange-600'"></span>
</div>
<div class="ml-4">
<p class="font-medium text-gray-900 dark:text-white"
x-text="getTransactionLabel(tx)"></p>
<p class="text-sm text-gray-500 dark:text-gray-400">
<span x-text="formatDateTime(tx.transaction_at)"></span>
<span x-show="tx.vendor_name" class="ml-2">
at <span x-text="tx.vendor_name"></span>
</span>
</p>
<p x-show="tx.notes" class="text-xs text-gray-400 mt-1" x-text="tx.notes"></p>
</div>
</div>
<div class="text-right">
<p class="text-lg font-semibold"
:class="tx.points_delta > 0 ? 'text-green-600' : 'text-orange-600'"
x-text="(tx.points_delta > 0 ? '+' : '') + formatNumber(tx.points_delta)"></p>
<p class="text-xs text-gray-500 dark:text-gray-400">
Balance: <span x-text="formatNumber(tx.balance_after)"></span>
</p>
</div>
</div>
</template>
</div>
</template>
<!-- Pagination -->
<div x-show="pagination.pages > 1" class="px-4 py-3 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between">
<button @click="previousPage()" :disabled="pagination.page <= 1"
class="px-3 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50">
Previous
</button>
<span class="text-sm text-gray-500 dark:text-gray-400">
Page <span x-text="pagination.page"></span> of <span x-text="pagination.pages"></span>
</span>
<button @click="nextPage()" :disabled="pagination.page >= pagination.pages"
class="px-3 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50">
Next
</button>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('loyalty_static', path='storefront/js/loyalty-history.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,158 @@
{# app/modules/loyalty/templates/loyalty/vendor/card-detail.html #}
{% extends "vendor/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 %}Member Details{% endblock %}
{% block alpine_data %}vendorLoyaltyCardDetail(){% endblock %}
{% block content %}
{% call detail_page_header("card?.customer_name || 'Member Details'", '/vendor/' + vendor_code + '/loyalty/cards', subtitle_show='card') %}
Card: <span x-text="card?.card_number"></span>
{% endcall %}
{{ loading_state('Loading member details...') }}
{{ error_state('Error loading member') }}
<div x-show="!loading && card">
<!-- Quick Stats -->
<div class="grid gap-6 mb-8 md:grid-cols-4">
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-purple-500 bg-purple-100 rounded-full dark:text-purple-100 dark:bg-purple-500">
<span x-html="$icon('currency-dollar', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">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">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">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">Member Since</p>
<p class="text-sm font-semibold text-gray-700 dark:text-gray-200" x-text="formatDate(card?.created_at)">-</p>
</div>
</div>
</div>
<div class="grid gap-6 mb-8 md:grid-cols-2">
<!-- Customer Info -->
<div class="px-4 py-5 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
<span x-html="$icon('user', 'inline w-5 h-5 mr-2')"></span>
Customer Information
</h3>
<div class="space-y-3">
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">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">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">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">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>
Card Details
</h3>
<div class="space-y-3">
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">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">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 ? 'Active' : 'Inactive'"></span>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">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">Enrolled At</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="card?.enrolled_at_vendor_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>
Transaction History
</h3>
{% call table_wrapper() %}
{{ table_header(['Date', 'Type', 'Points', 'Location', '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">
No transactions yet
</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.vendor_name || '-'"></td>
<td class="px-4 py-3 text-sm text-gray-500" x-text="tx.notes || '-'"></td>
</tr>
</template>
</tbody>
{% endcall %}
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('loyalty_static', path='vendor/js/loyalty-card-detail.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,150 @@
{# app/modules/loyalty/templates/loyalty/vendor/cards.html #}
{% extends "vendor/base.html" %}
{% from 'shared/macros/pagination.html' import pagination %}
{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% from 'shared/macros/tables.html' import table_wrapper, table_header %}
{% block title %}Loyalty Members{% endblock %}
{% block alpine_data %}vendorLoyaltyCards(){% endblock %}
{% block content %}
<!-- Page Header -->
{% call page_header_flex(title='Loyalty Members', subtitle='View and manage your loyalty program members') %}
<div class="flex items-center gap-3">
{{ refresh_button(loading_var='loading', onclick='loadCards()', variant='secondary') }}
<a href="/vendor/{{ vendor_code }}/loyalty/enroll"
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('user-plus', 'w-4 h-4 mr-2')"></span>
Enroll New
</a>
</div>
{% endcall %}
{{ loading_state('Loading members...') }}
{{ error_state('Error loading members') }}
<!-- Stats Cards -->
<div x-show="!loading" 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">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">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">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">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" 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="Search by name, email, phone, or card..."
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="">All Status</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
</div>
</div>
<!-- Cards Table -->
<div x-show="!loading">
{% call table_wrapper() %}
{{ table_header(['Member', 'Card Number', 'Points Balance', 'Last Activity', 'Status', '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">No members found</p>
<p class="text-xs mt-1" x-text="filters.search ? 'Try adjusting your search' : 'Enroll your first customer to get started'"></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 ? 'Active' : 'Inactive'"></span>
</td>
<td class="px-4 py-3">
<a :href="'/vendor/{{ vendor_code }}/loyalty/cards/' + card.id"
class="text-purple-600 hover:text-purple-700 dark:text-purple-400">
View
</a>
</td>
</tr>
</template>
</tbody>
{% endcall %}
{{ pagination() }}
</div>
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('loyalty_static', path='vendor/js/loyalty-cards.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,146 @@
{# app/modules/loyalty/templates/loyalty/vendor/enroll.html #}
{% extends "vendor/base.html" %}
{% from 'shared/macros/headers.html' import detail_page_header %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% block title %}Enroll Customer{% endblock %}
{% block alpine_data %}vendorLoyaltyEnroll(){% endblock %}
{% block content %}
{% call detail_page_header("'Enroll New Customer'", '/vendor/' + vendor_code + '/loyalty/terminal') %}
Add a new member to your loyalty program
{% endcall %}
{{ loading_state('Loading...') }}
{{ error_state('Error loading enrollment form') }}
<div x-show="!loading" class="max-w-2xl">
<form @submit.prevent="enrollCustomer">
<!-- Customer Information -->
<div class="px-4 py-5 mb-6 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>
Customer Information
</h3>
<div class="space-y-4">
<div class="grid gap-4 md:grid-cols-2">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
First Name <span class="text-red-500">*</span>
</label>
<input type="text" x-model="form.first_name" required
class="w-full 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">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Last Name</label>
<input type="text" x-model="form.last_name"
class="w-full 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">
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Email <span class="text-red-500">*</span>
</label>
<input type="email" x-model="form.email" required
class="w-full 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">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Phone</label>
<input type="tel" x-model="form.phone"
class="w-full 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">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Birthday</label>
<input type="date" x-model="form.birthday"
class="w-full 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">
<p class="mt-1 text-xs text-gray-500">For birthday rewards (optional)</p>
</div>
</div>
</div>
<!-- Marketing Consent -->
<div class="px-4 py-5 mb-6 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('bell', 'inline w-5 h-5 mr-2')"></span>
Communication Preferences
</h3>
<div class="space-y-3">
<label class="flex items-center">
<input type="checkbox" x-model="form.marketing_email"
class="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500">
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Send promotional emails</span>
</label>
<label class="flex items-center">
<input type="checkbox" x-model="form.marketing_sms"
class="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500">
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Send promotional SMS</span>
</label>
</div>
</div>
<!-- Welcome Bonus Info -->
<div x-show="program?.welcome_bonus_points > 0" class="px-4 py-4 mb-6 bg-green-50 border border-green-200 rounded-lg dark:bg-green-900/20 dark:border-green-800">
<div class="flex items-center">
<span x-html="$icon('gift', 'w-5 h-5 text-green-500 mr-3')"></span>
<div>
<p class="text-sm font-medium text-green-800 dark:text-green-200">Welcome Bonus</p>
<p class="text-sm text-green-700 dark:text-green-300">
Customer will receive <span class="font-bold" x-text="program?.welcome_bonus_points"></span> bonus points!
</p>
</div>
</div>
</div>
<!-- Actions -->
<div class="flex items-center gap-4">
<a href="/vendor/{{ vendor_code }}/loyalty/terminal"
class="px-6 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600">
Cancel
</a>
<button type="submit" :disabled="enrolling || !form.first_name || !form.email"
class="flex items-center px-6 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50">
<span x-show="enrolling" x-html="$icon('spinner', 'w-4 h-4 mr-2 animate-spin')"></span>
<span x-text="enrolling ? 'Enrolling...' : 'Enroll Customer'"></span>
</button>
</div>
</form>
<!-- Success Modal -->
<div x-show="enrolledCard" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full mx-4 p-6">
<div class="text-center">
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-green-100 flex items-center justify-center">
<span x-html="$icon('check', 'w-8 h-8 text-green-500')"></span>
</div>
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200 mb-2">Customer Enrolled!</h3>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">
Card Number: <span class="font-mono font-semibold" x-text="enrolledCard?.card_number"></span>
</p>
<p class="text-sm text-gray-600 dark:text-gray-300 mb-6">
Starting Balance: <span class="font-bold text-purple-600" x-text="enrolledCard?.points_balance"></span> points
</p>
<div class="flex gap-3 justify-center">
<a href="/vendor/{{ vendor_code }}/loyalty/terminal"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300">
Back to Terminal
</a>
<button @click="enrolledCard = null; resetForm()"
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700">
Enroll Another
</button>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('loyalty_static', path='vendor/js/loyalty-enroll.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,158 @@
{# app/modules/loyalty/templates/loyalty/vendor/settings.html #}
{% extends "vendor/base.html" %}
{% from 'shared/macros/headers.html' import page_header_flex %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% block title %}Loyalty Settings{% endblock %}
{% block alpine_data %}vendorLoyaltySettings(){% endblock %}
{% block content %}
{% call page_header_flex(title='Loyalty Program Settings', subtitle='Configure your loyalty program') %}{% endcall %}
{{ loading_state('Loading settings...') }}
{{ error_state('Error loading settings') }}
<div x-show="!loading">
<form @submit.prevent="saveSettings">
<!-- Points Configuration -->
<div class="px-4 py-5 mb-6 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('currency-dollar', 'inline w-5 h-5 mr-2')"></span>
Points Configuration
</h3>
<div class="grid gap-6 md:grid-cols-2">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Points per EUR spent</label>
<input type="number" min="1" max="100" x-model.number="settings.points_per_euro"
class="w-full 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">
<p class="mt-1 text-xs text-gray-500">1 EUR = <span x-text="settings.points_per_euro || 1"></span> point(s)</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Welcome Bonus Points</label>
<input type="number" min="0" x-model.number="settings.welcome_bonus_points"
class="w-full 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">
<p class="mt-1 text-xs text-gray-500">Bonus points awarded on enrollment</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Minimum Redemption Points</label>
<input type="number" min="1" x-model.number="settings.minimum_redemption_points"
class="w-full 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">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Points Expiration (days)</label>
<input type="number" min="0" x-model.number="settings.points_expiration_days"
placeholder="0 = never expire"
class="w-full 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">
<p class="mt-1 text-xs text-gray-500">Days of inactivity before points expire (0 = never)</p>
</div>
</div>
</div>
<!-- Rewards Configuration -->
<div class="px-4 py-5 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">
<span x-html="$icon('gift', 'inline w-5 h-5 mr-2')"></span>
Redemption Rewards
</h3>
<button type="button" @click="addReward()"
class="flex items-center px-3 py-1 text-sm text-purple-600 hover:text-purple-700">
<span x-html="$icon('plus', 'w-4 h-4 mr-1')"></span>
Add Reward
</button>
</div>
<div class="space-y-4">
<template x-if="settings.points_rewards.length === 0">
<p class="text-gray-500 dark:text-gray-400 text-sm">No rewards configured. Add a reward to allow customers to redeem points.</p>
</template>
<template x-for="(reward, index) in settings.points_rewards" :key="index">
<div class="flex items-start gap-4 p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
<div class="flex-1 grid gap-4 md:grid-cols-3">
<div>
<label class="block text-xs text-gray-500 mb-1">Reward Name</label>
<input type="text" x-model="reward.name" placeholder="e.g., EUR5 off"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 dark:text-gray-300">
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Points Required</label>
<input type="number" min="1" x-model.number="reward.points_required"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 dark:text-gray-300">
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Description</label>
<input type="text" x-model="reward.description" placeholder="Optional description"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 dark:text-gray-300">
</div>
</div>
<button type="button" @click="removeReward(index)"
class="text-red-500 hover:text-red-700 p-2">
<span x-html="$icon('trash', 'w-5 h-5')"></span>
</button>
</div>
</template>
</div>
</div>
<!-- Branding -->
<div class="px-4 py-5 mb-6 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('paint-brush', 'inline w-5 h-5 mr-2')"></span>
Branding
</h3>
<div class="grid gap-6 md:grid-cols-2">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Card Name</label>
<input type="text" x-model="settings.card_name" placeholder="e.g., VIP Rewards"
class="w-full 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">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Primary Color</label>
<div class="flex items-center gap-3">
<input type="color" x-model="settings.card_color"
class="w-12 h-10 rounded cursor-pointer">
<input type="text" x-model="settings.card_color" pattern="^#[0-9A-Fa-f]{6}$"
class="flex-1 px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 dark:text-gray-300">
</div>
</div>
</div>
</div>
<!-- Status -->
<div class="px-4 py-5 mb-6 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('power', 'inline w-5 h-5 mr-2')"></span>
Program Status
</h3>
<label class="flex items-center justify-between p-4 border border-gray-200 dark:border-gray-600 rounded-lg">
<div>
<p class="font-medium text-gray-700 dark:text-gray-300">Program Active</p>
<p class="text-sm text-gray-500">When disabled, customers cannot earn or redeem points</p>
</div>
<div class="relative">
<input type="checkbox" x-model="settings.is_active" class="sr-only peer">
<div @click="settings.is_active = !settings.is_active"
class="w-11 h-6 bg-gray-200 rounded-full cursor-pointer peer-checked:bg-purple-600 dark:bg-gray-700"
:class="settings.is_active ? 'bg-purple-600' : ''">
<div class="absolute top-[2px] left-[2px] bg-white w-5 h-5 rounded-full transition-transform"
:class="settings.is_active ? 'translate-x-5' : ''"></div>
</div>
</div>
</label>
</div>
<!-- Actions -->
<div class="flex items-center justify-end gap-4">
<button type="submit" :disabled="saving"
class="flex items-center px-6 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50">
<span x-show="saving" x-html="$icon('spinner', 'w-4 h-4 mr-2 animate-spin')"></span>
<span x-text="saving ? 'Saving...' : 'Save Settings'"></span>
</button>
</div>
</form>
</div>
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('loyalty_static', path='vendor/js/loyalty-settings.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,134 @@
{# app/modules/loyalty/templates/loyalty/vendor/stats.html #}
{% extends "vendor/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 Stats{% endblock %}
{% block alpine_data %}vendorLoyaltyStats(){% endblock %}
{% block content %}
{% call page_header_flex(title='Loyalty Statistics', subtitle='Track your loyalty program performance') %}
<div class="flex items-center gap-3">
{{ refresh_button(loading_var='loading', onclick='loadStats()', variant='secondary') }}
</div>
{% endcall %}
{{ loading_state('Loading statistics...') }}
{{ error_state('Error loading statistics') }}
<div x-show="!loading">
<!-- Summary Stats -->
<div class="grid gap-6 mb-8 md:grid-cols-2 xl: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">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('trending-up', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Points Issued (30d)</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="formatNumber(stats.points_issued_30d)">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">Points Redeemed (30d)</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="formatNumber(stats.points_redeemed_30d)">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('chart-bar', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Transactions (30d)</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="formatNumber(stats.transactions_30d)">0</p>
</div>
</div>
</div>
<!-- Detailed Metrics -->
<div class="grid gap-6 mb-8 md:grid-cols-2">
<!-- Points Overview -->
<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('currency-dollar', 'inline w-5 h-5 mr-2')"></span>
Points Overview
</h3>
<div class="space-y-4">
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Total Points Issued (All Time)</span>
<span class="font-semibold" x-text="formatNumber(stats.total_points_issued)">0</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Total Points Redeemed</span>
<span class="font-semibold" x-text="formatNumber(stats.total_points_redeemed)">0</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Outstanding Balance</span>
<span class="font-semibold text-purple-600" x-text="formatNumber(stats.total_points_balance)">0</span>
</div>
</div>
</div>
<!-- Member Activity -->
<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('users', 'inline w-5 h-5 mr-2')"></span>
Member Activity
</h3>
<div class="space-y-4">
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Active Members (30d)</span>
<span class="font-semibold" x-text="formatNumber(stats.active_cards)">0</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">New This Month</span>
<span class="font-semibold" x-text="formatNumber(stats.new_this_month)">0</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Avg Points Per Member</span>
<span class="font-semibold" x-text="formatNumber(stats.avg_points_per_member)">0</span>
</div>
</div>
</div>
</div>
<!-- Quick Links -->
<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">Quick Actions</h3>
<div class="flex flex-wrap gap-3">
<a href="/vendor/{{ vendor_code }}/loyalty/terminal"
class="flex items-center px-4 py-2 text-sm font-medium text-purple-600 bg-purple-50 rounded-lg hover:bg-purple-100 dark:bg-purple-900/20 dark:text-purple-400">
<span x-html="$icon('device-tablet', 'w-4 h-4 mr-2')"></span>
Open Terminal
</a>
<a href="/vendor/{{ vendor_code }}/loyalty/cards"
class="flex items-center px-4 py-2 text-sm font-medium text-blue-600 bg-blue-50 rounded-lg hover:bg-blue-100 dark:bg-blue-900/20 dark:text-blue-400">
<span x-html="$icon('users', 'w-4 h-4 mr-2')"></span>
View Members
</a>
<a href="/vendor/{{ vendor_code }}/loyalty/settings"
class="flex items-center px-4 py-2 text-sm font-medium text-gray-600 bg-gray-50 rounded-lg hover:bg-gray-100 dark:bg-gray-700 dark:text-gray-400">
<span x-html="$icon('cog', 'w-4 h-4 mr-2')"></span>
Settings
</a>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('loyalty_static', path='vendor/js/loyalty-stats.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,309 @@
{# app/modules/loyalty/templates/loyalty/vendor/terminal.html #}
{% extends "vendor/base.html" %}
{% from 'shared/macros/headers.html' import page_header_flex %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% from 'shared/macros/modals.html' import modal_simple %}
{% block title %}Loyalty Terminal{% endblock %}
{% block alpine_data %}vendorLoyaltyTerminal(){% endblock %}
{% block content %}
<!-- Page Header -->
{% call page_header_flex(title='Loyalty Terminal', subtitle='Process loyalty transactions') %}
<div class="flex items-center gap-3">
<a href="/vendor/{{ vendor_code }}/loyalty/cards"
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:bg-gray-800 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-700">
<span x-html="$icon('users', 'w-4 h-4 mr-2')"></span>
Members
</a>
<a href="/vendor/{{ vendor_code }}/loyalty/stats"
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:bg-gray-800 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-700">
<span x-html="$icon('chart-bar', 'w-4 h-4 mr-2')"></span>
Stats
</a>
</div>
{% endcall %}
{{ loading_state('Loading loyalty terminal...') }}
{{ error_state('Error loading terminal') }}
<!-- No Program Setup Notice -->
<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 Program Not Set Up</h3>
<p class="mt-1 text-sm text-yellow-700 dark:text-yellow-300">Your company doesn't have a loyalty program configured yet.</p>
<a href="/vendor/{{ vendor_code }}/loyalty/settings"
class="mt-3 inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-yellow-600 rounded-lg hover:bg-yellow-700">
<span x-html="$icon('cog', 'w-4 h-4 mr-2')"></span>
Set Up Loyalty Program
</a>
</div>
</div>
</div>
<!-- Main Terminal -->
<div x-show="!loading && program">
<div class="grid gap-6 lg:grid-cols-2">
<!-- Left: Customer Lookup -->
<div class="bg-white rounded-lg shadow-md dark:bg-gray-800">
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">
<span x-html="$icon('search', 'inline w-5 h-5 mr-2')"></span>
Find Customer
</h3>
</div>
<div class="p-4">
<!-- Search Input -->
<div class="relative mb-4">
<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="searchQuery"
@keyup.enter="lookupCustomer()"
placeholder="Email, phone, or card number..."
class="w-full pl-10 pr-4 py-3 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>
<button
@click="lookupCustomer()"
:disabled="!searchQuery || lookingUp"
class="w-full flex items-center justify-center px-4 py-3 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 focus:outline-none disabled:opacity-50"
>
<span x-show="lookingUp" x-html="$icon('spinner', 'w-5 h-5 mr-2 animate-spin')"></span>
<span x-text="lookingUp ? 'Looking up...' : 'Look Up Customer'"></span>
</button>
<!-- Divider -->
<div class="relative my-6">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-gray-200 dark:border-gray-700"></div>
</div>
<div class="relative flex justify-center text-sm">
<span class="px-3 bg-white text-gray-500 dark:bg-gray-800 dark:text-gray-400">or</span>
</div>
</div>
<!-- Enroll New Customer -->
<a href="/vendor/{{ vendor_code }}/loyalty/enroll"
class="w-full flex items-center justify-center px-4 py-3 text-sm font-medium text-purple-600 bg-purple-50 border border-purple-200 rounded-lg hover:bg-purple-100 dark:bg-purple-900/20 dark:text-purple-400 dark:border-purple-800">
<span x-html="$icon('user-plus', 'w-5 h-5 mr-2')"></span>
Enroll New Customer
</a>
</div>
</div>
<!-- Right: Customer Card (shown when found) -->
<div x-show="selectedCard" class="bg-white rounded-lg shadow-md dark:bg-gray-800">
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">
<span x-html="$icon('user', 'inline w-5 h-5 mr-2')"></span>
Customer Found
</h3>
</div>
<div class="p-4">
<!-- Customer Info -->
<div class="flex items-start mb-4">
<div class="flex-shrink-0 w-12 h-12 rounded-full flex items-center justify-center"
:style="'background-color: ' + (program?.card_color || '#4F46E5') + '20'">
<span class="text-lg font-semibold"
:style="'color: ' + (program?.card_color || '#4F46E5')"
x-text="selectedCard?.customer_name?.charAt(0).toUpperCase() || '?'"></span>
</div>
<div class="ml-4 flex-1">
<p class="font-semibold text-gray-700 dark:text-gray-200" x-text="selectedCard?.customer_name || 'Unknown'"></p>
<p class="text-sm text-gray-500 dark:text-gray-400" x-text="selectedCard?.customer_email"></p>
<p class="text-xs text-gray-400 dark:text-gray-500" x-text="'Card: ' + selectedCard?.card_number"></p>
</div>
<button @click="clearCustomer()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<span x-html="$icon('x', 'w-5 h-5')"></span>
</button>
</div>
<!-- Points Balance -->
<div class="mb-6 p-4 rounded-lg text-center"
:style="'background-color: ' + (program?.card_color || '#4F46E5') + '10'">
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Points Balance</p>
<p class="text-3xl font-bold"
:style="'color: ' + (program?.card_color || '#4F46E5')"
x-text="formatNumber(selectedCard?.points_balance || 0)"></p>
</div>
<!-- Action Buttons -->
<div class="grid gap-4 md:grid-cols-2">
<!-- Earn Points -->
<div class="p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
<h4 class="font-medium text-gray-700 dark:text-gray-300 mb-3">
<span x-html="$icon('plus-circle', 'inline w-4 h-4 mr-1 text-green-500')"></span>
Earn Points
</h4>
<div class="mb-3">
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Purchase Amount</label>
<div class="relative">
<span class="absolute inset-y-0 left-0 flex items-center pl-3 text-gray-500">EUR</span>
<input type="number" step="0.01" min="0"
x-model.number="earnAmount"
class="w-full pl-12 pr-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-green-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
</div>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
Points to award: <span class="font-semibold text-green-600" x-text="Math.floor((earnAmount || 0) * (program?.points_per_euro || 1))"></span>
</p>
<button @click="showPinModal('earn')"
:disabled="!earnAmount || earnAmount <= 0"
class="w-full px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700 disabled:opacity-50">
Award Points
</button>
</div>
<!-- Redeem Points -->
<div class="p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
<h4 class="font-medium text-gray-700 dark:text-gray-300 mb-3">
<span x-html="$icon('gift', 'inline w-4 h-4 mr-1 text-orange-500')"></span>
Redeem Reward
</h4>
<div class="mb-3">
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Select Reward</label>
<select x-model="selectedReward"
class="w-full px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-orange-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
<option value="">Select reward...</option>
<template x-for="reward in availableRewards" :key="reward.id">
<option :value="reward.id" :disabled="(selectedCard?.points_balance || 0) < reward.points_required"
x-text="reward.name + ' (' + reward.points_required + ' pts)'"></option>
</template>
</select>
</div>
<template x-if="selectedReward">
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
Points after: <span class="font-semibold text-orange-600" x-text="formatNumber((selectedCard?.points_balance || 0) - (getSelectedRewardPoints() || 0))"></span>
</p>
</template>
<button @click="showPinModal('redeem')"
:disabled="!selectedReward"
class="w-full px-4 py-2 text-sm font-medium text-white bg-orange-600 rounded-lg hover:bg-orange-700 disabled:opacity-50">
Redeem Reward
</button>
</div>
</div>
</div>
</div>
<!-- Empty State (when no customer selected) -->
<div x-show="!selectedCard" class="bg-white rounded-lg shadow-md dark:bg-gray-800">
<div class="p-8 text-center">
<span x-html="$icon('user-circle', 'w-16 h-16 mx-auto text-gray-300 dark:text-gray-600')"></span>
<p class="mt-4 text-gray-500 dark:text-gray-400">Search for a customer to process a transaction</p>
</div>
</div>
</div>
<!-- Recent Transactions -->
<div class="mt-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">
<span x-html="$icon('clock', 'inline w-5 h-5 mr-2')"></span>
Recent Transactions at This Location
</h3>
</div>
<div class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-700">
<th class="px-4 py-3">Time</th>
<th class="px-4 py-3">Customer</th>
<th class="px-4 py-3">Type</th>
<th class="px-4 py-3 text-right">Points</th>
<th class="px-4 py-3">Notes</th>
</tr>
</thead>
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<template x-if="recentTransactions.length === 0">
<tr>
<td colspan="5" class="px-4 py-6 text-center text-gray-500 dark:text-gray-400">
No recent transactions
</td>
</tr>
</template>
<template x-for="tx in recentTransactions" :key="tx.id">
<tr class="text-gray-700 dark:text-gray-400">
<td class="px-4 py-3 text-sm" x-text="formatTime(tx.transaction_at)"></td>
<td class="px-4 py-3 text-sm" x-text="tx.customer_name || 'Unknown'"></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.points_delta > 0 ? 'Earned' : 'Redeemed'"></span>
</td>
<td class="px-4 py-3 text-sm text-right font-medium"
:class="tx.points_delta > 0 ? 'text-green-600' : 'text-orange-600'"
x-text="(tx.points_delta > 0 ? '+' : '') + formatNumber(tx.points_delta)"></td>
<td class="px-4 py-3 text-sm text-gray-500" x-text="tx.notes || '-'"></td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</div>
<!-- Staff PIN Modal -->
{% call modal_simple(id='pinModal', title='Enter Staff PIN', show_var='showPinEntry') %}
<div class="p-6">
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
Enter your staff PIN to authorize this transaction.
</p>
<div class="flex justify-center mb-4">
<div class="flex gap-2">
<template x-for="i in 4">
<div class="w-12 h-12 border-2 rounded-lg flex items-center justify-center text-2xl font-bold"
:class="pinDigits.length >= i ? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20' : 'border-gray-300 dark:border-gray-600'"
x-text="pinDigits.length >= i ? '*' : ''"></div>
</template>
</div>
</div>
<div class="grid grid-cols-3 gap-2 max-w-xs mx-auto">
<template x-for="digit in [1, 2, 3, 4, 5, 6, 7, 8, 9]">
<button @click="addPinDigit(digit)"
class="h-14 text-xl font-semibold rounded-lg bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600">
<span x-text="digit"></span>
</button>
</template>
<button @click="pinDigits = ''"
class="h-14 text-sm font-medium rounded-lg bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600">
Clear
</button>
<button @click="addPinDigit(0)"
class="h-14 text-xl font-semibold rounded-lg bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600">
0
</button>
<button @click="removePinDigit()"
class="h-14 text-sm font-medium rounded-lg bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600">
<span x-html="$icon('backspace', 'w-6 h-6 mx-auto')"></span>
</button>
</div>
<div class="mt-4 flex justify-end gap-3">
<button @click="cancelPinEntry()"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600">
Cancel
</button>
<button @click="submitTransaction()"
:disabled="pinDigits.length !== 4 || processing"
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50">
<span x-show="processing" x-html="$icon('spinner', 'w-4 h-4 mr-2 inline animate-spin')"></span>
<span x-text="processing ? 'Processing...' : 'Confirm'"></span>
</button>
</div>
</div>
{% endcall %}
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('loyalty_static', path='vendor/js/loyalty-terminal.js') }}"></script>
{% endblock %}