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

@@ -131,11 +131,12 @@ loyalty_module = ModuleDefinition(
FrontendType.STORE: [
"terminal", # Loyalty terminal
"cards", # Customer cards
"stats", # Store stats
"loyalty-program", # Program config
"loyalty-analytics", # Store analytics
],
FrontendType.MERCHANT: [
"loyalty-overview", # Merchant loyalty overview
"loyalty-settings", # Merchant loyalty settings
"loyalty-program", # Merchant loyalty program
"loyalty-analytics", # Merchant loyalty analytics
],
},
# New module-driven menu definitions
@@ -188,10 +189,18 @@ loyalty_module = ModuleDefinition(
requires_permission="loyalty.view_programs",
),
MenuItemDefinition(
id="stats",
label_key="loyalty.menu.statistics",
id="loyalty-program",
label_key="loyalty.menu.program",
icon="cog",
route="/store/{store_code}/loyalty/program",
order=25,
requires_permission="loyalty.view_programs",
),
MenuItemDefinition(
id="loyalty-analytics",
label_key="loyalty.menu.analytics",
icon="chart-bar",
route="/store/{store_code}/loyalty/stats",
route="/store/{store_code}/loyalty/analytics",
order=30,
requires_permission="loyalty.view_programs",
),
@@ -206,17 +215,17 @@ loyalty_module = ModuleDefinition(
order=60,
items=[
MenuItemDefinition(
id="loyalty-overview",
label_key="loyalty.menu.overview",
id="loyalty-program",
label_key="loyalty.menu.program",
icon="gift",
route="/merchants/loyalty/overview",
route="/merchants/loyalty/program",
order=10,
),
MenuItemDefinition(
id="loyalty-settings",
label_key="loyalty.menu.settings",
icon="cog",
route="/merchants/loyalty/settings",
id="loyalty-analytics",
label_key="loyalty.menu.analytics",
icon="chart-bar",
route="/merchants/loyalty/analytics",
order=20,
),
],

View File

@@ -88,6 +88,7 @@
"terminal": "Terminal",
"customer_cards": "Customer Cards",
"statistics": "Statistics",
"program": "Program",
"overview": "Overview",
"settings": "Settings"
},

View File

@@ -78,6 +78,7 @@
"terminal": "Terminal",
"customer_cards": "Cartes clients",
"statistics": "Statistiques",
"program": "Programme",
"overview": "Aperçu",
"settings": "Paramètres"
},

View File

@@ -3,7 +3,9 @@
Loyalty Merchant Page Routes (HTML rendering).
Merchant portal pages for:
- Loyalty overview (aggregate stats across all stores)
- Loyalty program (read-only view)
- Loyalty program edit
- Loyalty analytics (aggregate stats across all stores)
Authentication: merchant_token cookie or Authorization header.
@@ -60,23 +62,22 @@ def _get_merchant_context(
# ============================================================================
# LOYALTY OVERVIEW
# LOYALTY PROGRAM (Read-only view)
# ============================================================================
@router.get("/overview", response_class=HTMLResponse, include_in_schema=False)
async def merchant_loyalty_overview(
@router.get("/program", response_class=HTMLResponse, include_in_schema=False)
async def merchant_loyalty_program(
request: Request,
current_user: UserContext = Depends(get_current_merchant_from_cookie_or_header),
merchant: Merchant = Depends(get_merchant_for_current_user_page),
db: Session = Depends(get_db),
):
"""
Render merchant loyalty overview page.
Render merchant loyalty program view page.
Shows aggregate loyalty program stats across all merchant stores.
Shows read-only program configuration with link to edit.
"""
# Get merchant stats server-side
merchant_id = merchant.id
stats = {}
try:
@@ -95,25 +96,25 @@ async def merchant_loyalty_overview(
merchant_id=merchant_id,
)
return templates.TemplateResponse(
"loyalty/merchant/overview.html",
"loyalty/merchant/program.html",
context,
)
# ============================================================================
# LOYALTY SETTINGS
# LOYALTY PROGRAM EDIT
# ============================================================================
@router.get("/settings", response_class=HTMLResponse, include_in_schema=False)
async def merchant_loyalty_settings(
@router.get("/program/edit", response_class=HTMLResponse, include_in_schema=False)
async def merchant_loyalty_program_edit(
request: Request,
current_user: UserContext = Depends(get_current_merchant_from_cookie_or_header),
merchant: Merchant = Depends(get_merchant_for_current_user_page),
db: Session = Depends(get_db),
):
"""
Render merchant loyalty settings page.
Render merchant loyalty program edit page.
Allows merchant to create, configure, and manage their loyalty program.
"""
@@ -124,6 +125,46 @@ async def merchant_loyalty_settings(
merchant_id=merchant.id,
)
return templates.TemplateResponse(
"loyalty/merchant/settings.html",
"loyalty/merchant/program-edit.html",
context,
)
# ============================================================================
# LOYALTY ANALYTICS
# ============================================================================
@router.get("/analytics", response_class=HTMLResponse, include_in_schema=False)
async def merchant_loyalty_analytics(
request: Request,
current_user: UserContext = Depends(get_current_merchant_from_cookie_or_header),
merchant: Merchant = Depends(get_merchant_for_current_user_page),
db: Session = Depends(get_db),
):
"""
Render merchant loyalty analytics page.
Shows aggregate loyalty program stats across all merchant stores.
"""
merchant_id = merchant.id
stats = {}
try:
stats = program_service.get_merchant_stats(db, merchant_id)
except Exception:
logger.warning(
f"Failed to load loyalty stats for merchant {merchant_id}",
exc_info=True,
)
context = _get_merchant_context(
request,
db,
current_user,
loyalty_stats=stats,
merchant_id=merchant_id,
)
return templates.TemplateResponse(
"loyalty/merchant/analytics.html",
context,
)

View File

@@ -5,8 +5,9 @@ Loyalty Store Page Routes (HTML rendering).
Store pages for:
- Loyalty terminal (primary daily interface for staff)
- Loyalty members management
- Program settings
- Stats dashboard
- Program view (read-only)
- Program edit (settings)
- Analytics dashboard
Routes follow the standard store convention: /loyalty/...
so they match the menu URLs in definition.py.
@@ -183,49 +184,49 @@ async def store_loyalty_card_detail(
# ============================================================================
# STATS DASHBOARD
# PROGRAM VIEW (Read-only)
# ============================================================================
@router.get(
"/loyalty/stats",
"/loyalty/program",
response_class=HTMLResponse,
include_in_schema=False,
)
async def store_loyalty_stats(
async def store_loyalty_program(
request: Request,
store_code: str = Depends(get_resolved_store_code),
current_user: User = Depends(get_current_store_from_cookie_or_header),
db: Session = Depends(get_db),
):
"""
Render loyalty statistics dashboard.
Shows store's loyalty program metrics and trends.
Render loyalty program view page (read-only).
Shows program configuration. Edit button shown to merchant owners only.
"""
return templates.TemplateResponse(
"loyalty/store/stats.html",
"loyalty/store/program.html",
get_store_context(request, db, current_user, store_code),
)
# ============================================================================
# SETTINGS (Merchant Owner)
# PROGRAM EDIT (Merchant Owner)
# ============================================================================
@router.get(
"/loyalty/settings",
"/loyalty/program/edit",
response_class=HTMLResponse,
include_in_schema=False,
)
async def store_loyalty_settings(
async def store_loyalty_program_edit(
request: Request,
store_code: str = Depends(get_resolved_store_code),
current_user: User = Depends(get_current_store_from_cookie_or_header),
db: Session = Depends(get_db),
):
"""
Render loyalty program settings page.
Render loyalty program edit page.
Allows merchant owners to create or edit their loyalty program.
"""
return templates.TemplateResponse(
@@ -234,6 +235,32 @@ async def store_loyalty_settings(
)
# ============================================================================
# ANALYTICS DASHBOARD
# ============================================================================
@router.get(
"/loyalty/analytics",
response_class=HTMLResponse,
include_in_schema=False,
)
async def store_loyalty_analytics(
request: Request,
store_code: str = Depends(get_resolved_store_code),
current_user: User = Depends(get_current_store_from_cookie_or_header),
db: Session = Depends(get_db),
):
"""
Render loyalty analytics dashboard.
Shows store's loyalty program metrics and trends.
"""
return templates.TemplateResponse(
"loyalty/store/analytics.html",
get_store_context(request, db, current_user, store_code),
)
# ============================================================================
# ENROLLMENT
# ============================================================================

View File

@@ -826,6 +826,20 @@ class ProgramService:
"minimum_redemption_points": program.minimum_redemption_points,
"points_expiration_days": program.points_expiration_days,
"is_active": program.is_active,
"stamps_target": program.stamps_target,
"stamps_reward_description": program.stamps_reward_description,
"stamps_reward_value_cents": program.stamps_reward_value_cents,
"minimum_purchase_cents": program.minimum_purchase_cents,
"cooldown_minutes": program.cooldown_minutes,
"max_daily_stamps": program.max_daily_stamps,
"require_staff_pin": program.require_staff_pin,
"card_color": program.card_color,
"card_secondary_color": program.card_secondary_color,
"logo_url": program.logo_url,
"hero_image_url": program.hero_image_url,
"terms_text": program.terms_text,
"privacy_url": program.privacy_url,
"points_rewards": program.points_rewards,
}
thirty_days_ago = datetime.now(UTC) - timedelta(days=30)

View File

@@ -6,26 +6,11 @@ const loyaltySettingsLog = window.LogConfig.loggers.loyaltySettings || window.Lo
function merchantLoyaltySettings() {
return {
...data(),
currentPage: 'loyalty-settings',
settings: {
loyalty_type: 'points',
points_per_euro: 1,
welcome_bonus_points: 0,
minimum_redemption_points: 100,
points_expiration_days: null,
points_rewards: [],
card_name: '',
card_color: '#4F46E5',
is_active: true
},
...createProgramFormMixin(),
currentPage: 'loyalty-program',
loading: false,
saving: false,
deleting: false,
error: null,
isNewProgram: false,
showDeleteModal: false,
async init() {
loyaltySettingsLog.info('=== MERCHANT LOYALTY SETTINGS PAGE INITIALIZING ===');
@@ -46,17 +31,7 @@ function merchantLoyaltySettings() {
try {
const response = await apiClient.get('/merchants/loyalty/program');
if (response) {
this.settings = {
loyalty_type: response.loyalty_type || 'points',
points_per_euro: response.points_per_euro || 1,
welcome_bonus_points: response.welcome_bonus_points || 0,
minimum_redemption_points: response.minimum_redemption_points || 100,
points_expiration_days: response.points_expiration_days || null,
points_rewards: response.points_rewards || [],
card_name: response.card_name || '',
card_color: response.card_color || '#4F46E5',
is_active: response.is_active !== false
};
this.populateSettings(response);
this.isNewProgram = false;
loyaltySettingsLog.info('Settings loaded');
}
@@ -76,19 +51,13 @@ function merchantLoyaltySettings() {
this.saving = true;
try {
// Ensure rewards have IDs
this.settings.points_rewards = this.settings.points_rewards.map((r, i) => ({
...r,
id: r.id || `reward_${i + 1}`,
is_active: r.is_active !== false
}));
const payload = this.buildPayload();
let response;
if (this.isNewProgram) {
response = await apiClient.post('/merchants/loyalty/program', this.settings);
await apiClient.post('/merchants/loyalty/program', payload);
this.isNewProgram = false;
} else {
response = await apiClient.patch('/merchants/loyalty/program', this.settings);
await apiClient.patch('/merchants/loyalty/program', payload);
}
Utils.showToast('Settings saved successfully', 'success');
@@ -101,10 +70,6 @@ function merchantLoyaltySettings() {
}
},
confirmDelete() {
this.showDeleteModal = true;
},
async deleteProgram() {
this.deleting = true;
@@ -112,8 +77,7 @@ function merchantLoyaltySettings() {
await apiClient.delete('/merchants/loyalty/program');
Utils.showToast('Loyalty program deleted', 'success');
loyaltySettingsLog.info('Program deleted');
// Redirect to overview
window.location.href = '/merchants/loyalty/overview';
window.location.href = '/merchants/loyalty/program';
} catch (error) {
Utils.showToast(`Failed to delete: ${error.message}`, 'error');
loyaltySettingsLog.error('Delete failed:', error);
@@ -122,20 +86,6 @@ function merchantLoyaltySettings() {
this.showDeleteModal = false;
}
},
addReward() {
this.settings.points_rewards.push({
id: `reward_${Date.now()}`,
name: '',
points_required: 100,
description: '',
is_active: true
});
},
removeReward(index) {
this.settings.points_rewards.splice(index, 1);
}
};
}

View File

@@ -1,12 +1,12 @@
// app/modules/loyalty/static/store/js/loyalty-stats.js
// app/modules/loyalty/static/store/js/loyalty-analytics.js
// noqa: js-006 - async init pattern is safe, loadData has try/catch
const loyaltyStatsLog = window.LogConfig.loggers.loyaltyStats || window.LogConfig.createLogger('loyaltyStats');
const loyaltyAnalyticsLog = window.LogConfig.loggers.loyaltyAnalytics || window.LogConfig.createLogger('loyaltyAnalytics');
function storeLoyaltyStats() {
function storeLoyaltyAnalytics() {
return {
...data(),
currentPage: 'loyalty-stats',
currentPage: 'loyalty-analytics',
program: null,
@@ -27,9 +27,9 @@ function storeLoyaltyStats() {
error: null,
async init() {
loyaltyStatsLog.info('=== LOYALTY STATS PAGE INITIALIZING ===');
if (window._loyaltyStatsInitialized) return;
window._loyaltyStatsInitialized = true;
loyaltyAnalyticsLog.info('=== LOYALTY ANALYTICS PAGE INITIALIZING ===');
if (window._loyaltyAnalyticsInitialized) return;
window._loyaltyAnalyticsInitialized = true;
// IMPORTANT: Call parent init first to set storeCode from URL
const parentInit = data().init;
@@ -41,7 +41,7 @@ function storeLoyaltyStats() {
if (this.program) {
await this.loadStats();
}
loyaltyStatsLog.info('=== LOYALTY STATS PAGE INITIALIZATION COMPLETE ===');
loyaltyAnalyticsLog.info('=== LOYALTY ANALYTICS PAGE INITIALIZATION COMPLETE ===');
},
async loadProgram() {
@@ -72,10 +72,10 @@ function storeLoyaltyStats() {
transactions_30d: response.transactions_30d || 0,
avg_points_per_member: response.avg_points_per_member || 0
};
loyaltyStatsLog.info('Stats loaded');
loyaltyAnalyticsLog.info('Stats loaded');
}
} catch (error) {
loyaltyStatsLog.error('Failed to load stats:', error);
loyaltyAnalyticsLog.error('Failed to load stats:', error);
this.error = error.message;
} finally {
this.loading = false;
@@ -88,7 +88,7 @@ function storeLoyaltyStats() {
};
}
if (!window.LogConfig.loggers.loyaltyStats) {
window.LogConfig.loggers.loyaltyStats = window.LogConfig.createLogger('loyaltyStats');
if (!window.LogConfig.loggers.loyaltyAnalytics) {
window.LogConfig.loggers.loyaltyAnalytics = window.LogConfig.createLogger('loyaltyAnalytics');
}
loyaltyStatsLog.info('Loyalty stats module loaded');
loyaltyAnalyticsLog.info('Loyalty analytics module loaded');

View File

@@ -10,38 +10,16 @@ function loyaltySettings() {
return {
// Inherit base layout functionality
...data(),
...createProgramFormMixin(),
// Page identifier
currentPage: 'loyalty-settings',
currentPage: 'loyalty-program',
// State
program: null,
loading: false,
saving: false,
error: null,
isOwner: false,
// Form data
form: {
loyalty_type: 'points',
stamps_target: 10,
stamps_reward_description: 'Free item',
stamps_reward_value_cents: null,
points_per_euro: 10,
welcome_bonus_points: 0,
minimum_redemption_points: 100,
minimum_purchase_cents: 0,
points_expiration_days: null,
points_rewards: [],
cooldown_minutes: 15,
max_daily_stamps: 5,
require_staff_pin: true,
card_name: '',
card_color: '#4F46E5',
logo_url: '',
terms_text: '',
},
// Initialize
async init() {
loyaltySettingsLog.info('=== LOYALTY SETTINGS INITIALIZING ===');
@@ -85,83 +63,37 @@ function loyaltySettings() {
const response = await apiClient.get('/store/loyalty/program');
if (response) {
this.program = response;
this.populateForm(response);
this.populateSettings(response);
this.isNewProgram = false;
loyaltySettingsLog.info('Program loaded:', response.display_name);
}
} catch (error) {
if (error.status === 404) {
loyaltySettingsLog.info('No program configured — showing create form');
this.program = null;
this.isNewProgram = true;
} else {
throw error;
}
}
},
populateForm(program) {
this.form.loyalty_type = program.loyalty_type || 'points';
this.form.stamps_target = program.stamps_target || 10;
this.form.stamps_reward_description = program.stamps_reward_description || 'Free item';
this.form.stamps_reward_value_cents = program.stamps_reward_value_cents || null;
this.form.points_per_euro = program.points_per_euro || 10;
this.form.welcome_bonus_points = program.welcome_bonus_points || 0;
this.form.minimum_redemption_points = program.minimum_redemption_points || 100;
this.form.minimum_purchase_cents = program.minimum_purchase_cents || 0;
this.form.points_expiration_days = program.points_expiration_days || null;
this.form.points_rewards = (program.points_rewards || []).map(r => ({
id: r.id,
name: r.name,
points_required: r.points_required,
description: r.description || '',
is_active: r.is_active !== false,
}));
this.form.cooldown_minutes = program.cooldown_minutes ?? 15;
this.form.max_daily_stamps = program.max_daily_stamps || 5;
this.form.require_staff_pin = program.require_staff_pin !== false;
this.form.card_name = program.card_name || '';
this.form.card_color = program.card_color || '#4F46E5';
this.form.logo_url = program.logo_url || '';
this.form.terms_text = program.terms_text || '';
},
addReward() {
const id = 'reward_' + Date.now();
this.form.points_rewards.push({
id: id,
name: '',
points_required: 100,
description: '',
is_active: true,
});
},
async saveProgram() {
async saveSettings() {
this.saving = true;
try {
const payload = { ...this.form };
// Clean up empty optional fields
if (!payload.stamps_reward_value_cents) payload.stamps_reward_value_cents = null;
if (!payload.points_expiration_days) payload.points_expiration_days = null;
if (!payload.card_name) payload.card_name = null;
if (!payload.logo_url) payload.logo_url = null;
if (!payload.terms_text) payload.terms_text = null;
const payload = this.buildPayload();
let response;
if (this.program) {
// Update existing
response = await apiClient.put('/store/loyalty/program', payload);
Utils.showToast('Program updated successfully', 'success');
} else {
// Create new
if (this.isNewProgram) {
response = await apiClient.post('/store/loyalty/program', payload);
Utils.showToast('Program created successfully', 'success');
} else {
response = await apiClient.put('/store/loyalty/program', payload);
Utils.showToast('Program updated successfully', 'success');
}
this.program = response;
this.populateForm(response);
this.populateSettings(response);
this.isNewProgram = false;
loyaltySettingsLog.info('Program saved:', response.display_name);
} catch (error) {
@@ -171,6 +103,25 @@ function loyaltySettings() {
this.saving = false;
}
},
async deleteProgram() {
this.deleting = true;
try {
await apiClient.delete('/store/loyalty/program');
Utils.showToast('Loyalty program deleted', 'success');
loyaltySettingsLog.info('Program deleted');
// Redirect to terminal page
const storeCode = window.location.pathname.split('/')[2];
window.location.href = `/store/${storeCode}/loyalty/program`;
} catch (error) {
Utils.showToast(`Failed to delete: ${error.message}`, 'error');
loyaltySettingsLog.error('Delete failed:', error);
} finally {
this.deleting = false;
this.showDeleteModal = false;
}
},
};
}

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