feat: add SQL query presets, shared program form, and loyalty API/admin improvements
- 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:
@@ -84,6 +84,78 @@ function sqlQueryTool() {
|
||||
{ name: 'Alembic versions', sql: "SELECT * FROM alembic_version\nORDER BY version_num;" },
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'Loyalty',
|
||||
items: [
|
||||
{ name: 'Programs', sql: "SELECT lp.id, lp.merchant_id, lp.loyalty_type,\n lp.stamps_target, lp.points_per_euro, lp.is_active,\n lp.activated_at, lp.created_at\nFROM loyalty_programs lp\nORDER BY lp.id DESC;" },
|
||||
{ name: 'Cards', sql: "SELECT lc.id, lc.card_number, c.email AS customer_email,\n lc.stamp_count, lc.points_balance, lc.is_active,\n lc.last_activity_at, lc.created_at\nFROM loyalty_cards lc\nJOIN customers c ON c.id = lc.customer_id\nORDER BY lc.id DESC\nLIMIT 100;" },
|
||||
{ name: 'Transactions', sql: "SELECT lt.id, lt.transaction_type, lc.card_number,\n lt.stamps_delta, lt.points_delta,\n lt.purchase_amount_cents, lt.transaction_at\nFROM loyalty_transactions lt\nJOIN loyalty_cards lc ON lc.id = lt.card_id\nORDER BY lt.transaction_at DESC\nLIMIT 100;" },
|
||||
{ name: 'Staff PINs', sql: "SELECT sp.id, sp.name, sp.staff_id,\n s.name AS store_name, sp.is_active,\n sp.last_used_at, sp.created_at\nFROM staff_pins sp\nJOIN stores s ON s.id = sp.store_id\nORDER BY sp.id DESC;" },
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'Billing',
|
||||
items: [
|
||||
{ name: 'Subscription tiers', sql: "SELECT st.id, p.code AS platform_code,\n st.code, st.name, st.price_monthly_cents,\n st.price_annual_cents, st.is_active, st.is_public\nFROM subscription_tiers st\nJOIN platforms p ON p.id = st.platform_id\nORDER BY p.code, st.display_order;" },
|
||||
{ name: 'Merchant subscriptions', sql: "SELECT ms.id, m.name AS merchant_name,\n p.code AS platform_code, st.name AS tier_name,\n ms.status, ms.is_annual,\n ms.stripe_customer_id, ms.stripe_subscription_id\nFROM merchant_subscriptions ms\nJOIN merchants m ON m.id = ms.merchant_id\nJOIN platforms p ON p.id = ms.platform_id\nLEFT JOIN subscription_tiers st ON st.id = ms.tier_id\nORDER BY ms.id DESC;" },
|
||||
{ name: 'Billing history', sql: "SELECT bh.id, s.name AS store_name,\n bh.invoice_number, bh.total_cents,\n bh.currency, bh.status, bh.invoice_date\nFROM billing_history bh\nJOIN stores s ON s.id = bh.store_id\nORDER BY bh.invoice_date DESC\nLIMIT 50;" },
|
||||
{ name: 'Add-on products', sql: "SELECT id, code, name, category,\n price_cents, billing_period, is_active\nFROM addon_products\nORDER BY display_order;" },
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'Orders',
|
||||
items: [
|
||||
{ name: 'Recent orders', sql: "SELECT o.id, s.name AS store_name,\n o.order_number, o.status, o.total_amount_cents,\n o.customer_email, o.order_date\nFROM orders o\nJOIN stores s ON s.id = o.store_id\nORDER BY o.order_date DESC\nLIMIT 50;" },
|
||||
{ name: 'Order items', sql: "SELECT oi.id, o.order_number, oi.product_name,\n oi.product_sku, oi.quantity, oi.unit_price_cents,\n oi.item_state\nFROM order_items oi\nJOIN orders o ON o.id = oi.order_id\nORDER BY oi.id DESC\nLIMIT 100;" },
|
||||
{ name: 'Invoices', sql: "SELECT i.id, s.name AS store_name,\n i.invoice_number, i.status,\n i.total_cents, i.invoice_date\nFROM invoices i\nJOIN stores s ON s.id = i.store_id\nORDER BY i.invoice_date DESC\nLIMIT 50;" },
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'Catalog',
|
||||
items: [
|
||||
{ name: 'Products', sql: "SELECT p.id, s.name AS store_name,\n p.store_sku, p.gtin, p.price_cents,\n p.availability, p.is_active\nFROM products p\nJOIN stores s ON s.id = p.store_id\nORDER BY p.id DESC\nLIMIT 50;" },
|
||||
{ name: 'Product translations', sql: "SELECT pt.id, p.store_sku,\n pt.language, pt.title\nFROM product_translations pt\nJOIN products p ON p.id = pt.product_id\nORDER BY pt.id DESC\nLIMIT 50;" },
|
||||
{ name: 'Product media', sql: "SELECT pm.id, p.store_sku,\n pm.usage_type, pm.display_order\nFROM product_media pm\nJOIN products p ON p.id = pm.product_id\nORDER BY pm.id DESC\nLIMIT 50;" },
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'Customers',
|
||||
items: [
|
||||
{ name: 'Customers', sql: "SELECT c.id, s.name AS store_name,\n c.email, c.first_name, c.last_name,\n c.customer_number, c.is_active\nFROM customers c\nJOIN stores s ON s.id = c.store_id\nORDER BY c.id DESC\nLIMIT 50;" },
|
||||
{ name: 'Customer addresses', sql: "SELECT ca.id, c.email AS customer_email,\n ca.address_type, ca.city, ca.country_iso,\n ca.is_default\nFROM customer_addresses ca\nJOIN customers c ON c.id = ca.customer_id\nORDER BY ca.id DESC\nLIMIT 50;" },
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'Inventory',
|
||||
items: [
|
||||
{ name: 'Stock levels', sql: "SELECT inv.id, s.name AS store_name,\n p.store_sku, inv.quantity,\n inv.reserved_quantity, inv.warehouse\nFROM inventory inv\nJOIN products p ON p.id = inv.product_id\nJOIN stores s ON s.id = inv.store_id\nORDER BY inv.id DESC;" },
|
||||
{ name: 'Recent transactions', sql: "SELECT it.id, p.store_sku,\n it.transaction_type, it.quantity_change,\n it.order_number, it.created_at\nFROM inventory_transactions it\nJOIN products p ON p.id = it.product_id\nORDER BY it.created_at DESC\nLIMIT 100;" },
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'CMS',
|
||||
items: [
|
||||
{ name: 'Content pages', sql: "SELECT cp.id, cp.slug, cp.title,\n cp.is_published, cp.is_platform_page,\n s.name AS store_name, p.code AS platform_code\nFROM content_pages cp\nLEFT JOIN stores s ON s.id = cp.store_id\nLEFT JOIN platforms p ON p.id = cp.platform_id\nORDER BY cp.id DESC\nLIMIT 50;" },
|
||||
{ name: 'Media files', sql: "SELECT mf.id, s.name AS store_name,\n mf.filename, mf.media_type,\n mf.file_size, mf.usage_count\nFROM media_files mf\nJOIN stores s ON s.id = mf.store_id\nORDER BY mf.id DESC\nLIMIT 50;" },
|
||||
{ name: 'Store themes', sql: "SELECT st.id, s.name AS store_name,\n st.theme_name, st.is_active, st.layout_style\nFROM store_themes st\nJOIN stores s ON s.id = st.store_id\nORDER BY st.id DESC;" },
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'Messaging',
|
||||
items: [
|
||||
{ name: 'Email templates', sql: "SELECT id, code, language, name,\n category, is_active\nFROM email_templates\nORDER BY category, code, language;" },
|
||||
{ name: 'Email logs', sql: "SELECT id, recipient_email, subject,\n status, sent_at, provider\nFROM email_logs\nORDER BY sent_at DESC\nLIMIT 50;" },
|
||||
{ name: 'Admin notifications', sql: "SELECT id, type, priority, title,\n is_read, action_required, created_at\nFROM admin_notifications\nORDER BY created_at DESC\nLIMIT 50;" },
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'Marketplace',
|
||||
items: [
|
||||
{ name: 'Import jobs', sql: "SELECT mij.id, s.name AS store_name,\n mij.marketplace, mij.status,\n mij.imported_count, mij.error_count,\n mij.created_at\nFROM marketplace_import_jobs mij\nJOIN stores s ON s.id = mij.store_id\nORDER BY mij.created_at DESC\nLIMIT 50;" },
|
||||
{ name: 'Marketplace products', sql: "SELECT id, marketplace, brand, gtin,\n price_cents, availability, is_active\nFROM marketplace_products\nORDER BY id DESC\nLIMIT 50;" },
|
||||
{ name: 'Fulfillment queue', sql: "SELECT fq.id, s.name AS store_name,\n fq.operation, fq.status, fq.attempts,\n fq.error_message, fq.created_at\nFROM letzshop_fulfillment_queue fq\nJOIN stores s ON s.id = fq.store_id\nORDER BY fq.created_at DESC\nLIMIT 50;" },
|
||||
]
|
||||
},
|
||||
],
|
||||
|
||||
toggleCategory(category) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
127
app/modules/loyalty/static/shared/js/loyalty-program-form.js
Normal file
127
app/modules/loyalty/static/shared/js/loyalty-program-form.js
Normal 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 %}
|
||||
|
||||
310
app/modules/loyalty/templates/loyalty/shared/program-form.html
Normal file
310
app/modules/loyalty/templates/loyalty/shared/program-form.html
Normal 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>
|
||||
Reference in New Issue
Block a user