Files
orion/app/modules/loyalty/templates/loyalty/shared/program-form.html
Samir Boulahtit 29d942322d
Some checks failed
CI / ruff (push) Successful in 10s
CI / pytest (push) Failing after 49m23s
CI / validate (push) Successful in 26s
CI / dependency-scanning (push) Successful in 30s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped
feat(loyalty): make logo URL mandatory on program edit forms
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>
2026-03-11 20:08:38 +01:00

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>