feat: add SQL query presets, shared program form, and loyalty API/admin improvements
Some checks failed
CI / ruff (push) Successful in 9s
CI / pytest (push) Failing after 48m35s
CI / validate (push) Successful in 25s
CI / dependency-scanning (push) Successful in 29s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped

- Add Loyalty and Billing SQL query presets to dev tools
- Extract shared program-form.html partial and loyalty-program-form.js mixin
- Refactor admin program-edit to use shared form partial
- Add store loyalty API endpoints for program management

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-10 22:53:19 +01:00
parent eee33d6a1b
commit 8cf5da6914
6 changed files with 535 additions and 267 deletions

View File

@@ -153,6 +153,22 @@ def update_program(
return response
@router.delete("/program", status_code=204)
def delete_program(
current_user: User = Depends(get_current_store_api),
db: Session = Depends(get_db),
):
"""Delete the merchant's loyalty program (merchant_owner only)."""
if current_user.role != "merchant_owner":
raise AuthorizationException("Only merchant owners can delete programs")
store_id = current_user.token_store_id
program = program_service.require_program_by_store(db, store_id)
program_service.delete_program(db, program.id)
logger.info(f"Store user deleted loyalty program {program.id}")
@router.get("/stats", response_model=ProgramStatsResponse)
def get_stats(
current_user: User = Depends(get_current_store_api),

View File

@@ -6,32 +6,15 @@ const loyaltyProgramEditLog = window.LogConfig.loggers.loyaltyProgramEdit || win
function adminLoyaltyProgramEdit() {
return {
...data(),
...createProgramFormMixin(),
currentPage: 'loyalty-programs',
merchantId: null,
merchant: null,
programId: null,
settings: {
loyalty_type: 'points',
points_per_euro: 1,
welcome_bonus_points: 0,
minimum_redemption_points: 100,
points_expiration_days: null,
points_rewards: [],
stamps_target: 10,
stamps_reward_description: '',
card_name: '',
card_color: '#4F46E5',
is_active: true
},
loading: false,
saving: false,
deleting: false,
error: null,
isNewProgram: false,
showDeleteModal: false,
get backUrl() {
return `/admin/loyalty/merchants/${this.merchantId}`;
@@ -88,39 +71,22 @@ function adminLoyaltyProgramEdit() {
async loadProgram() {
try {
// Get program via merchant stats endpoint (includes program data)
const response = await apiClient.get(`/admin/loyalty/merchants/${this.merchantId}/stats`);
if (response && response.program) {
const program = response.program;
this.programId = program.id;
this.isNewProgram = false;
this.settings = {
loyalty_type: program.loyalty_type || 'points',
points_per_euro: program.points_per_euro || 1,
welcome_bonus_points: program.welcome_bonus_points || 0,
minimum_redemption_points: program.minimum_redemption_points || 100,
points_expiration_days: program.points_expiration_days || null,
points_rewards: program.points_rewards || [],
stamps_target: program.stamps_target || 10,
stamps_reward_description: program.stamps_reward_description || '',
card_name: program.card_name || '',
card_color: program.card_color || '#4F46E5',
is_active: program.is_active !== false
};
this.populateSettings(program);
loyaltyProgramEditLog.info('Program loaded, ID:', this.programId);
} else {
this.isNewProgram = true;
// Set default card name from merchant
if (this.merchant) {
this.settings.card_name = this.merchant.name + ' Loyalty';
}
loyaltyProgramEditLog.info('No program found, create mode');
}
} catch (error) {
// If stats fail (e.g., no program), we're in create mode
this.isNewProgram = true;
if (this.merchant) {
this.settings.card_name = this.merchant.name + ' Loyalty';
@@ -133,17 +99,12 @@ function adminLoyaltyProgramEdit() {
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();
if (this.isNewProgram) {
const response = await apiClient.post(
`/admin/loyalty/merchants/${this.merchantId}/program`,
this.settings
payload
);
this.programId = response.id;
this.isNewProgram = false;
@@ -151,13 +112,12 @@ function adminLoyaltyProgramEdit() {
} else {
await apiClient.patch(
`/admin/loyalty/programs/${this.programId}`,
this.settings
payload
);
Utils.showToast('Program updated successfully', 'success');
}
loyaltyProgramEditLog.info('Program saved');
// Navigate back to merchant detail
window.location.href = this.backUrl;
} catch (error) {
Utils.showToast(`Failed to save: ${error.message}`, 'error');
@@ -167,10 +127,6 @@ function adminLoyaltyProgramEdit() {
}
},
confirmDelete() {
this.showDeleteModal = true;
},
async deleteProgram() {
if (!this.programId) return;
this.deleting = true;
@@ -188,20 +144,6 @@ function adminLoyaltyProgramEdit() {
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

@@ -0,0 +1,127 @@
// app/modules/loyalty/static/shared/js/loyalty-program-form.js
// Shared mixin for loyalty program settings forms (admin, merchant, store).
/**
* Factory that returns the shared Alpine.js properties and methods
* for the loyalty program settings form.
*
* Each page spreads this into its own component and provides:
* - init(), loadData(), saveSettings(), deleteProgram()
* with the correct API paths and navigation.
*/
function createProgramFormMixin() {
return {
// ---- state ----
settings: {
loyalty_type: 'points',
stamps_target: 10,
stamps_reward_description: '',
stamps_reward_value_cents: null,
points_per_euro: 1,
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',
card_secondary_color: '',
logo_url: '',
hero_image_url: '',
terms_text: '',
privacy_url: '',
is_active: true,
},
isNewProgram: false,
saving: false,
deleting: false,
showDeleteModal: false,
// ---- helpers ----
/**
* Populate settings from an API program response object.
*/
populateSettings(program) {
this.settings = {
loyalty_type: program.loyalty_type || 'points',
stamps_target: program.stamps_target || 10,
stamps_reward_description: program.stamps_reward_description || '',
stamps_reward_value_cents: program.stamps_reward_value_cents || null,
points_per_euro: program.points_per_euro || 1,
welcome_bonus_points: program.welcome_bonus_points || 0,
minimum_redemption_points: program.minimum_redemption_points || 100,
minimum_purchase_cents: program.minimum_purchase_cents || 0,
points_expiration_days: program.points_expiration_days || null,
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,
})),
cooldown_minutes: program.cooldown_minutes ?? 15,
max_daily_stamps: program.max_daily_stamps || 5,
require_staff_pin: program.require_staff_pin !== false,
card_name: program.card_name || '',
card_color: program.card_color || '#4F46E5',
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 || '',
is_active: program.is_active !== false,
};
},
/**
* Build a clean payload from settings for POST/PATCH/PUT.
* Ensures rewards have IDs and cleans empty optional fields.
*/
buildPayload() {
const payload = { ...this.settings };
// Ensure rewards have IDs
payload.points_rewards = (payload.points_rewards || []).map((r, i) => ({
...r,
id: r.id || `reward_${i + 1}`,
is_active: r.is_active !== false,
}));
// Clean 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.minimum_purchase_cents) payload.minimum_purchase_cents = null;
if (!payload.card_name) payload.card_name = null;
if (!payload.card_secondary_color) payload.card_secondary_color = null;
if (!payload.logo_url) payload.logo_url = null;
if (!payload.hero_image_url) payload.hero_image_url = null;
if (!payload.terms_text) payload.terms_text = null;
if (!payload.privacy_url) payload.privacy_url = null;
return payload;
},
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);
},
confirmDelete() {
this.showDeleteModal = true;
},
};
}

View File

@@ -3,7 +3,6 @@
{% from 'shared/macros/headers.html' import detail_page_header %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% from 'shared/macros/modals.html' import confirm_modal %}
{% from 'shared/macros/inputs.html' import number_stepper %}
{% block title %}Program Configuration{% endblock %}
@@ -19,209 +18,10 @@
<div x-show="!loading">
<form @submit.prevent="saveSettings">
<!-- Program Type (only shown on create) -->
<div x-show="isNewProgram" 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('squares-2x2', 'inline w-5 h-5 mr-2')"></span>
Program Type
</h3>
<div class="grid gap-4 md:grid-cols-3">
<label class="flex items-start p-4 border rounded-lg cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700"
:class="settings.loyalty_type === 'points' ? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20' : 'border-gray-200 dark:border-gray-600'">
<input type="radio" name="loyalty_type" value="points" x-model="settings.loyalty_type" class="mt-1 text-purple-600 form-radio">
<div class="ml-3">
<p class="font-medium text-gray-700 dark:text-gray-300">Points</p>
<p class="text-sm text-gray-500">Earn points per EUR spent</p>
</div>
</label>
<label class="flex items-start p-4 border rounded-lg cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700"
:class="settings.loyalty_type === 'stamps' ? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20' : 'border-gray-200 dark:border-gray-600'">
<input type="radio" name="loyalty_type" value="stamps" x-model="settings.loyalty_type" class="mt-1 text-purple-600 form-radio">
<div class="ml-3">
<p class="font-medium text-gray-700 dark:text-gray-300">Stamps</p>
<p class="text-sm text-gray-500">Collect N stamps, get reward</p>
</div>
</label>
<label class="flex items-start p-4 border rounded-lg cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700"
:class="settings.loyalty_type === 'hybrid' ? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20' : 'border-gray-200 dark:border-gray-600'">
<input type="radio" name="loyalty_type" value="hybrid" x-model="settings.loyalty_type" class="mt-1 text-purple-600 form-radio">
<div class="ml-3">
<p class="font-medium text-gray-700 dark:text-gray-300">Hybrid</p>
<p class="text-sm text-gray-500">Both stamps and points</p>
</div>
</label>
</div>
</div>
<!-- Points Configuration -->
<div x-show="settings.loyalty_type === 'points' || settings.loyalty_type === 'hybrid'" 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>
{{ number_stepper(model='settings.points_per_euro', min=1, max=100, label='Points per EUR spent') }}
<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>
<!-- Stamps Configuration -->
<div x-show="settings.loyalty_type === 'stamps' || settings.loyalty_type === 'hybrid'" 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('star', 'inline w-5 h-5 mr-2')"></span>
Stamps 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">Stamps Target</label>
<input type="number" min="2" max="50" x-model.number="settings.stamps_target"
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">Number of stamps needed for reward</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Reward Description</label>
<input type="text" x-model="settings.stamps_reward_description" 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>
</div>
<!-- Rewards Configuration (Points) -->
<div x-show="settings.loyalty_type === 'points' || settings.loyalty_type === 'hybrid'" 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</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>
<div class="flex items-center gap-3">
<a :href="backUrl"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-700">
Cancel
</a>
<button type="submit" :disabled="saving"
class="flex items-center px-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 Changes')"></span>
</button>
</div>
</div>
{% set show_delete = true %}
{% set show_status = true %}
{% set cancel_url = '/admin/loyalty/merchants/' ~ merchant_id %}
{% include "loyalty/shared/program-form.html" %}
</form>
</div>
@@ -239,5 +39,6 @@
{% 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='admin/js/loyalty-program-edit.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,310 @@
{# app/modules/loyalty/templates/loyalty/shared/program-form.html #}
{#
Canonical loyalty program form partial.
Include with:
{% include "loyalty/shared/program-form.html" %}
Expected Jinja2 variables (set before include):
- show_delete (bool) — show Delete Program button
- show_status (bool) — show is_active toggle
- cancel_url (str) — Cancel link href (Alpine expression or literal)
Expected Alpine.js state on the parent component:
- settings.* — full program settings object
- isNewProgram — boolean
- saving — boolean
- showDeleteModal — boolean
- addReward()
- removeReward(index)
- confirmDelete()
#}
<!-- Program Type (only shown on create) -->
<div x-show="isNewProgram" 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('squares-2x2', 'inline w-5 h-5 mr-2')"></span>
Program Type
</h3>
<div class="grid gap-4 md:grid-cols-3">
<label class="flex items-start p-4 border rounded-lg cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700"
:class="settings.loyalty_type === 'points' ? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20' : 'border-gray-200 dark:border-gray-600'">
<input type="radio" name="loyalty_type" value="points" x-model="settings.loyalty_type" class="mt-1 text-purple-600 form-radio">
<div class="ml-3">
<p class="font-medium text-gray-700 dark:text-gray-300">Points</p>
<p class="text-sm text-gray-500">Earn points per EUR spent</p>
</div>
</label>
<label class="flex items-start p-4 border rounded-lg cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700"
:class="settings.loyalty_type === 'stamps' ? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20' : 'border-gray-200 dark:border-gray-600'">
<input type="radio" name="loyalty_type" value="stamps" x-model="settings.loyalty_type" class="mt-1 text-purple-600 form-radio">
<div class="ml-3">
<p class="font-medium text-gray-700 dark:text-gray-300">Stamps</p>
<p class="text-sm text-gray-500">Collect N stamps, get reward</p>
</div>
</label>
<label class="flex items-start p-4 border rounded-lg cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700"
:class="settings.loyalty_type === 'hybrid' ? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20' : 'border-gray-200 dark:border-gray-600'">
<input type="radio" name="loyalty_type" value="hybrid" x-model="settings.loyalty_type" class="mt-1 text-purple-600 form-radio">
<div class="ml-3">
<p class="font-medium text-gray-700 dark:text-gray-300">Hybrid</p>
<p class="text-sm text-gray-500">Both stamps and points</p>
</div>
</label>
</div>
</div>
<!-- Stamps Configuration -->
<div x-show="settings.loyalty_type === 'stamps' || settings.loyalty_type === 'hybrid'" 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('star', 'inline w-5 h-5 mr-2')"></span>
Stamps 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">Stamps Target</label>
<input type="number" min="2" max="50" x-model.number="settings.stamps_target"
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">Number of stamps needed for reward</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Reward Description</label>
<input type="text" x-model="settings.stamps_reward_description" placeholder="e.g., Free coffee" maxlength="255"
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">Reward Value (cents)</label>
<input type="number" min="0" x-model.number="settings.stamps_reward_value_cents"
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="settings.loyalty_type === 'points' || settings.loyalty_type === 'hybrid'" 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">Minimum Purchase (cents)</label>
<input type="number" min="0" x-model.number="settings.minimum_purchase_cents"
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">Minimum purchase amount to earn points (0 = no minimum)</p>
</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>
<!-- Redemption Rewards (Points) -->
<div x-show="settings.loyalty_type === 'points' || settings.loyalty_type === 'hybrid'" 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>
<!-- Anti-Fraud Settings -->
<div class="px-4 py-5 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
<span x-html="$icon('shield-check', 'inline w-5 h-5 mr-2')"></span>
Anti-Fraud Settings
</h3>
<div class="grid gap-6 md:grid-cols-3">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Cooldown (minutes)</label>
<input type="number" min="0" max="1440" x-model.number="settings.cooldown_minutes"
class="w-full px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
<p class="mt-1 text-xs text-gray-500">Time between stamps from the same card</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Max Daily Stamps</label>
<input type="number" min="1" max="50" x-model.number="settings.max_daily_stamps"
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">Maximum stamps per card per day</p>
</div>
<div class="flex items-end">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" x-model="settings.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="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" maxlength="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-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}$" maxlength="7"
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-2">Secondary Color</label>
<div class="flex items-center gap-3">
<input type="color" x-model="settings.card_secondary_color"
class="w-12 h-10 rounded cursor-pointer">
<input type="text" x-model="settings.card_secondary_color" pattern="^#[0-9A-Fa-f]{6}$" maxlength="7"
placeholder="#FFFFFF"
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-2">Logo URL</label>
<input type="url" x-model="settings.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-2">Hero Image URL</label>
<input type="url" x-model="settings.hero_image_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>
</div>
<!-- Terms -->
<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('document-text', 'inline w-5 h-5 mr-2')"></span>
Terms & Privacy
</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">Terms & Conditions</label>
<textarea x-model="settings.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>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Privacy Policy URL</label>
<input type="url" x-model="settings.privacy_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>
</div>
<!-- Program Status -->
{% if show_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</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>
{% endif %}
<!-- Actions -->
<div class="flex items-center justify-between">
<div>
{% if show_delete %}
<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>
{% endif %}
</div>
<div class="flex items-center gap-3">
<a href="{{ cancel_url }}"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-700">
Cancel
</a>
<button type="submit" :disabled="saving"
class="flex items-center px-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 Changes')"></span>
</button>
</div>
</div>