Compare commits

..

2 Commits

Author SHA1 Message Date
4c1608f78a feat(loyalty): Phase 4.1 — T&C via CMS integration
Some checks failed
CI / ruff (push) Successful in 12s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
Add support for linking a loyalty program's Terms & Conditions to a
CMS page, replacing the simple terms_text textarea with a scalable
content source that supports rich HTML, multi-language, and store
overrides.

- Migration loyalty_006: adds terms_cms_page_slug column to
  loyalty_programs (nullable, String 200).
- Model + schemas: new field on LoyaltyProgram, ProgramCreate,
  ProgramUpdate, ProgramResponse.
- Program form: new "CMS Page Slug" input field with hint text,
  placed above the legacy terms_text (now labeled as "fallback").
- Enrollment page: when terms_cms_page_slug is set, JS fetches the
  CMS page content via /storefront/cms/pages/{slug} and displays
  rendered HTML in the modal. Falls back to terms_text when no slug.
- i18n: 3 new keys in 4 locales (terms_cms_page, terms_cms_page_hint,
  terms_fallback_hint).

Legacy terms_text field preserved as fallback for existing programs.

342 tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 20:26:22 +02:00
24219e4d9a a11y(loyalty): Phase 4.2 — accessibility audit fixes
Fix 15 accessibility issues across loyalty templates:

Modals (4 fixes):
- storefront/dashboard.html: barcode modal — add role="dialog",
  aria-modal, aria-labelledby, @keydown.escape
- storefront/enroll.html: terms modal — add role="dialog",
  aria-modal, aria-labelledby, aria-label on close button
- store/enroll.html: success modal — add role="dialog",
  aria-modal, aria-labelledby, @keydown.escape
- store/terminal.html: PIN entry — add aria-live="polite" on
  digit display with role="status" for screen reader announcements

Icon-only buttons (10 fixes):
- shared/pins-list.html: edit, delete, unlock — add aria-label
- admin/programs.html: view, edit, delete, activate/deactivate —
  add aria-label (dynamic for toggle state)
- store/terminal.html: clear customer, backspace — add aria-label

All buttons also get explicit type="button" where missing.

342 tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 20:14:03 +02:00
15 changed files with 122 additions and 36 deletions

View File

@@ -405,7 +405,10 @@
"logo_url_help": "Erforderlich für Google Wallet-Integration. Muss eine öffentlich zugängliche Bild-URL sein (PNG oder JPG).",
"hero_image_url": "Hintergrundbild-URL",
"terms_privacy": "AGB & Datenschutz",
"terms_conditions": "Allgemeine Geschäftsbedingungen",
"terms_cms_page": "CMS-Seiten-Slug",
"terms_cms_page_hint": "Geben Sie einen CMS-Seiten-Slug ein (z.B. agb) um die vollständigen AGB aus dem CMS-Modul anzuzeigen",
"terms_conditions": "Allgemeine Geschäftsbedingungen (Fallback)",
"terms_fallback_hint": "Wird verwendet wenn kein CMS-Slug gesetzt ist",
"privacy_policy_url": "Datenschutzrichtlinien-URL",
"program_status": "Programmstatus",
"program_active": "Programm aktiv",

View File

@@ -405,7 +405,10 @@
"logo_url_help": "Required for Google Wallet integration. Must be a publicly accessible image URL (PNG or JPG).",
"hero_image_url": "Hero Image URL",
"terms_privacy": "Terms & Privacy",
"terms_conditions": "Terms & Conditions",
"terms_cms_page": "CMS Page Slug",
"terms_cms_page_hint": "Enter a CMS page slug (e.g. terms-and-conditions) to display full T&C from the CMS module",
"terms_conditions": "Terms & Conditions (fallback)",
"terms_fallback_hint": "Used when no CMS page slug is set",
"privacy_policy_url": "Privacy Policy URL",
"program_status": "Program Status",
"program_active": "Program Active",

View File

@@ -405,7 +405,10 @@
"logo_url_help": "Requis pour l'intégration Google Wallet. Doit être une URL d'image publique (PNG ou JPG).",
"hero_image_url": "URL de l'image principale",
"terms_privacy": "Conditions & Confidentialité",
"terms_conditions": "Conditions Générales",
"terms_cms_page": "Slug de page CMS",
"terms_cms_page_hint": "Entrez un slug de page CMS (ex. conditions-generales) pour afficher les CGV complètes depuis le module CMS",
"terms_conditions": "Conditions Générales (secours)",
"terms_fallback_hint": "Utilisé quand aucun slug CMS n'est défini",
"privacy_policy_url": "URL politique de confidentialité",
"program_status": "Statut du programme",
"program_active": "Programme actif",

View File

@@ -405,7 +405,10 @@
"logo_url_help": "Erfuerderlech fir Google Wallet-Integratioun. Muss eng ëffentlech zougänglech Bild-URL sinn (PNG oder JPG).",
"hero_image_url": "Hannergrondbild-URL",
"terms_privacy": "AGB & Dateschutz",
"terms_conditions": "Allgemeng Geschäftsbedingungen",
"terms_cms_page": "CMS-Säiten-Slug",
"terms_cms_page_hint": "Gitt e CMS-Säiten-Slug an (z.B. agb) fir déi komplett AGB vum CMS-Modul unzeweisen",
"terms_conditions": "Allgemeng Geschäftsbedingungen (Fallback)",
"terms_fallback_hint": "Gëtt benotzt wann keen CMS-Slug gesat ass",
"privacy_policy_url": "Dateschutzrichtlinn-URL",
"program_status": "Programmstatus",
"program_active": "Programm aktiv",

View File

@@ -0,0 +1,35 @@
"""loyalty 006 - add terms_cms_page_slug to loyalty_programs
Allows linking a loyalty program's T&C to a CMS page instead of
using the simple terms_text field. When set, the enrollment page
resolves the slug to a full CMS page. The legacy terms_text is
kept as a fallback for existing programs.
Revision ID: loyalty_006
Revises: loyalty_005
Create Date: 2026-04-11
"""
import sqlalchemy as sa
from alembic import op
revision = "loyalty_006"
down_revision = "loyalty_005"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"loyalty_programs",
sa.Column(
"terms_cms_page_slug",
sa.String(200),
nullable=True,
comment="CMS page slug for full T&C content (overrides terms_text when set)",
),
)
def downgrade() -> None:
op.drop_column("loyalty_programs", "terms_cms_page_slug")

View File

@@ -227,7 +227,12 @@ class LoyaltyProgram(Base, TimestampMixin, SoftDeleteMixin):
terms_text = Column(
Text,
nullable=True,
comment="Loyalty program terms and conditions",
comment="Loyalty program terms and conditions (legacy — use terms_cms_page_slug when available)",
)
terms_cms_page_slug = Column(
String(200),
nullable=True,
comment="CMS page slug for full T&C content (overrides terms_text when set)",
)
privacy_url = Column(
String(500),

View File

@@ -110,7 +110,8 @@ class ProgramCreate(BaseModel):
hero_image_url: str | None = Field(None, max_length=500, description="Hero image URL")
# Terms
terms_text: str | None = Field(None, description="Terms and conditions")
terms_text: str | None = Field(None, description="Terms and conditions (legacy)")
terms_cms_page_slug: str | None = Field(None, max_length=200, description="CMS page slug for T&C")
privacy_url: str | None = Field(None, max_length=500, description="Privacy policy URL")
@@ -155,6 +156,7 @@ class ProgramUpdate(BaseModel):
# Terms
terms_text: str | None = None
terms_cms_page_slug: str | None = Field(None, max_length=200)
privacy_url: str | None = Field(None, max_length=500)
# Wallet integration
@@ -202,6 +204,7 @@ class ProgramResponse(BaseModel):
# Terms
terms_text: str | None = None
terms_cms_page_slug: str | None = None
privacy_url: str | None = None
# Wallet

View File

@@ -27,10 +27,26 @@ function customerLoyaltyEnroll() {
enrolledCard: null,
error: null,
showTerms: false,
termsHtml: null,
async init() {
loyaltyEnrollLog.info('Customer loyalty enroll initializing...');
await this.loadProgram();
// Load CMS T&C content if a page slug is configured
if (this.program?.terms_cms_page_slug) {
this.loadTermsFromCms(this.program.terms_cms_page_slug);
}
},
async loadTermsFromCms(slug) {
try {
const response = await apiClient.get(`/storefront/cms/pages/${slug}`);
if (response?.content_html) {
this.termsHtml = response.content_html;
}
} catch (e) {
loyaltyEnrollLog.warn('Could not load CMS T&C page:', e.message);
}
},
async loadProgram() {

View File

@@ -215,7 +215,7 @@
<a
:href="'/admin/loyalty/merchants/' + program.merchant_id"
class="flex items-center justify-center p-2 text-blue-600 rounded-lg hover:bg-blue-50 dark:text-blue-400 dark:hover:bg-gray-700 focus:outline-none transition-colors"
title="{{ _('loyalty.common.view') }}"
aria-label="{{ _('loyalty.common.view') }}"
>
<span x-html="$icon('eye', 'w-5 h-5')"></span>
</a>
@@ -224,26 +224,26 @@
<a
:href="'/admin/loyalty/merchants/' + program.merchant_id + '/program'"
class="flex items-center justify-center p-2 text-purple-600 rounded-lg hover:bg-purple-50 dark:text-purple-400 dark:hover:bg-gray-700 focus:outline-none transition-colors"
title="{{ _('loyalty.common.edit') }}"
aria-label="{{ _('loyalty.common.edit') }}"
>
<span x-html="$icon('edit', 'w-5 h-5')"></span>
</a>
<!-- Delete Button -->
<button
<button type="button"
@click="confirmDeleteProgram(program)"
class="flex items-center justify-center p-2 text-red-600 rounded-lg hover:bg-red-50 dark:text-red-400 dark:hover:bg-gray-700 focus:outline-none transition-colors"
title="{{ _('loyalty.common.delete') }}"
aria-label="{{ _('loyalty.common.delete') }}"
>
<span x-html="$icon('delete', 'w-5 h-5')"></span>
</button>
<!-- Activate/Deactivate Toggle -->
<button
<button type="button"
@click="toggleProgramActive(program)"
class="flex items-center justify-center p-2 rounded-lg focus:outline-none transition-colors"
:class="program.is_active ? 'text-orange-600 hover:bg-orange-50 dark:text-orange-400 dark:hover:bg-gray-700' : 'text-green-600 hover:bg-green-50 dark:text-green-400 dark:hover:bg-gray-700'"
:title="program.is_active ? 'Deactivate program' : 'Activate program'"
:aria-label="program.is_active ? '{{ _('loyalty.common.deactivate') }}' : '{{ _('loyalty.common.activate') }}'"
>
<span x-html="$icon(program.is_active ? 'pause' : 'play', 'w-5 h-5')"></span>
</button>

View File

@@ -103,17 +103,19 @@
<td class="px-4 py-3">
{% if show_crud %}
<div class="flex items-center gap-2">
<button @click="openEditModal(pin)"
<button @click="openEditModal(pin)" type="button"
aria-label="{{ _('loyalty.common.edit') }}"
class="text-purple-600 hover:text-purple-700 dark:text-purple-400 text-sm">
<span x-html="$icon('pencil', 'w-4 h-4')"></span>
</button>
<button @click="openDeleteModal(pin)"
<button @click="openDeleteModal(pin)" type="button"
aria-label="{{ _('loyalty.common.delete') }}"
class="text-red-600 hover:text-red-700 dark:text-red-400 text-sm">
<span x-html="$icon('trash', 'w-4 h-4')"></span>
</button>
<button x-show="pin.is_locked" @click="unlockPin(pin)"
class="text-orange-600 hover:text-orange-700 dark:text-orange-400 text-sm"
:title="$t('loyalty.shared.pins.unlock')">
<button x-show="pin.is_locked" @click="unlockPin(pin)" type="button"
aria-label="{{ _('loyalty.shared.pins.unlock') }}"
class="text-orange-600 hover:text-orange-700 dark:text-orange-400 text-sm">
<span x-html="$icon('lock-open', 'w-4 h-4')"></span>
</button>
</div>

View File

@@ -247,9 +247,11 @@
</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">{{ _('loyalty.shared.program_form.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>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ _('loyalty.shared.program_form.terms_cms_page') }}</label>
<input type="text" x-model="settings.terms_cms_page_slug" maxlength="200"
placeholder="e.g. terms-and-conditions"
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">{{ _('loyalty.shared.program_form.terms_cms_page_hint') }}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ _('loyalty.shared.program_form.privacy_policy_url') }}</label>
@@ -257,6 +259,12 @@
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 class="mt-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ _('loyalty.shared.program_form.terms_conditions') }}</label>
<textarea x-model="settings.terms_text" rows="3"
placeholder="{{ _('loyalty.shared.program_form.terms_fallback_hint') }}"
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>
<!-- Program Status -->

View File

@@ -114,13 +114,16 @@
</form>
<!-- Success Modal --> {# noqa: FE-004 #}
<div x-show="enrolledCard" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div x-show="enrolledCard"
role="dialog" aria-modal="true" aria-labelledby="enroll-success-title"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
@keydown.escape.window="enrolledCard = null">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full mx-4 p-6">
<div class="text-center">
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-green-100 flex items-center justify-center">
<span x-html="$icon('check', 'w-8 h-8 text-green-500')"></span>
</div>
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200 mb-2">{{ _('loyalty.store.enroll.customer_enrolled') }}</h3>
<h3 id="enroll-success-title" class="text-lg font-semibold text-gray-700 dark:text-gray-200 mb-2">{{ _('loyalty.store.enroll.customer_enrolled') }}</h3>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">
{{ _('loyalty.store.enroll.card_number_label') }}: <span class="font-mono font-semibold" x-text="enrolledCard?.card_number"></span>
</p>

View File

@@ -131,7 +131,7 @@
<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">
<button @click="clearCustomer()" type="button" aria-label="{{ _('loyalty.common.close') }}" 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>
@@ -324,8 +324,8 @@
<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">
<div class="flex justify-center mb-4" aria-live="polite" aria-atomic="true">
<div class="flex gap-2" role="status" :aria-label="pinDigits.length + ' of 4 digits entered'">
<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'"
@@ -348,7 +348,7 @@
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()"
<button @click="removePinDigit()" type="button" aria-label="{{ _('loyalty.common.back') }}"
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>

View File

@@ -186,10 +186,12 @@
<!-- Barcode Modal -->
<div x-show="showBarcode" x-cloak
role="dialog" aria-modal="true" aria-labelledby="barcode-modal-title"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
@click.self="showBarcode = false">
@click.self="showBarcode = false"
@keydown.escape.window="showBarcode = false">
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-xl max-w-sm w-full p-6 text-center">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">{{ _('loyalty.storefront.dashboard.your_loyalty_card') }}</h3>
<h3 id="barcode-modal-title" class="text-lg font-semibold text-gray-900 dark:text-white mb-4">{{ _('loyalty.storefront.dashboard.your_loyalty_card') }}</h3>
<!-- Barcode Placeholder -->
<div class="bg-white p-4 rounded-lg mb-4">

View File

@@ -96,10 +96,10 @@
style="color: var(--color-primary)">
<span class="ml-2 text-sm text-gray-600 dark:text-gray-400">
{{ _('loyalty.enrollment.form.terms_agree') }}
<template x-if="program?.terms_text">
<template x-if="program?.terms_text || program?.terms_cms_page_slug">
<a href="#" @click.prevent="showTerms = true" class="underline" style="color: var(--color-primary)">{{ _('loyalty.enrollment.form.terms') }}</a>
</template>
<template x-if="!program?.terms_text">
<template x-if="!program?.terms_text && !program?.terms_cms_page_slug">
<span class="underline" style="color: var(--color-primary)">{{ _('loyalty.enrollment.form.terms') }}</span>
</template>
</span>
@@ -137,22 +137,22 @@
</div>
</div>
{# TODO: Rework T&C strategy - current approach (small text field on program model) won't scale
for full legal T&C. Options: (1) leverage the CMS module to host T&C pages, or
(2) create a dedicated T&C page within the loyalty module. Decision pending. #}
<!-- Terms & Conditions Modal -->
<div x-show="showTerms" x-cloak
role="dialog" aria-modal="true" aria-labelledby="terms-modal-title"
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50"
@click.self="showTerms = false"
@keydown.escape.window="showTerms = false">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-lg w-full max-h-[80vh] flex flex-col">
<div class="flex items-center justify-between p-4 border-b dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ _('loyalty.enrollment.form.terms') }}</h3>
<button @click="showTerms = false" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<h3 id="terms-modal-title" class="text-lg font-semibold text-gray-900 dark:text-white">{{ _('loyalty.enrollment.form.terms') }}</h3>
<button @click="showTerms = false" type="button" aria-label="{{ _('loyalty.enrollment.close') }}" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<span x-html="$icon('x-mark', 'w-5 h-5')"></span>
</button>
</div>
<div class="p-4 overflow-y-auto text-sm text-gray-700 dark:text-gray-300 whitespace-pre-line" x-text="program?.terms_text"></div>
{# CMS page content takes priority over legacy terms_text #}
<div x-show="termsHtml" class="p-4 overflow-y-auto text-sm text-gray-700 dark:text-gray-300 prose dark:prose-invert max-w-none" x-html="termsHtml"></div>
<div x-show="!termsHtml && program?.terms_text" class="p-4 overflow-y-auto text-sm text-gray-700 dark:text-gray-300 whitespace-pre-line" x-text="program?.terms_text"></div>
<template x-if="program?.privacy_url">
<div class="px-4 pb-2">
<a :href="program.privacy_url" target="_blank" class="text-sm underline" style="color: var(--color-primary)">{{ _('loyalty.enrollment.privacy_policy') }}</a>