Some checks failed
Logo URL is required by Google Wallet API for LoyaltyClass creation. Added validation across all three program edit screens (admin, merchant, store) with a helpful hint explaining the requirement. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
312 lines
19 KiB
HTML
312 lines
19 KiB
HTML
{# 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 <span class="text-red-500">*</span></label>
|
|
<input type="url" x-model="settings.logo_url" maxlength="500" placeholder="https://..." required
|
|
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 dark:text-gray-400">Required for Google Wallet integration. Must be a publicly accessible image URL (PNG or JPG).</p>
|
|
</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>
|