feat(loyalty): align program view, edit, and analytics pages across all frontends
Some checks failed
CI / ruff (push) Successful in 11s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled

Standardize naming (Program for view/edit, Analytics for stats), create shared
read-only program-view partial, fix admin edit field population bug (14 missing
fields), add store Program menu item, and rename merchant Overview→Program,
Settings→Analytics.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-10 22:51:26 +01:00
parent aefca3115e
commit eee33d6a1b
20 changed files with 674 additions and 665 deletions

View File

@@ -111,58 +111,8 @@
</div>
<!-- Program Configuration -->
<div x-show="program" class="px-4 py-3 mb-8 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('cog', 'inline w-5 h-5 mr-2')"></span>
Program Configuration
</h3>
<div class="flex items-center gap-2">
<button @click="toggleActive()"
class="flex items-center px-3 py-1.5 text-sm font-medium rounded-lg border transition-colors"
:class="program?.is_active
? 'text-orange-600 border-orange-300 hover:bg-orange-50 dark:text-orange-400 dark:border-orange-700 dark:hover:bg-orange-900/20'
: 'text-green-600 border-green-300 hover:bg-green-50 dark:text-green-400 dark:border-green-700 dark:hover:bg-green-900/20'">
<span x-html="$icon(program?.is_active ? 'pause' : 'play', 'w-4 h-4 mr-1')"></span>
<span x-text="program?.is_active ? 'Deactivate' : 'Activate'"></span>
</button>
<button @click="confirmDeleteProgram()"
class="flex items-center px-3 py-1.5 text-sm font-medium text-red-600 border border-red-300 rounded-lg hover:bg-red-50 dark:text-red-400 dark:border-red-700 dark:hover:bg-red-900/20">
<span x-html="$icon('trash', 'w-4 h-4 mr-1')"></span>
Delete
</button>
</div>
</div>
<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>
{% set show_edit_button = false %}
{% include "loyalty/shared/program-view.html" %}
<!-- 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">

View File

@@ -1,24 +1,17 @@
{# app/modules/loyalty/templates/loyalty/merchant/overview.html #}
{# app/modules/loyalty/templates/loyalty/merchant/analytics.html #}
{% extends "merchant/base.html" %}
{% block title %}Loyalty Overview{% endblock %}
{% block title %}Loyalty Analytics{% endblock %}
{% block content %}
<div x-data="merchantLoyaltyOverview()">
<div x-data="merchantLoyaltyAnalytics()">
<!-- Page Header -->
<div class="mb-8 mt-6 flex items-center justify-between">
<div>
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100">Loyalty Overview</h2>
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100">Loyalty Analytics</h2>
<p class="mt-1 text-gray-500 dark:text-gray-400">Loyalty program statistics across all your stores.</p>
</div>
<template x-if="stats.program_id">
<a href="/merchants/loyalty/settings"
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('cog', 'w-4 h-4 mr-2')"></span>
Edit Program
</a>
</template>
</div>
<!-- No Program State -->
@@ -27,9 +20,9 @@
<span x-html="$icon('gift', 'w-12 h-12 mx-auto text-gray-400')"></span>
<h3 class="mt-4 text-lg font-medium text-gray-900 dark:text-white">No Loyalty Program</h3>
<p class="mt-2 text-gray-500 dark:text-gray-400">
Your loyalty program hasn't been set up yet. Create one to start rewarding your customers.
Set up a loyalty program to see analytics here.
</p>
<a href="/merchants/loyalty/settings"
<a href="/merchants/loyalty/program/edit"
class="inline-flex items-center mt-4 px-6 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700">
<span x-html="$icon('plus', 'w-4 h-4 mr-2')"></span>
Create Program
@@ -120,10 +113,16 @@
{% block extra_scripts %}
<script>
function merchantLoyaltyOverview() {
function merchantLoyaltyAnalytics() {
return {
...data(),
currentPage: 'loyalty-analytics',
loading: false,
stats: {{ loyalty_stats | tojson }},
init() {
this.loadMenuConfig();
},
};
}
</script>

View File

@@ -0,0 +1,42 @@
{# app/modules/loyalty/templates/loyalty/merchant/program-edit.html #}
{% extends "merchant/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 confirm_modal %}
{% block title %}Loyalty Settings{% endblock %}
{% block alpine_data %}merchantLoyaltySettings(){% 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">
{% set show_delete = true %}
{% set show_status = true %}
{% set cancel_url = '/merchants/loyalty/program' %}
{% include "loyalty/shared/program-form.html" %}
</form>
</div>
<!-- Delete Confirmation Modal -->
{{ confirm_modal(
'deleteProgramModal',
'Delete Loyalty Program',
'This will permanently delete your loyalty program and all associated data (cards, transactions, rewards). This action cannot be undone.',
'deleteProgram()',
'showDeleteModal',
'Delete Program',
'Cancel',
'danger'
) }}
{% endblock %}
{% block extra_scripts %}
<script defer src="{{ url_for('loyalty_static', path='shared/js/loyalty-program-form.js') }}"></script>
<script defer src="{{ url_for('loyalty_static', path='merchant/js/loyalty-settings.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,67 @@
{# app/modules/loyalty/templates/loyalty/merchant/program.html #}
{% extends "merchant/base.html" %}
{% block title %}Loyalty Program{% endblock %}
{% block content %}
<div x-data="merchantLoyaltyProgram()">
<!-- Page Header -->
<div class="mb-8 mt-6 flex items-center justify-between">
<div>
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100">Loyalty Program</h2>
<p class="mt-1 text-gray-500 dark:text-gray-400">Your loyalty program configuration.</p>
</div>
<template x-if="stats.program_id">
<a href="/merchants/loyalty/program/edit"
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('pencil', 'w-4 h-4 mr-2')"></span>
Edit Program
</a>
</template>
</div>
<!-- No Program State -->
<template x-if="!stats.program_id && !loading">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-12 text-center">
<span x-html="$icon('gift', 'w-12 h-12 mx-auto text-gray-400')"></span>
<h3 class="mt-4 text-lg font-medium text-gray-900 dark:text-white">No Loyalty Program</h3>
<p class="mt-2 text-gray-500 dark:text-gray-400">
Your loyalty program hasn't been set up yet. Create one to start rewarding your customers.
</p>
<a href="/merchants/loyalty/program/edit"
class="inline-flex items-center mt-4 px-6 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700">
<span x-html="$icon('plus', 'w-4 h-4 mr-2')"></span>
Create Program
</a>
</div>
</template>
<!-- Program View -->
<template x-if="stats.program_id || loading">
<div>
{% set edit_url = '/merchants/loyalty/program/edit' %}
{% set show_edit_button = false %}
{% include "loyalty/shared/program-view.html" %}
</div>
</template>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
function merchantLoyaltyProgram() {
return {
...data(),
currentPage: 'loyalty-program',
loading: false,
stats: {{ loyalty_stats | tojson }},
program: {{ (loyalty_stats.program if loyalty_stats.program else 'null') | tojson }},
init() {
this.loadMenuConfig();
},
};
}
</script>
{% endblock %}

View File

@@ -1,189 +0,0 @@
{# app/modules/loyalty/templates/loyalty/merchant/settings.html #}
{% extends "merchant/base.html" %}
{% from 'shared/macros/headers.html' import page_header_flex %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% block title %}Loyalty Settings{% endblock %}
{% block alpine_data %}merchantLoyaltySettings(){% 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-between">
<div>
<template x-if="!isNewProgram">
<button type="button" @click="confirmDelete()"
class="flex items-center px-4 py-2 text-sm font-medium text-red-600 border border-red-300 rounded-lg hover:bg-red-50 dark:text-red-400 dark:border-red-700 dark:hover:bg-red-900/20">
<span x-html="$icon('trash', 'w-4 h-4 mr-2')"></span>
Delete Program
</button>
</template>
</div>
<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...' : (isNewProgram ? 'Create Program' : 'Save Settings')"></span>
</button>
</div>
</form>
</div>
<!-- Delete Confirmation Modal -->
<div x-show="showDeleteModal" x-cloak
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 p-6 max-w-md w-full mx-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">Delete Loyalty Program</h3>
<p class="text-gray-600 dark:text-gray-400 mb-4">
This will permanently delete your loyalty program and all associated data (cards, transactions, rewards).
This action cannot be undone.
</p>
<div class="flex justify-end gap-3">
<button @click="showDeleteModal = false"
class="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">
Cancel
</button>
<button @click="deleteProgram()" :disabled="deleting"
class="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700 disabled:opacity-50">
<span x-text="deleting ? 'Deleting...' : 'Delete Program'"></span>
</button>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script defer src="{{ url_for('loyalty_static', path='merchant/js/loyalty-settings.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,228 @@
{# app/modules/loyalty/templates/loyalty/shared/program-view.html #}
{#
Read-only program configuration view partial.
Include with:
{% include "loyalty/shared/program-view.html" %}
Expected Jinja2 variables (set before include):
- edit_url (str) — href for "Edit Program" button
- show_edit_button (bool, default true) — whether to show the edit button
Expected Alpine.js state on the parent component:
- program.* — full program object (from API or stats.program)
#}
<div x-show="program" class="px-4 py-3 mb-8 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('cog', 'inline w-5 h-5 mr-2')"></span>
Program Configuration
</h3>
<div class="flex items-center gap-2">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium capitalize"
:class="{
'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300': program?.loyalty_type === 'points',
'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300': program?.loyalty_type === 'stamps',
'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300': program?.loyalty_type === 'hybrid'
}"
x-text="program?.loyalty_type || 'unknown'"></span>
<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>
{% if show_edit_button is not defined or show_edit_button %}
<a href="{{ edit_url }}"
class="flex items-center px-3 py-1.5 text-sm font-medium text-purple-600 border border-purple-300 rounded-lg hover:bg-purple-50 dark:text-purple-400 dark:border-purple-700 dark:hover:bg-purple-900/20">
<span x-html="$icon('pencil', 'w-4 h-4 mr-1')"></span>
Edit
</a>
{% endif %}
</div>
</div>
<!-- Program Info -->
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3 mb-6">
<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 || '-'">-</p>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase mb-2">Card Name</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="program?.card_name || '-'">-</p>
</div>
</div>
<!-- Stamps Config -->
<template x-if="program?.loyalty_type === 'stamps' || program?.loyalty_type === 'hybrid'">
<div class="mb-6">
<h4 class="text-sm font-semibold text-gray-600 dark:text-gray-400 uppercase mb-3 flex items-center">
<span x-html="$icon('star', 'inline w-4 h-4 mr-1')"></span>
Stamps Configuration
</h4>
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">Stamps Target</p>
<p class="text-sm font-medium text-gray-700 dark:text-gray-300" x-text="program?.stamps_target || '-'">-</p>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">Reward Description</p>
<p class="text-sm font-medium text-gray-700 dark:text-gray-300" x-text="program?.stamps_reward_description || '-'">-</p>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">Reward Value</p>
<p class="text-sm font-medium text-gray-700 dark:text-gray-300"
x-text="program?.stamps_reward_value_cents ? '€' + (program.stamps_reward_value_cents / 100).toFixed(2) : '-'">-</p>
</div>
</div>
</div>
</template>
<!-- Points Config -->
<template x-if="program?.loyalty_type === 'points' || program?.loyalty_type === 'hybrid'">
<div class="mb-6">
<h4 class="text-sm font-semibold text-gray-600 dark:text-gray-400 uppercase mb-3 flex items-center">
<span x-html="$icon('currency-dollar', 'inline w-4 h-4 mr-1')"></span>
Points Configuration
</h4>
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">Points per EUR</p>
<p class="text-sm font-medium text-gray-700 dark:text-gray-300" x-text="program?.points_per_euro || 1">-</p>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">Welcome Bonus</p>
<p class="text-sm font-medium 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 text-gray-500 dark:text-gray-400 mb-1">Minimum Redemption</p>
<p class="text-sm font-medium 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 text-gray-500 dark:text-gray-400 mb-1">Minimum Purchase</p>
<p class="text-sm font-medium text-gray-700 dark:text-gray-300"
x-text="program?.minimum_purchase_cents ? '€' + (program.minimum_purchase_cents / 100).toFixed(2) : 'None'">-</p>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">Points Expiration</p>
<p class="text-sm font-medium text-gray-700 dark:text-gray-300"
x-text="program?.points_expiration_days ? program.points_expiration_days + ' days of inactivity' : 'Never'">-</p>
</div>
</div>
</div>
</template>
<!-- Redemption Rewards -->
<template x-if="(program?.loyalty_type === 'points' || program?.loyalty_type === 'hybrid') && program?.points_rewards?.length > 0">
<div class="mb-6">
<h4 class="text-sm font-semibold text-gray-600 dark:text-gray-400 uppercase mb-3 flex items-center">
<span x-html="$icon('gift', 'inline w-4 h-4 mr-1')"></span>
Redemption Rewards
</h4>
<div class="overflow-hidden border border-gray-200 dark:border-gray-700 rounded-lg">
<table class="w-full text-sm">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr>
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-gray-300 uppercase">Reward</th>
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-gray-300 uppercase">Points Required</th>
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-gray-300 uppercase">Description</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<template x-for="reward in program.points_rewards" :key="reward.name">
<tr>
<td class="px-4 py-2 font-medium text-gray-700 dark:text-gray-300" x-text="reward.name">-</td>
<td class="px-4 py-2 text-gray-600 dark:text-gray-400" x-text="reward.points_required">-</td>
<td class="px-4 py-2 text-gray-600 dark:text-gray-400" x-text="reward.description || '-'">-</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</template>
<!-- Anti-Fraud -->
<div class="mb-6">
<h4 class="text-sm font-semibold text-gray-600 dark:text-gray-400 uppercase mb-3 flex items-center">
<span x-html="$icon('shield-check', 'inline w-4 h-4 mr-1')"></span>
Anti-Fraud
</h4>
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">Cooldown</p>
<p class="text-sm font-medium text-gray-700 dark:text-gray-300"
x-text="program?.cooldown_minutes ? program.cooldown_minutes + ' minutes' : 'None'">-</p>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">Max Daily Stamps</p>
<p class="text-sm font-medium text-gray-700 dark:text-gray-300" x-text="program?.max_daily_stamps || '-'">-</p>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">Staff PIN Required</p>
<span class="inline-flex items-center px-2 py-1 text-xs font-semibold leading-tight rounded-full"
:class="program?.require_staff_pin ? '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="program?.require_staff_pin ? 'Yes' : 'No'"></span>
</span>
</div>
</div>
</div>
<!-- Branding -->
<div class="mb-6">
<h4 class="text-sm font-semibold text-gray-600 dark:text-gray-400 uppercase mb-3 flex items-center">
<span x-html="$icon('paint-brush', 'inline w-4 h-4 mr-1')"></span>
Branding
</h4>
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">Primary Color</p>
<div class="flex items-center gap-2">
<div class="w-6 h-6 rounded border border-gray-300 dark:border-gray-600"
:style="'background-color: ' + (program?.card_color || '#6B21A8')"></div>
<span class="text-sm text-gray-700 dark:text-gray-300" x-text="program?.card_color || '#6B21A8'"></span>
</div>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">Secondary Color</p>
<div class="flex items-center gap-2">
<div class="w-6 h-6 rounded border border-gray-300 dark:border-gray-600"
:style="'background-color: ' + (program?.card_secondary_color || '#FFFFFF')"></div>
<span class="text-sm text-gray-700 dark:text-gray-300" x-text="program?.card_secondary_color || '#FFFFFF'"></span>
</div>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">Logo URL</p>
<p class="text-sm text-gray-700 dark:text-gray-300 truncate" x-text="program?.logo_url || '-'">-</p>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">Hero Image URL</p>
<p class="text-sm text-gray-700 dark:text-gray-300 truncate" x-text="program?.hero_image_url || '-'">-</p>
</div>
</div>
</div>
<!-- Terms -->
<div>
<h4 class="text-sm font-semibold text-gray-600 dark:text-gray-400 uppercase mb-3 flex items-center">
<span x-html="$icon('document-text', 'inline w-4 h-4 mr-1')"></span>
Terms & Privacy
</h4>
<div class="grid gap-6 md:grid-cols-2">
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">Terms & Conditions</p>
<p class="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-line" x-text="program?.terms_text || '-'">-</p>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">Privacy Policy URL</p>
<template x-if="program?.privacy_url">
<a :href="program.privacy_url" target="_blank" class="text-sm text-purple-600 hover:text-purple-700 dark:text-purple-400" x-text="program.privacy_url"></a>
</template>
<template x-if="!program?.privacy_url">
<p class="text-sm text-gray-700 dark:text-gray-300">-</p>
</template>
</div>
</div>
</div>
</div>

View File

@@ -1,21 +1,21 @@
{# app/modules/loyalty/templates/loyalty/store/stats.html #}
{# app/modules/loyalty/templates/loyalty/store/analytics.html #}
{% extends "store/base.html" %}
{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% block title %}Loyalty Stats{% endblock %}
{% block title %}Loyalty Analytics{% endblock %}
{% block alpine_data %}storeLoyaltyStats(){% endblock %}
{% block alpine_data %}storeLoyaltyAnalytics(){% endblock %}
{% block content %}
{% call page_header_flex(title='Loyalty Statistics', subtitle='Track your loyalty program performance') %}
{% call page_header_flex(title='Loyalty Analytics', 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') }}
{{ loading_state('Loading analytics...') }}
{{ error_state('Error loading analytics') }}
<!-- 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">
@@ -25,7 +25,7 @@
<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 merchant doesn't have a loyalty program configured yet.</p>
{% if user.role == 'merchant_owner' %}
<a href="/store/{{ store_code }}/loyalty/settings"
<a href="/store/{{ store_code }}/loyalty/program/edit"
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
@@ -139,10 +139,10 @@
<span x-html="$icon('users', 'w-4 h-4 mr-2')"></span>
View Members
</a>
<a href="/store/{{ store_code }}/loyalty/settings"
<a href="/store/{{ store_code }}/loyalty/program"
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
Program
</a>
</div>
</div>
@@ -150,5 +150,5 @@
{% endblock %}
{% block extra_scripts %}
<script defer src="{{ url_for('loyalty_static', path='store/js/loyalty-stats.js') }}"></script>
<script defer src="{{ url_for('loyalty_static', path='store/js/loyalty-analytics.js') }}"></script>
{% endblock %}

View File

@@ -34,7 +34,7 @@
<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 merchant doesn't have a loyalty program configured yet.</p>
{% if user.role == 'merchant_owner' %}
<a href="/store/{{ store_code }}/loyalty/settings"
<a href="/store/{{ store_code }}/loyalty/program/edit"
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

View File

@@ -0,0 +1,100 @@
{# app/modules/loyalty/templates/loyalty/store/program.html #}
{% extends "store/base.html" %}
{% from 'shared/macros/headers.html' import page_header_flex %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% block title %}Loyalty Program{% endblock %}
{% block alpine_data %}storeLoyaltyProgram(){% endblock %}
{% block content %}
{% call page_header_flex(title='Loyalty Program', subtitle='Your loyalty program configuration') %}
<div class="flex items-center gap-3">
{% if user.role == 'merchant_owner' %}
<a href="/store/{{ store_code }}/loyalty/program/edit"
class="flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700"
x-show="program">
<span x-html="$icon('pencil', 'w-4 h-4 mr-2')"></span>
Edit Program
</a>
{% endif %}
</div>
{% endcall %}
{{ loading_state('Loading program...') }}
{{ error_state('Error loading program') }}
<!-- No Program State -->
<div x-show="!loading && !program" class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-12 text-center">
<span x-html="$icon('gift', 'w-12 h-12 mx-auto text-gray-400')"></span>
<h3 class="mt-4 text-lg font-medium text-gray-900 dark:text-white">No Loyalty Program</h3>
<p class="mt-2 text-gray-500 dark:text-gray-400">
Your merchant doesn't have a loyalty program configured yet.
</p>
{% if user.role == 'merchant_owner' %}
<a href="/store/{{ store_code }}/loyalty/program/edit"
class="inline-flex items-center mt-4 px-6 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700">
<span x-html="$icon('plus', 'w-4 h-4 mr-2')"></span>
Create Program
</a>
{% else %}
<p class="mt-4 text-sm text-gray-500 dark:text-gray-400">Contact your administrator to set up a loyalty program.</p>
{% endif %}
</div>
<!-- Program View -->
<div x-show="!loading && program">
{% set edit_url = '/store/' ~ store_code ~ '/loyalty/program/edit' %}
{% if user.role == 'merchant_owner' %}
{% set show_edit_button = true %}
{% else %}
{% set show_edit_button = false %}
{% endif %}
{% include "loyalty/shared/program-view.html" %}
</div>
{% endblock %}
{% block extra_scripts %}
<script>
function storeLoyaltyProgram() {
return {
...data(),
currentPage: 'loyalty-program',
program: null,
loading: false,
error: null,
async init() {
if (window._storeLoyaltyProgramInitialized) return;
window._storeLoyaltyProgramInitialized = true;
const parentInit = data().init;
if (parentInit) {
await parentInit.call(this);
}
await this.loadProgram();
},
async loadProgram() {
this.loading = true;
this.error = null;
try {
const response = await apiClient.get('/store/loyalty/program');
if (response) {
this.program = response;
}
} catch (error) {
if (error.status !== 404) {
this.error = error.message || 'Failed to load program';
}
} finally {
this.loading = false;
}
},
};
}
</script>
{% endblock %}

View File

@@ -2,6 +2,7 @@
{% extends "store/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 confirm_modal %}
{% block title %}Loyalty Settings{% endblock %}
@@ -9,12 +10,12 @@
{% block content %}
<!-- Page Header -->
{% call page_header_flex(title='Loyalty Settings', subtitle='Configure your loyalty program') %}
{% call page_header_flex(title='Loyalty Program Settings', subtitle='Configure your loyalty program') %}
<div class="flex items-center gap-3">
<a href="/store/{{ store_code }}/loyalty/terminal"
<a href="/store/{{ store_code }}/loyalty/program"
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('terminal', 'w-4 h-4 mr-2')"></span>
Terminal
<span x-html="$icon('arrow-left', 'w-4 h-4 mr-2')"></span>
Back to Program
</a>
</div>
{% endcall %}
@@ -35,212 +36,29 @@
</div>
<!-- Settings Form -->
<div x-show="!loading && isOwner" class="space-y-6">
<!-- Program Type (create only) -->
<div x-show="!program" 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('layers', 'inline w-5 h-5 mr-2')"></span>
Program Type
</h3>
</div>
<div class="p-4">
<div class="grid gap-4 md:grid-cols-3">
<label class="relative flex items-start p-4 border-2 rounded-lg cursor-pointer transition-colors"
:class="form.loyalty_type === 'stamps' ? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20' : 'border-gray-200 dark:border-gray-700 hover:border-gray-300'">
<input type="radio" x-model="form.loyalty_type" value="stamps" class="sr-only">
<div>
<span x-html="$icon('stamp', 'w-8 h-8 text-purple-500 mb-2')"></span>
<p class="font-semibold text-gray-700 dark:text-gray-200">Stamps</p>
<p class="text-xs text-gray-500 dark:text-gray-400">Collect stamps for rewards</p>
</div>
</label>
<label class="relative flex items-start p-4 border-2 rounded-lg cursor-pointer transition-colors"
:class="form.loyalty_type === 'points' ? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20' : 'border-gray-200 dark:border-gray-700 hover:border-gray-300'">
<input type="radio" x-model="form.loyalty_type" value="points" class="sr-only">
<div>
<span x-html="$icon('coins', 'w-8 h-8 text-purple-500 mb-2')"></span>
<p class="font-semibold text-gray-700 dark:text-gray-200">Points</p>
<p class="text-xs text-gray-500 dark:text-gray-400">Earn points per purchase</p>
</div>
</label>
<label class="relative flex items-start p-4 border-2 rounded-lg cursor-pointer transition-colors"
:class="form.loyalty_type === 'hybrid' ? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20' : 'border-gray-200 dark:border-gray-700 hover:border-gray-300'">
<input type="radio" x-model="form.loyalty_type" value="hybrid" class="sr-only">
<div>
<span x-html="$icon('layers', 'w-8 h-8 text-purple-500 mb-2')"></span>
<p class="font-semibold text-gray-700 dark:text-gray-200">Hybrid</p>
<p class="text-xs text-gray-500 dark:text-gray-400">Both stamps and points</p>
</div>
</label>
</div>
</div>
</div>
<!-- Stamps Configuration -->
<div x-show="form.loyalty_type === 'stamps' || form.loyalty_type === 'hybrid'"
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">Stamps Configuration</h3>
</div>
<div class="p-4 grid gap-4 md:grid-cols-2">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Stamps for Reward</label>
<input type="number" x-model.number="form.stamps_target" min="1" max="50"
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-1">Reward Description</label>
<input type="text" x-model="form.stamps_reward_description" maxlength="255"
placeholder="e.g., Free coffee"
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-1">Reward Value (cents)</label>
<input type="number" x-model.number="form.stamps_reward_value_cents" min="0"
placeholder="e.g., 500 for 5 EUR"
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>
<!-- Points Configuration -->
<div x-show="form.loyalty_type === 'points' || form.loyalty_type === 'hybrid'"
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">Points Configuration</h3>
</div>
<div class="p-4 grid gap-4 md:grid-cols-2">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Points per EUR</label>
<input type="number" x-model.number="form.points_per_euro" min="1" max="100"
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-1">Welcome Bonus Points</label>
<input type="number" x-model.number="form.welcome_bonus_points" min="0"
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-1">Min Redemption Points</label>
<input type="number" x-model.number="form.minimum_redemption_points" min="1"
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-1">Min Purchase (cents)</label>
<input type="number" x-model.number="form.minimum_purchase_cents" min="0"
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-1">Points Expiry (days)</label>
<input type="number" x-model.number="form.points_expiration_days" min="30"
placeholder="Leave empty for no expiry"
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>
<!-- Rewards List -->
<div class="px-4 pb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Rewards</label>
<div class="space-y-2">
<template x-for="(reward, index) in form.points_rewards" :key="index">
<div class="flex items-center gap-2">
<input type="text" x-model="reward.name" placeholder="Reward name"
class="flex-1 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 dark:text-gray-300">
<input type="number" x-model.number="reward.points_required" placeholder="Points" min="1"
class="w-24 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 dark:text-gray-300">
<button @click="form.points_rewards.splice(index, 1)" type="button"
class="p-2 text-red-500 hover:text-red-700">
<span x-html="$icon('x', 'w-4 h-4')"></span>
</button>
</div>
</template>
</div>
<button @click="addReward()" type="button"
class="mt-2 text-sm text-purple-600 hover:text-purple-800 dark:text-purple-400">
+ Add Reward
</button>
</div>
</div>
<!-- Anti-Fraud -->
<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">Anti-Fraud Settings</h3>
</div>
<div class="p-4 grid gap-4 md:grid-cols-3">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Cooldown (minutes)</label>
<input type="number" x-model.number="form.cooldown_minutes" min="0" max="1440"
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-1">Max Daily Stamps</label>
<input type="number" x-model.number="form.max_daily_stamps" min="1" max="50"
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 class="flex items-end">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" x-model="form.require_staff_pin"
class="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Require Staff PIN</span>
</label>
</div>
</div>
</div>
<!-- Branding -->
<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">Branding</h3>
</div>
<div class="p-4 grid gap-4 md:grid-cols-2">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Card Name</label>
<input type="text" x-model="form.card_name" maxlength="100"
placeholder="e.g., My Rewards Card"
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-1">Card Color</label>
<div class="flex items-center gap-2">
<input type="color" x-model="form.card_color"
class="w-10 h-10 rounded border border-gray-300 dark:border-gray-600 cursor-pointer">
<input type="text" x-model="form.card_color" maxlength="7" placeholder="#4F46E5"
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>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Logo URL</label>
<input type="url" x-model="form.logo_url" maxlength="500"
placeholder="https://..."
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-1">Terms & Conditions</label>
<textarea x-model="form.terms_text" rows="3"
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"></textarea>
</div>
</div>
</div>
<!-- Actions -->
<div class="flex justify-end gap-3">
<a href="/store/{{ store_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 @click="saveProgram()"
:disabled="saving"
class="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 inline animate-spin')"></span>
<span x-text="saving ? 'Saving...' : (program ? 'Save Changes' : 'Create Program')"></span>
</button>
</div>
<div x-show="!loading && isOwner">
<form @submit.prevent="saveSettings">
{% set show_delete = true %}
{% set show_status = true %}
{% set cancel_url = '/store/' ~ store_code ~ '/loyalty/program' %}
{% include "loyalty/shared/program-form.html" %}
</form>
</div>
<!-- Delete Confirmation Modal -->
{{ confirm_modal(
'deleteProgramModal',
'Delete Loyalty Program',
'This will permanently delete the loyalty program and all associated data (cards, transactions, rewards). This action cannot be undone.',
'deleteProgram()',
'showDeleteModal',
'Delete Program',
'Cancel',
'danger'
) }}
{% endblock %}
{% block extra_scripts %}
<script defer src="{{ url_for('loyalty_static', path='shared/js/loyalty-program-form.js') }}"></script>
<script defer src="{{ url_for('loyalty_static', path='store/js/loyalty-settings.js') }}"></script>
{% endblock %}

View File

@@ -17,10 +17,10 @@
<span x-html="$icon('users', 'w-4 h-4 mr-2')"></span>
Members
</a>
<a href="/store/{{ store_code }}/loyalty/stats"
<a href="/store/{{ store_code }}/loyalty/analytics"
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
Analytics
</a>
</div>
{% endcall %}
@@ -37,7 +37,7 @@
<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 merchant doesn't have a loyalty program configured yet.</p>
{% if user.role == 'merchant_owner' %}
<a href="/store/{{ store_code }}/loyalty/settings"
<a href="/store/{{ store_code }}/loyalty/program/edit"
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