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

View File

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

View File

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

View File

@@ -3,7 +3,9 @@
Loyalty Merchant Page Routes (HTML rendering). Loyalty Merchant Page Routes (HTML rendering).
Merchant portal pages for: 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. 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) @router.get("/program", response_class=HTMLResponse, include_in_schema=False)
async def merchant_loyalty_overview( async def merchant_loyalty_program(
request: Request, request: Request,
current_user: UserContext = Depends(get_current_merchant_from_cookie_or_header), current_user: UserContext = Depends(get_current_merchant_from_cookie_or_header),
merchant: Merchant = Depends(get_merchant_for_current_user_page), merchant: Merchant = Depends(get_merchant_for_current_user_page),
db: Session = Depends(get_db), 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 merchant_id = merchant.id
stats = {} stats = {}
try: try:
@@ -95,25 +96,25 @@ async def merchant_loyalty_overview(
merchant_id=merchant_id, merchant_id=merchant_id,
) )
return templates.TemplateResponse( return templates.TemplateResponse(
"loyalty/merchant/overview.html", "loyalty/merchant/program.html",
context, context,
) )
# ============================================================================ # ============================================================================
# LOYALTY SETTINGS # LOYALTY PROGRAM EDIT
# ============================================================================ # ============================================================================
@router.get("/settings", response_class=HTMLResponse, include_in_schema=False) @router.get("/program/edit", response_class=HTMLResponse, include_in_schema=False)
async def merchant_loyalty_settings( async def merchant_loyalty_program_edit(
request: Request, request: Request,
current_user: UserContext = Depends(get_current_merchant_from_cookie_or_header), current_user: UserContext = Depends(get_current_merchant_from_cookie_or_header),
merchant: Merchant = Depends(get_merchant_for_current_user_page), merchant: Merchant = Depends(get_merchant_for_current_user_page),
db: Session = Depends(get_db), 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. Allows merchant to create, configure, and manage their loyalty program.
""" """
@@ -124,6 +125,46 @@ async def merchant_loyalty_settings(
merchant_id=merchant.id, merchant_id=merchant.id,
) )
return templates.TemplateResponse( 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, context,
) )

View File

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

View File

@@ -826,6 +826,20 @@ class ProgramService:
"minimum_redemption_points": program.minimum_redemption_points, "minimum_redemption_points": program.minimum_redemption_points,
"points_expiration_days": program.points_expiration_days, "points_expiration_days": program.points_expiration_days,
"is_active": program.is_active, "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) 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() { function merchantLoyaltySettings() {
return { return {
...data(), ...data(),
currentPage: 'loyalty-settings', ...createProgramFormMixin(),
currentPage: 'loyalty-program',
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
},
loading: false, loading: false,
saving: false,
deleting: false,
error: null, error: null,
isNewProgram: false,
showDeleteModal: false,
async init() { async init() {
loyaltySettingsLog.info('=== MERCHANT LOYALTY SETTINGS PAGE INITIALIZING ==='); loyaltySettingsLog.info('=== MERCHANT LOYALTY SETTINGS PAGE INITIALIZING ===');
@@ -46,17 +31,7 @@ function merchantLoyaltySettings() {
try { try {
const response = await apiClient.get('/merchants/loyalty/program'); const response = await apiClient.get('/merchants/loyalty/program');
if (response) { if (response) {
this.settings = { this.populateSettings(response);
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.isNewProgram = false; this.isNewProgram = false;
loyaltySettingsLog.info('Settings loaded'); loyaltySettingsLog.info('Settings loaded');
} }
@@ -76,19 +51,13 @@ function merchantLoyaltySettings() {
this.saving = true; this.saving = true;
try { try {
// Ensure rewards have IDs const payload = this.buildPayload();
this.settings.points_rewards = this.settings.points_rewards.map((r, i) => ({
...r,
id: r.id || `reward_${i + 1}`,
is_active: r.is_active !== false
}));
let response;
if (this.isNewProgram) { if (this.isNewProgram) {
response = await apiClient.post('/merchants/loyalty/program', this.settings); await apiClient.post('/merchants/loyalty/program', payload);
this.isNewProgram = false; this.isNewProgram = false;
} else { } else {
response = await apiClient.patch('/merchants/loyalty/program', this.settings); await apiClient.patch('/merchants/loyalty/program', payload);
} }
Utils.showToast('Settings saved successfully', 'success'); Utils.showToast('Settings saved successfully', 'success');
@@ -101,10 +70,6 @@ function merchantLoyaltySettings() {
} }
}, },
confirmDelete() {
this.showDeleteModal = true;
},
async deleteProgram() { async deleteProgram() {
this.deleting = true; this.deleting = true;
@@ -112,8 +77,7 @@ function merchantLoyaltySettings() {
await apiClient.delete('/merchants/loyalty/program'); await apiClient.delete('/merchants/loyalty/program');
Utils.showToast('Loyalty program deleted', 'success'); Utils.showToast('Loyalty program deleted', 'success');
loyaltySettingsLog.info('Program deleted'); loyaltySettingsLog.info('Program deleted');
// Redirect to overview window.location.href = '/merchants/loyalty/program';
window.location.href = '/merchants/loyalty/overview';
} catch (error) { } catch (error) {
Utils.showToast(`Failed to delete: ${error.message}`, 'error'); Utils.showToast(`Failed to delete: ${error.message}`, 'error');
loyaltySettingsLog.error('Delete failed:', error); loyaltySettingsLog.error('Delete failed:', error);
@@ -122,20 +86,6 @@ function merchantLoyaltySettings() {
this.showDeleteModal = false; 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 // 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 { return {
...data(), ...data(),
currentPage: 'loyalty-stats', currentPage: 'loyalty-analytics',
program: null, program: null,
@@ -27,9 +27,9 @@ function storeLoyaltyStats() {
error: null, error: null,
async init() { async init() {
loyaltyStatsLog.info('=== LOYALTY STATS PAGE INITIALIZING ==='); loyaltyAnalyticsLog.info('=== LOYALTY ANALYTICS PAGE INITIALIZING ===');
if (window._loyaltyStatsInitialized) return; if (window._loyaltyAnalyticsInitialized) return;
window._loyaltyStatsInitialized = true; window._loyaltyAnalyticsInitialized = true;
// IMPORTANT: Call parent init first to set storeCode from URL // IMPORTANT: Call parent init first to set storeCode from URL
const parentInit = data().init; const parentInit = data().init;
@@ -41,7 +41,7 @@ function storeLoyaltyStats() {
if (this.program) { if (this.program) {
await this.loadStats(); await this.loadStats();
} }
loyaltyStatsLog.info('=== LOYALTY STATS PAGE INITIALIZATION COMPLETE ==='); loyaltyAnalyticsLog.info('=== LOYALTY ANALYTICS PAGE INITIALIZATION COMPLETE ===');
}, },
async loadProgram() { async loadProgram() {
@@ -72,10 +72,10 @@ function storeLoyaltyStats() {
transactions_30d: response.transactions_30d || 0, transactions_30d: response.transactions_30d || 0,
avg_points_per_member: response.avg_points_per_member || 0 avg_points_per_member: response.avg_points_per_member || 0
}; };
loyaltyStatsLog.info('Stats loaded'); loyaltyAnalyticsLog.info('Stats loaded');
} }
} catch (error) { } catch (error) {
loyaltyStatsLog.error('Failed to load stats:', error); loyaltyAnalyticsLog.error('Failed to load stats:', error);
this.error = error.message; this.error = error.message;
} finally { } finally {
this.loading = false; this.loading = false;
@@ -88,7 +88,7 @@ function storeLoyaltyStats() {
}; };
} }
if (!window.LogConfig.loggers.loyaltyStats) { if (!window.LogConfig.loggers.loyaltyAnalytics) {
window.LogConfig.loggers.loyaltyStats = window.LogConfig.createLogger('loyaltyStats'); 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 { return {
// Inherit base layout functionality // Inherit base layout functionality
...data(), ...data(),
...createProgramFormMixin(),
// Page identifier // Page identifier
currentPage: 'loyalty-settings', currentPage: 'loyalty-program',
// State // State
program: null,
loading: false, loading: false,
saving: false,
error: null, error: null,
isOwner: false, 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 // Initialize
async init() { async init() {
loyaltySettingsLog.info('=== LOYALTY SETTINGS INITIALIZING ==='); loyaltySettingsLog.info('=== LOYALTY SETTINGS INITIALIZING ===');
@@ -85,83 +63,37 @@ function loyaltySettings() {
const response = await apiClient.get('/store/loyalty/program'); const response = await apiClient.get('/store/loyalty/program');
if (response) { if (response) {
this.program = response; this.populateSettings(response);
this.populateForm(response); this.isNewProgram = false;
loyaltySettingsLog.info('Program loaded:', response.display_name); loyaltySettingsLog.info('Program loaded:', response.display_name);
} }
} catch (error) { } catch (error) {
if (error.status === 404) { if (error.status === 404) {
loyaltySettingsLog.info('No program configured — showing create form'); loyaltySettingsLog.info('No program configured — showing create form');
this.program = null; this.isNewProgram = true;
} else { } else {
throw error; throw error;
} }
} }
}, },
populateForm(program) { async saveSettings() {
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() {
this.saving = true; this.saving = true;
try { try {
const payload = { ...this.form }; const payload = this.buildPayload();
// 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;
let response; let response;
if (this.program) { if (this.isNewProgram) {
// Update existing
response = await apiClient.put('/store/loyalty/program', payload);
Utils.showToast('Program updated successfully', 'success');
} else {
// Create new
response = await apiClient.post('/store/loyalty/program', payload); response = await apiClient.post('/store/loyalty/program', payload);
Utils.showToast('Program created successfully', 'success'); 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.populateSettings(response);
this.populateForm(response); this.isNewProgram = false;
loyaltySettingsLog.info('Program saved:', response.display_name); loyaltySettingsLog.info('Program saved:', response.display_name);
} catch (error) { } catch (error) {
@@ -171,6 +103,25 @@ function loyaltySettings() {
this.saving = false; 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> </div>
<!-- Program Configuration --> <!-- Program Configuration -->
<div x-show="program" class="px-4 py-3 mb-8 bg-white rounded-lg shadow-md dark:bg-gray-800"> {% set show_edit_button = false %}
<div class="flex items-center justify-between mb-4"> {% include "loyalty/shared/program-view.html" %}
<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>
<!-- No Program Notice --> <!-- No Program Notice -->
<div x-show="!program" class="px-4 py-3 mb-8 bg-yellow-50 border border-yellow-200 rounded-lg dark:bg-yellow-900/20 dark:border-yellow-800"> <div 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" %} {% extends "merchant/base.html" %}
{% block title %}Loyalty Overview{% endblock %} {% block title %}Loyalty Analytics{% endblock %}
{% block content %} {% block content %}
<div x-data="merchantLoyaltyOverview()"> <div x-data="merchantLoyaltyAnalytics()">
<!-- Page Header --> <!-- Page Header -->
<div class="mb-8 mt-6 flex items-center justify-between"> <div class="mb-8 mt-6 flex items-center justify-between">
<div> <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> <p class="mt-1 text-gray-500 dark:text-gray-400">Loyalty program statistics across all your stores.</p>
</div> </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> </div>
<!-- No Program State --> <!-- No Program State -->
@@ -27,9 +20,9 @@
<span x-html="$icon('gift', 'w-12 h-12 mx-auto text-gray-400')"></span> <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> <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"> <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> </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"> 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> <span x-html="$icon('plus', 'w-4 h-4 mr-2')"></span>
Create Program Create Program
@@ -120,10 +113,16 @@
{% block extra_scripts %} {% block extra_scripts %}
<script> <script>
function merchantLoyaltyOverview() { function merchantLoyaltyAnalytics() {
return { return {
...data(),
currentPage: 'loyalty-analytics',
loading: false, loading: false,
stats: {{ loyalty_stats | tojson }}, stats: {{ loyalty_stats | tojson }},
init() {
this.loadMenuConfig();
},
}; };
} }
</script> </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" %} {% extends "store/base.html" %}
{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %} {% from 'shared/macros/headers.html' import page_header_flex, refresh_button %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %} {% 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 %} {% 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"> <div class="flex items-center gap-3">
{{ refresh_button(loading_var='loading', onclick='loadStats()', variant='secondary') }} {{ refresh_button(loading_var='loading', onclick='loadStats()', variant='secondary') }}
</div> </div>
{% endcall %} {% endcall %}
{{ loading_state('Loading statistics...') }} {{ loading_state('Loading analytics...') }}
{{ error_state('Error loading statistics') }} {{ error_state('Error loading analytics') }}
<!-- No Program Setup Notice --> <!-- No Program Setup Notice -->
<div x-show="!loading && !program" class="mb-6 px-4 py-5 bg-yellow-50 border border-yellow-200 rounded-lg dark:bg-yellow-900/20 dark:border-yellow-800"> <div 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> <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> <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' %} {% 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"> 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> <span x-html="$icon('cog', 'w-4 h-4 mr-2')"></span>
Set Up Loyalty Program Set Up Loyalty Program
@@ -139,10 +139,10 @@
<span x-html="$icon('users', 'w-4 h-4 mr-2')"></span> <span x-html="$icon('users', 'w-4 h-4 mr-2')"></span>
View Members View Members
</a> </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"> 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> <span x-html="$icon('cog', 'w-4 h-4 mr-2')"></span>
Settings Program
</a> </a>
</div> </div>
</div> </div>
@@ -150,5 +150,5 @@
{% endblock %} {% endblock %}
{% block extra_scripts %} {% 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 %} {% 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> <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> <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' %} {% 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"> 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> <span x-html="$icon('cog', 'w-4 h-4 mr-2')"></span>
Set Up Loyalty Program 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" %} {% extends "store/base.html" %}
{% from 'shared/macros/headers.html' import page_header_flex %} {% from 'shared/macros/headers.html' import page_header_flex %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %} {% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% from 'shared/macros/modals.html' import confirm_modal %}
{% block title %}Loyalty Settings{% endblock %} {% block title %}Loyalty Settings{% endblock %}
@@ -9,12 +10,12 @@
{% block content %} {% block content %}
<!-- Page Header --> <!-- 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"> <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"> 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> <span x-html="$icon('arrow-left', 'w-4 h-4 mr-2')"></span>
Terminal Back to Program
</a> </a>
</div> </div>
{% endcall %} {% endcall %}
@@ -35,212 +36,29 @@
</div> </div>
<!-- Settings Form --> <!-- Settings Form -->
<div x-show="!loading && isOwner" class="space-y-6"> <div x-show="!loading && isOwner">
<form @submit.prevent="saveSettings">
<!-- Program Type (create only) --> {% set show_delete = true %}
<div x-show="!program" class="bg-white rounded-lg shadow-md dark:bg-gray-800"> {% set show_status = true %}
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700"> {% set cancel_url = '/store/' ~ store_code ~ '/loyalty/program' %}
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200"> {% include "loyalty/shared/program-form.html" %}
<span x-html="$icon('layers', 'inline w-5 h-5 mr-2')"></span> </form>
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> </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 %} {% endblock %}
{% block extra_scripts %} {% 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> <script defer src="{{ url_for('loyalty_static', path='store/js/loyalty-settings.js') }}"></script>
{% endblock %} {% endblock %}

View File

@@ -17,10 +17,10 @@
<span x-html="$icon('users', 'w-4 h-4 mr-2')"></span> <span x-html="$icon('users', 'w-4 h-4 mr-2')"></span>
Members Members
</a> </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"> 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> <span x-html="$icon('chart-bar', 'w-4 h-4 mr-2')"></span>
Stats Analytics
</a> </a>
</div> </div>
{% endcall %} {% endcall %}
@@ -37,7 +37,7 @@
<h3 class="text-sm font-semibold text-yellow-800 dark:text-yellow-200">Loyalty Program Not Set Up</h3> <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> <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' %} {% 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"> 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> <span x-html="$icon('cog', 'w-4 h-4 mr-2')"></span>
Set Up Loyalty Program Set Up Loyalty Program