feat: add SQL query tool, platform debug, loyalty settings, and multi-module improvements
Some checks failed
CI / ruff (push) Successful in 14s
CI / pytest (push) Failing after 50m12s
CI / validate (push) Successful in 25s
CI / dependency-scanning (push) Successful in 32s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped

- Add admin SQL query tool with saved queries, schema explorer presets,
  and collapsible category sections (dev_tools module)
- Add platform debug tool for admin diagnostics
- Add loyalty settings page with owner-only access control
- Fix loyalty settings owner check (use currentUser instead of window.__userData)
- Replace HTTPException with AuthorizationException in loyalty routes
- Expand loyalty module with PIN service, Apple Wallet, program management
- Improve store login with platform detection and multi-platform support
- Update billing feature gates and subscription services
- Add store platform sync improvements and remove is_primary column
- Add unit tests for loyalty (PIN, points, stamps, program services)
- Update i18n translations across dev_tools locales

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-10 20:08:07 +01:00
parent a77a8a3a98
commit 319900623a
77 changed files with 5341 additions and 401 deletions

View File

@@ -26,8 +26,28 @@
{{ error_state('Error loading members') }}
<!-- No Program Setup Notice -->
<div x-show="!loading && !program" class="mb-6 px-4 py-5 bg-yellow-50 border border-yellow-200 rounded-lg dark:bg-yellow-900/20 dark:border-yellow-800">
<div class="flex items-start">
<span x-html="$icon('exclamation-triangle', 'w-6 h-6 text-yellow-500 mr-3 flex-shrink-0')"></span>
<div>
<h3 class="text-sm font-semibold text-yellow-800 dark:text-yellow-200">Loyalty Program Not Set Up</h3>
<p class="mt-1 text-sm text-yellow-700 dark:text-yellow-300">Your merchant doesn't have a loyalty program configured yet.</p>
{% if user.role == 'merchant_owner' %}
<a href="/store/{{ store_code }}/loyalty/settings"
class="mt-3 inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-yellow-600 rounded-lg hover:bg-yellow-700">
<span x-html="$icon('cog', 'w-4 h-4 mr-2')"></span>
Set Up Loyalty Program
</a>
{% else %}
<p class="mt-2 text-sm text-yellow-700 dark:text-yellow-300">Contact your administrator to complete the setup.</p>
{% endif %}
</div>
</div>
</div>
<!-- Stats Cards -->
<div x-show="!loading" class="grid gap-6 mb-8 md:grid-cols-4">
<div x-show="!loading && program" class="grid gap-6 mb-8 md:grid-cols-4">
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-purple-500 bg-purple-100 rounded-full dark:text-purple-100 dark:bg-purple-500">
<span x-html="$icon('users', 'w-5 h-5')"></span>
@@ -67,7 +87,7 @@
</div>
<!-- Search and Filters -->
<div x-show="!loading" class="mb-6 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div x-show="!loading && program" class="mb-6 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="flex flex-wrap items-center gap-4">
<div class="flex-1 min-w-[200px]">
<div class="relative">
@@ -91,7 +111,7 @@
</div>
<!-- Cards Table -->
<div x-show="!loading">
<div x-show="!loading && program">
{% call table_wrapper() %}
{{ table_header(['Member', 'Card Number', 'Points Balance', 'Last Activity', 'Status', 'Actions']) }}
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">

View File

@@ -0,0 +1,246 @@
{# app/modules/loyalty/templates/loyalty/store/settings.html #}
{% extends "store/base.html" %}
{% from 'shared/macros/headers.html' import page_header_flex %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% block title %}Loyalty Settings{% endblock %}
{% block alpine_data %}loyaltySettings(){% endblock %}
{% block content %}
<!-- Page Header -->
{% call page_header_flex(title='Loyalty Settings', subtitle='Configure your loyalty program') %}
<div class="flex items-center gap-3">
<a href="/store/{{ store_code }}/loyalty/terminal"
class="flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-700">
<span x-html="$icon('terminal', 'w-4 h-4 mr-2')"></span>
Terminal
</a>
</div>
{% endcall %}
{{ loading_state('Loading settings...') }}
{{ error_state('Error loading settings') }}
<!-- Access Denied (non-owner) -->
<div x-show="!loading && !isOwner" class="mb-6 px-4 py-5 bg-yellow-50 border border-yellow-200 rounded-lg dark:bg-yellow-900/20 dark:border-yellow-800">
<div class="flex items-start">
<span x-html="$icon('shield', 'w-6 h-6 text-yellow-500 mr-3 flex-shrink-0')"></span>
<div>
<h3 class="text-sm font-semibold text-yellow-800 dark:text-yellow-200">Access Restricted</h3>
<p class="mt-1 text-sm text-yellow-700 dark:text-yellow-300">Only the merchant owner can manage loyalty program settings.</p>
</div>
</div>
</div>
<!-- Settings Form -->
<div x-show="!loading && isOwner" class="space-y-6">
<!-- Program Type (create only) -->
<div x-show="!program" class="bg-white rounded-lg shadow-md dark:bg-gray-800">
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">
<span x-html="$icon('layers', 'inline w-5 h-5 mr-2')"></span>
Program Type
</h3>
</div>
<div class="p-4">
<div class="grid gap-4 md:grid-cols-3">
<label class="relative flex items-start p-4 border-2 rounded-lg cursor-pointer transition-colors"
:class="form.loyalty_type === 'stamps' ? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20' : 'border-gray-200 dark:border-gray-700 hover:border-gray-300'">
<input type="radio" x-model="form.loyalty_type" value="stamps" class="sr-only">
<div>
<span x-html="$icon('stamp', 'w-8 h-8 text-purple-500 mb-2')"></span>
<p class="font-semibold text-gray-700 dark:text-gray-200">Stamps</p>
<p class="text-xs text-gray-500 dark:text-gray-400">Collect stamps for rewards</p>
</div>
</label>
<label class="relative flex items-start p-4 border-2 rounded-lg cursor-pointer transition-colors"
:class="form.loyalty_type === 'points' ? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20' : 'border-gray-200 dark:border-gray-700 hover:border-gray-300'">
<input type="radio" x-model="form.loyalty_type" value="points" class="sr-only">
<div>
<span x-html="$icon('coins', 'w-8 h-8 text-purple-500 mb-2')"></span>
<p class="font-semibold text-gray-700 dark:text-gray-200">Points</p>
<p class="text-xs text-gray-500 dark:text-gray-400">Earn points per purchase</p>
</div>
</label>
<label class="relative flex items-start p-4 border-2 rounded-lg cursor-pointer transition-colors"
:class="form.loyalty_type === 'hybrid' ? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20' : 'border-gray-200 dark:border-gray-700 hover:border-gray-300'">
<input type="radio" x-model="form.loyalty_type" value="hybrid" class="sr-only">
<div>
<span x-html="$icon('layers', 'w-8 h-8 text-purple-500 mb-2')"></span>
<p class="font-semibold text-gray-700 dark:text-gray-200">Hybrid</p>
<p class="text-xs text-gray-500 dark:text-gray-400">Both stamps and points</p>
</div>
</label>
</div>
</div>
</div>
<!-- Stamps Configuration -->
<div x-show="form.loyalty_type === 'stamps' || form.loyalty_type === 'hybrid'"
class="bg-white rounded-lg shadow-md dark:bg-gray-800">
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Stamps Configuration</h3>
</div>
<div class="p-4 grid gap-4 md:grid-cols-2">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Stamps for Reward</label>
<input type="number" x-model.number="form.stamps_target" min="1" max="50"
class="w-full px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Reward Description</label>
<input type="text" x-model="form.stamps_reward_description" maxlength="255"
placeholder="e.g., Free coffee"
class="w-full px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Reward Value (cents)</label>
<input type="number" x-model.number="form.stamps_reward_value_cents" min="0"
placeholder="e.g., 500 for 5 EUR"
class="w-full px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
</div>
</div>
</div>
<!-- Points Configuration -->
<div x-show="form.loyalty_type === 'points' || form.loyalty_type === 'hybrid'"
class="bg-white rounded-lg shadow-md dark:bg-gray-800">
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Points Configuration</h3>
</div>
<div class="p-4 grid gap-4 md:grid-cols-2">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Points per EUR</label>
<input type="number" x-model.number="form.points_per_euro" min="1" max="100"
class="w-full px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Welcome Bonus Points</label>
<input type="number" x-model.number="form.welcome_bonus_points" min="0"
class="w-full px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Min Redemption Points</label>
<input type="number" x-model.number="form.minimum_redemption_points" min="1"
class="w-full px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Min Purchase (cents)</label>
<input type="number" x-model.number="form.minimum_purchase_cents" min="0"
class="w-full px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Points Expiry (days)</label>
<input type="number" x-model.number="form.points_expiration_days" min="30"
placeholder="Leave empty for no expiry"
class="w-full px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
</div>
</div>
<!-- Rewards List -->
<div class="px-4 pb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Rewards</label>
<div class="space-y-2">
<template x-for="(reward, index) in form.points_rewards" :key="index">
<div class="flex items-center gap-2">
<input type="text" x-model="reward.name" placeholder="Reward name"
class="flex-1 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 dark:text-gray-300">
<input type="number" x-model.number="reward.points_required" placeholder="Points" min="1"
class="w-24 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 dark:text-gray-300">
<button @click="form.points_rewards.splice(index, 1)" type="button"
class="p-2 text-red-500 hover:text-red-700">
<span x-html="$icon('x', 'w-4 h-4')"></span>
</button>
</div>
</template>
</div>
<button @click="addReward()" type="button"
class="mt-2 text-sm text-purple-600 hover:text-purple-800 dark:text-purple-400">
+ Add Reward
</button>
</div>
</div>
<!-- Anti-Fraud -->
<div class="bg-white rounded-lg shadow-md dark:bg-gray-800">
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Anti-Fraud Settings</h3>
</div>
<div class="p-4 grid gap-4 md:grid-cols-3">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Cooldown (minutes)</label>
<input type="number" x-model.number="form.cooldown_minutes" min="0" max="1440"
class="w-full px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Max Daily Stamps</label>
<input type="number" x-model.number="form.max_daily_stamps" min="1" max="50"
class="w-full px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
</div>
<div class="flex items-end">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" x-model="form.require_staff_pin"
class="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Require Staff PIN</span>
</label>
</div>
</div>
</div>
<!-- Branding -->
<div class="bg-white rounded-lg shadow-md dark:bg-gray-800">
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Branding</h3>
</div>
<div class="p-4 grid gap-4 md:grid-cols-2">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Card Name</label>
<input type="text" x-model="form.card_name" maxlength="100"
placeholder="e.g., My Rewards Card"
class="w-full px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Card Color</label>
<div class="flex items-center gap-2">
<input type="color" x-model="form.card_color"
class="w-10 h-10 rounded border border-gray-300 dark:border-gray-600 cursor-pointer">
<input type="text" x-model="form.card_color" maxlength="7" placeholder="#4F46E5"
class="flex-1 px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 dark:text-gray-300">
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Logo URL</label>
<input type="url" x-model="form.logo_url" maxlength="500"
placeholder="https://..."
class="w-full px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Terms & Conditions</label>
<textarea x-model="form.terms_text" rows="3"
class="w-full px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"></textarea>
</div>
</div>
</div>
<!-- Actions -->
<div class="flex justify-end gap-3">
<a href="/store/{{ store_code }}/loyalty/terminal"
class="px-6 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600">
Cancel
</a>
<button @click="saveProgram()"
:disabled="saving"
class="px-6 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50">
<span x-show="saving" x-html="$icon('spinner', 'w-4 h-4 mr-2 inline animate-spin')"></span>
<span x-text="saving ? 'Saving...' : (program ? 'Save Changes' : 'Create Program')"></span>
</button>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script defer src="{{ url_for('loyalty_static', path='store/js/loyalty-settings.js') }}"></script>
{% endblock %}

View File

@@ -17,7 +17,27 @@
{{ loading_state('Loading statistics...') }}
{{ error_state('Error loading statistics') }}
<div x-show="!loading">
<!-- No Program Setup Notice -->
<div x-show="!loading && !program" class="mb-6 px-4 py-5 bg-yellow-50 border border-yellow-200 rounded-lg dark:bg-yellow-900/20 dark:border-yellow-800">
<div class="flex items-start">
<span x-html="$icon('exclamation-triangle', 'w-6 h-6 text-yellow-500 mr-3 flex-shrink-0')"></span>
<div>
<h3 class="text-sm font-semibold text-yellow-800 dark:text-yellow-200">Loyalty Program Not Set Up</h3>
<p class="mt-1 text-sm text-yellow-700 dark:text-yellow-300">Your merchant doesn't have a loyalty program configured yet.</p>
{% if user.role == 'merchant_owner' %}
<a href="/store/{{ store_code }}/loyalty/settings"
class="mt-3 inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-yellow-600 rounded-lg hover:bg-yellow-700">
<span x-html="$icon('cog', 'w-4 h-4 mr-2')"></span>
Set Up Loyalty Program
</a>
{% else %}
<p class="mt-2 text-sm text-yellow-700 dark:text-yellow-300">Contact your administrator to complete the setup.</p>
{% endif %}
</div>
</div>
</div>
<div x-show="!loading && program">
<!-- Summary Stats -->
<div class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-4">
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">

View File

@@ -36,11 +36,15 @@
<div>
<h3 class="text-sm font-semibold text-yellow-800 dark:text-yellow-200">Loyalty Program Not Set Up</h3>
<p class="mt-1 text-sm text-yellow-700 dark:text-yellow-300">Your merchant doesn't have a loyalty program configured yet.</p>
{% if user.role == 'merchant_owner' %}
<a href="/store/{{ store_code }}/loyalty/settings"
class="mt-3 inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-yellow-600 rounded-lg hover:bg-yellow-700">
<span x-html="$icon('cog', 'w-4 h-4 mr-2')"></span>
Set Up Loyalty Program
</a>
{% else %}
<p class="mt-2 text-sm text-yellow-700 dark:text-yellow-300">Contact your administrator to complete the setup.</p>
{% endif %}
</div>
</div>
</div>
@@ -125,70 +129,127 @@
</button>
</div>
<!-- Points Balance -->
<!-- Balance Area -->
<div class="mb-6 p-4 rounded-lg text-center"
:style="'background-color: ' + (program?.card_color || '#4F46E5') + '10'">
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Points Balance</p>
<p class="text-3xl font-bold"
:style="'color: ' + (program?.card_color || '#4F46E5')"
x-text="formatNumber(selectedCard?.points_balance || 0)"></p>
<!-- Points balance (for points and hybrid) -->
<template x-if="program?.is_points_enabled">
<div>
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Points Balance</p>
<p class="text-3xl font-bold"
:style="'color: ' + (program?.card_color || '#4F46E5')"
x-text="formatNumber(selectedCard?.points_balance || 0)"></p>
</div>
</template>
<!-- Stamps progress (for stamps and hybrid) -->
<template x-if="program?.is_stamps_enabled">
<div :class="program?.is_points_enabled ? 'mt-3 pt-3 border-t border-gray-200 dark:border-gray-700' : ''">
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Stamps</p>
<p class="text-3xl font-bold"
:style="'color: ' + (program?.card_color || '#4F46E5')"
x-text="(selectedCard?.stamp_count || 0) + ' / ' + (program?.stamps_target || 10)"></p>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1"
x-text="selectedCard?.stamps_until_reward > 0 ? (selectedCard.stamps_until_reward + ' more for reward') : 'Ready to redeem!'"></p>
</div>
</template>
</div>
<!-- Action Buttons -->
<!-- Action Panels -->
<div class="grid gap-4 md:grid-cols-2">
<!-- Earn Points -->
<div class="p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
<h4 class="font-medium text-gray-700 dark:text-gray-300 mb-3">
<span x-html="$icon('plus-circle', 'inline w-4 h-4 mr-1 text-green-500')"></span>
Earn Points
</h4>
<div class="mb-3">
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Purchase Amount</label>
<div class="relative">
<span class="absolute inset-y-0 left-0 flex items-center pl-3 text-gray-500">EUR</span>
<input type="number" step="0.01" min="0" {# noqa: FE-008 #}
x-model.number="earnAmount"
class="w-full pl-12 pr-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-green-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
</div>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
Points to award: <span class="font-semibold text-green-600" x-text="Math.floor((earnAmount || 0) * (program?.points_per_euro || 1))"></span>
</p>
<button @click="showPinModal('earn')"
:disabled="!earnAmount || earnAmount <= 0"
class="w-full px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700 disabled:opacity-50">
Award Points
</button>
</div>
<!-- Redeem Points -->
<div class="p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
<h4 class="font-medium text-gray-700 dark:text-gray-300 mb-3">
<span x-html="$icon('gift', 'inline w-4 h-4 mr-1 text-orange-500')"></span>
Redeem Reward
</h4>
<div class="mb-3">
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Select Reward</label>
<select x-model="selectedReward"
class="w-full px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-orange-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
<option value="">Select reward...</option>
<template x-for="reward in availableRewards" :key="reward.id">
<option :value="reward.id" :disabled="(selectedCard?.points_balance || 0) < reward.points_required"
x-text="reward.name + ' (' + reward.points_required + ' pts)'"></option>
</template>
</select>
</div>
<template x-if="selectedReward">
<!-- Stamp Panels (for stamps and hybrid) -->
<template x-if="program?.is_stamps_enabled">
<div class="p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
<h4 class="font-medium text-gray-700 dark:text-gray-300 mb-3">
<span x-html="$icon('plus-circle', 'inline w-4 h-4 mr-1 text-green-500')"></span>
Add Stamp
</h4>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
Points after: <span class="font-semibold text-orange-600" x-text="formatNumber((selectedCard?.points_balance || 0) - (getSelectedRewardPoints() || 0))"></span>
Current: <span class="font-semibold" x-text="(selectedCard?.stamp_count || 0) + '/' + (program?.stamps_target || 10)"></span>
</p>
</template>
<button @click="showPinModal('redeem')"
:disabled="!selectedReward"
class="w-full px-4 py-2 text-sm font-medium text-white bg-orange-600 rounded-lg hover:bg-orange-700 disabled:opacity-50">
Redeem Reward
</button>
</div>
<button @click="showPinModal('stamp')"
:disabled="!selectedCard?.can_stamp"
class="w-full px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700 disabled:opacity-50">
Add Stamp
</button>
<template x-if="!selectedCard?.can_stamp && selectedCard?.cooldown_ends_at">
<p class="text-xs text-red-500 mt-2">Cooldown active</p>
</template>
</div>
</template>
<template x-if="program?.is_stamps_enabled">
<div class="p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
<h4 class="font-medium text-gray-700 dark:text-gray-300 mb-3">
<span x-html="$icon('gift', 'inline w-4 h-4 mr-1 text-orange-500')"></span>
Redeem Stamps
</h4>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3"
x-text="selectedCard?.can_redeem_stamps ? 'Reward: ' + (selectedCard?.stamp_reward_description || program?.stamps_reward_description || 'Free item') : 'Not enough stamps yet'"></p>
<button @click="showPinModal('redeemStamps')"
:disabled="!selectedCard?.can_redeem_stamps"
class="w-full px-4 py-2 text-sm font-medium text-white bg-orange-600 rounded-lg hover:bg-orange-700 disabled:opacity-50">
Redeem Stamps
</button>
</div>
</template>
<!-- Point Panels (for points and hybrid) -->
<template x-if="program?.is_points_enabled">
<div class="p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
<h4 class="font-medium text-gray-700 dark:text-gray-300 mb-3">
<span x-html="$icon('plus-circle', 'inline w-4 h-4 mr-1 text-green-500')"></span>
Earn Points
</h4>
<div class="mb-3">
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Purchase Amount</label>
<div class="relative">
<span class="absolute inset-y-0 left-0 flex items-center pl-3 text-gray-500">EUR</span>
<input type="number" step="0.01" min="0" {# noqa: FE-008 #}
x-model.number="earnAmount"
class="w-full pl-12 pr-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-green-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
</div>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
Points to award: <span class="font-semibold text-green-600" x-text="Math.floor((earnAmount || 0) * (program?.points_per_euro || 1))"></span>
</p>
<button @click="showPinModal('earn')"
:disabled="!earnAmount || earnAmount <= 0"
class="w-full px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700 disabled:opacity-50">
Award Points
</button>
</div>
</template>
<template x-if="program?.is_points_enabled">
<div class="p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
<h4 class="font-medium text-gray-700 dark:text-gray-300 mb-3">
<span x-html="$icon('gift', 'inline w-4 h-4 mr-1 text-orange-500')"></span>
Redeem Reward
</h4>
<div class="mb-3">
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Select Reward</label>
<select x-model="selectedReward"
class="w-full px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-orange-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
<option value="">Select reward...</option>
<template x-for="reward in availableRewards" :key="reward.id">
<option :value="reward.id" :disabled="(selectedCard?.points_balance || 0) < reward.points_required"
x-text="reward.name + ' (' + reward.points_required + ' pts)'"></option>
</template>
</select>
</div>
<template x-if="selectedReward">
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
Points after: <span class="font-semibold text-orange-600" x-text="formatNumber((selectedCard?.points_balance || 0) - (getSelectedRewardPoints() || 0))"></span>
</p>
</template>
<button @click="showPinModal('redeem')"
:disabled="!selectedReward"
class="w-full px-4 py-2 text-sm font-medium text-white bg-orange-600 rounded-lg hover:bg-orange-700 disabled:opacity-50">
Redeem Reward
</button>
</div>
</template>
</div>
</div>
</div>