feat(loyalty): align program view, edit, and analytics pages across all frontends
Some checks failed
Some checks failed
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:
@@ -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,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -88,6 +88,7 @@
|
||||
"terminal": "Terminal",
|
||||
"customer_cards": "Customer Cards",
|
||||
"statistics": "Statistics",
|
||||
"program": "Program",
|
||||
"overview": "Overview",
|
||||
"settings": "Settings"
|
||||
},
|
||||
|
||||
@@ -78,6 +78,7 @@
|
||||
"terminal": "Terminal",
|
||||
"customer_cards": "Cartes clients",
|
||||
"statistics": "Statistiques",
|
||||
"program": "Programme",
|
||||
"overview": "Aperçu",
|
||||
"settings": "Paramètres"
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
# ============================================================================
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
67
app/modules/loyalty/templates/loyalty/merchant/program.html
Normal file
67
app/modules/loyalty/templates/loyalty/merchant/program.html
Normal 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 %}
|
||||
@@ -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 %}
|
||||
228
app/modules/loyalty/templates/loyalty/shared/program-view.html
Normal file
228
app/modules/loyalty/templates/loyalty/shared/program-view.html
Normal 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>
|
||||
@@ -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 %}
|
||||
@@ -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
|
||||
|
||||
100
app/modules/loyalty/templates/loyalty/store/program.html
Normal file
100
app/modules/loyalty/templates/loyalty/store/program.html
Normal 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 %}
|
||||
@@ -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 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>
|
||||
|
||||
<!-- 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>
|
||||
<!-- 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 %}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user