Files
orion/app/modules/loyalty/templates/loyalty/store/terminal.html
Samir Boulahtit 694a1cd1a5 feat(loyalty): add full i18n support for all loyalty module pages
Replace hardcoded English strings across all 22 templates, 10 JS files,
and 4 locale files (en/fr/de/lb) with ~300 translation keys per language.
Uses server-side _() for Jinja2 templates and I18n.t() for JS toast
messages and dynamic Alpine.js expressions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 19:53:17 +01:00

370 lines
23 KiB
HTML

{# app/modules/loyalty/templates/loyalty/store/terminal.html #}
{% extends "store/base.html" %}
{% from 'shared/macros/headers.html' import page_header_flex %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% from 'shared/macros/modals.html' import modal_simple %}
{% block title %}{{ _('loyalty.store.terminal.title') }}{% endblock %}
{% block i18n_modules %}['loyalty']{% endblock %}
{% block alpine_data %}storeLoyaltyTerminal(){% endblock %}
{% block content %}
<!-- Page Header -->
{% call page_header_flex(title=_('loyalty.store.terminal.title'), subtitle=_('loyalty.store.terminal.subtitle')) %}
<div class="flex items-center gap-3">
<a href="/store/{{ store_code }}/loyalty/cards"
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('users', 'w-4 h-4 mr-2')"></span>
{{ _('loyalty.store.terminal.members') }}
</a>
<a href="/store/{{ store_code }}/loyalty/analytics"
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('chart-bar', 'w-4 h-4 mr-2')"></span>
{{ _('loyalty.store.terminal.analytics') }}
</a>
</div>
{% endcall %}
{{ loading_state(_('loyalty.store.terminal.loading')) }}
{{ error_state(_('loyalty.store.terminal.error_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.common.program_not_setup') }}</h3>
<p class="mt-1 text-sm text-yellow-700 dark:text-yellow-300">{{ _('loyalty.common.program_not_setup_desc') }}</p>
{% if user.role == 'merchant_owner' %}
<a href="/store/{{ store_code }}/loyalty/program/edit"
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>
{{ _('loyalty.common.setup_program') }}
</a>
{% else %}
<p class="mt-2 text-sm text-yellow-700 dark:text-yellow-300">{{ _('loyalty.common.contact_admin_setup') }}</p>
{% endif %}
</div>
</div>
</div>
<!-- Main Terminal -->
<div x-show="!loading && program">
<div class="grid gap-6 lg:grid-cols-2">
<!-- Left: Customer Lookup -->
<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">
<span x-html="$icon('search', 'inline w-5 h-5 mr-2')"></span>
{{ _('loyalty.store.terminal.find_customer') }}
</h3>
</div>
<div class="p-4">
<!-- Search Input -->
<div class="relative mb-4">
<span class="absolute inset-y-0 left-0 flex items-center pl-3">
<span x-html="$icon('search', 'w-5 h-5 text-gray-400')"></span>
</span>
<input
type="text"
x-model="searchQuery"
@keyup.enter="lookupCustomer()"
placeholder="{{ _('loyalty.store.terminal.search_placeholder') }}"
class="w-full pl-10 pr-4 py-3 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>
<button
@click="lookupCustomer()"
:disabled="!searchQuery || lookingUp"
class="w-full flex items-center justify-center px-4 py-3 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 focus:outline-none disabled:opacity-50"
>
<span x-show="lookingUp" x-html="$icon('spinner', 'w-5 h-5 mr-2 animate-spin')"></span>
<span x-text="lookingUp ? $t('loyalty.store.terminal.looking_up') : $t('loyalty.store.terminal.look_up_customer')"></span>
</button>
<!-- Divider -->
<div class="relative my-6">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-gray-200 dark:border-gray-700"></div>
</div>
<div class="relative flex justify-center text-sm">
<span class="px-3 bg-white text-gray-500 dark:bg-gray-800 dark:text-gray-400">{{ _('loyalty.common.or') }}</span>
</div>
</div>
<!-- Enroll New Customer -->
<a href="/store/{{ store_code }}/loyalty/enroll"
class="w-full flex items-center justify-center px-4 py-3 text-sm font-medium text-purple-600 bg-purple-50 border border-purple-200 rounded-lg hover:bg-purple-100 dark:bg-purple-900/20 dark:text-purple-400 dark:border-purple-800">
<span x-html="$icon('user-plus', 'w-5 h-5 mr-2')"></span>
{{ _('loyalty.store.terminal.enroll_new_customer') }}
</a>
</div>
</div>
<!-- Right: Customer Card (shown when found) -->
<div x-show="selectedCard" 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('user', 'inline w-5 h-5 mr-2')"></span>
{{ _('loyalty.store.terminal.customer_found') }}
</h3>
</div>
<div class="p-4">
<!-- Customer Info -->
<div class="flex items-start mb-4">
<div class="flex-shrink-0 w-12 h-12 rounded-full flex items-center justify-center"
:style="'background-color: ' + (program?.card_color || '#4F46E5') + '20'">
<span class="text-lg font-semibold"
:style="'color: ' + (program?.card_color || '#4F46E5')"
x-text="selectedCard?.customer_name?.charAt(0).toUpperCase() || '?'"></span>
</div>
<div class="ml-4 flex-1">
<p class="font-semibold text-gray-700 dark:text-gray-200" x-text="selectedCard?.customer_name || 'Unknown'"></p>
<p class="text-sm text-gray-500 dark:text-gray-400" x-text="selectedCard?.customer_email"></p>
<p class="text-xs text-gray-400 dark:text-gray-500" x-text="$t('loyalty.store.terminal.card_label') + ': ' + selectedCard?.card_number"></p>
</div>
<button @click="clearCustomer()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<span x-html="$icon('x', 'w-5 h-5')"></span>
</button>
</div>
<!-- Balance Area -->
<div class="mb-6 p-4 rounded-lg text-center"
:style="'background-color: ' + (program?.card_color || '#4F46E5') + '10'">
<!-- 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" x-text="$t('loyalty.store.terminal.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" x-text="$t('loyalty.store.terminal.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 ? $t('loyalty.store.terminal.more_for_reward', {count: selectedCard.stamps_until_reward}) : $t('loyalty.store.terminal.ready_to_redeem')"></p>
</div>
</template>
</div>
<!-- Action Panels -->
<div class="grid gap-4 md:grid-cols-2">
<!-- 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>
{{ _('loyalty.store.terminal.add_stamp') }}
</h4>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
{{ _('loyalty.store.terminal.current') }}: <span class="font-semibold" x-text="(selectedCard?.stamp_count || 0) + '/' + (program?.stamps_target || 10)"></span>
</p>
<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">
{{ _('loyalty.store.terminal.add_stamp') }}
</button>
<template x-if="!selectedCard?.can_stamp && selectedCard?.cooldown_ends_at">
<p class="text-xs text-red-500 mt-2" x-text="$t('loyalty.store.terminal.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>
{{ _('loyalty.store.terminal.redeem_stamps') }}
</h4>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3"
x-text="selectedCard?.can_redeem_stamps ? $t('loyalty.store.terminal.reward_label') + ': ' + (selectedCard?.stamp_reward_description || program?.stamps_reward_description || $t('loyalty.store.terminal.free_item')) : $t('loyalty.store.terminal.not_enough_stamps')"></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">
{{ _('loyalty.store.terminal.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>
{{ _('loyalty.store.terminal.earn_points') }}
</h4>
<div class="mb-3">
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">{{ _('loyalty.store.terminal.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">
{{ _('loyalty.store.terminal.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">
{{ _('loyalty.store.terminal.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>
{{ _('loyalty.store.terminal.redeem_reward') }}
</h4>
<div class="mb-3">
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">{{ _('loyalty.store.terminal.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="">{{ _('loyalty.store.terminal.select_reward_placeholder') }}</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">
{{ _('loyalty.store.terminal.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">
{{ _('loyalty.store.terminal.redeem_reward') }}
</button>
</div>
</template>
</div>
</div>
</div>
<!-- Empty State (when no customer selected) -->
<div x-show="!selectedCard" class="bg-white rounded-lg shadow-md dark:bg-gray-800">
<div class="p-8 text-center">
<span x-html="$icon('user-circle', 'w-16 h-16 mx-auto text-gray-300 dark:text-gray-600')"></span>
<p class="mt-4 text-gray-500 dark:text-gray-400">{{ _('loyalty.store.terminal.search_empty_state') }}</p>
</div>
</div>
</div>
<!-- Recent Transactions -->
<div class="mt-6 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('clock', 'inline w-5 h-5 mr-2')"></span>
{{ _('loyalty.store.terminal.recent_transactions') }}
</h3>
</div>
<div class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-700">
<th class="px-4 py-3">{{ _('loyalty.store.terminal.col_time') }}</th>
<th class="px-4 py-3">{{ _('loyalty.store.terminal.col_customer') }}</th>
<th class="px-4 py-3">{{ _('loyalty.store.terminal.col_type') }}</th>
<th class="px-4 py-3 text-right">{{ _('loyalty.store.terminal.col_points') }}</th>
<th class="px-4 py-3">{{ _('loyalty.store.terminal.col_notes') }}</th>
</tr>
</thead>
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<template x-if="recentTransactions.length === 0">
<tr>
<td colspan="5" class="px-4 py-6 text-center text-gray-500 dark:text-gray-400">
{{ _('loyalty.store.terminal.no_recent_transactions') }}
</td>
</tr>
</template>
<template x-for="tx in recentTransactions" :key="tx.id">
<tr class="text-gray-700 dark:text-gray-400">
<td class="px-4 py-3 text-sm" x-text="formatTime(tx.transaction_at)"></td>
<td class="px-4 py-3 text-sm" x-text="tx.customer_name || 'Unknown'"></td>
<td class="px-4 py-3">
<span class="px-2 py-1 text-xs font-semibold rounded-full"
:class="getTransactionColor(tx)"
x-text="getTransactionLabel(tx)"></span>
</td>
<td class="px-4 py-3 text-sm text-right font-medium"
:class="tx.points_delta > 0 ? 'text-green-600' : 'text-orange-600'"
x-text="(tx.points_delta > 0 ? '+' : '') + formatNumber(tx.points_delta)"></td>
<td class="px-4 py-3 text-sm text-gray-500" x-text="tx.notes || '-'"></td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</div>
<!-- Staff PIN Modal -->
{% call modal_simple(id='pinModal', title=_('loyalty.store.terminal.enter_staff_pin'), show_var='showPinEntry') %}
<div class="p-6">
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
{{ _('loyalty.store.terminal.pin_authorize_text') }}
</p>
<div class="flex justify-center mb-4">
<div class="flex gap-2">
<template x-for="i in 4">
<div class="w-12 h-12 border-2 rounded-lg flex items-center justify-center text-2xl font-bold"
:class="pinDigits.length >= i ? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20' : 'border-gray-300 dark:border-gray-600'"
x-text="pinDigits.length >= i ? '*' : ''"></div>
</template>
</div>
</div>
<div class="grid grid-cols-3 gap-2 max-w-xs mx-auto">
<template x-for="digit in [1, 2, 3, 4, 5, 6, 7, 8, 9]">
<button @click="addPinDigit(digit)"
class="h-14 text-xl font-semibold rounded-lg bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600">
<span x-text="digit"></span>
</button>
</template>
<button @click="pinDigits = ''"
class="h-14 text-sm font-medium rounded-lg bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600">
{{ _('loyalty.store.terminal.clear') }}
</button>
<button @click="addPinDigit(0)"
class="h-14 text-xl font-semibold rounded-lg bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600">
0
</button>
<button @click="removePinDigit()"
class="h-14 text-sm font-medium rounded-lg bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600">
<span x-html="$icon('backspace', 'w-6 h-6 mx-auto')"></span>
</button>
</div>
<div class="mt-4 flex justify-end gap-3">
<button @click="cancelPinEntry()"
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">
{{ _('loyalty.common.cancel') }}
</button>
<button @click="submitTransaction()"
:disabled="pinDigits.length !== 4 || processing"
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50">
<span x-show="processing" x-html="$icon('spinner', 'w-4 h-4 mr-2 inline animate-spin')"></span>
<span x-text="processing ? $t('loyalty.store.terminal.processing') : $t('loyalty.store.terminal.confirm')"></span>
</button>
</div>
</div>
{% endcall %}
{% endblock %}
{% block extra_scripts %}
<script defer src="{{ url_for('loyalty_static', path='store/js/loyalty-terminal.js') }}"></script>
{% endblock %}