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>
This commit is contained in:
2026-04-11 20:26:22 +02:00
parent 24219e4d9a
commit 4c1608f78a
10 changed files with 93 additions and 15 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).", "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", "hero_image_url": "Hintergrundbild-URL",
"terms_privacy": "AGB & Datenschutz", "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", "privacy_policy_url": "Datenschutzrichtlinien-URL",
"program_status": "Programmstatus", "program_status": "Programmstatus",
"program_active": "Programm aktiv", "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).", "logo_url_help": "Required for Google Wallet integration. Must be a publicly accessible image URL (PNG or JPG).",
"hero_image_url": "Hero Image URL", "hero_image_url": "Hero Image URL",
"terms_privacy": "Terms & Privacy", "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", "privacy_policy_url": "Privacy Policy URL",
"program_status": "Program Status", "program_status": "Program Status",
"program_active": "Program Active", "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).", "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", "hero_image_url": "URL de l'image principale",
"terms_privacy": "Conditions & Confidentialité", "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é", "privacy_policy_url": "URL politique de confidentialité",
"program_status": "Statut du programme", "program_status": "Statut du programme",
"program_active": "Programme actif", "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).", "logo_url_help": "Erfuerderlech fir Google Wallet-Integratioun. Muss eng ëffentlech zougänglech Bild-URL sinn (PNG oder JPG).",
"hero_image_url": "Hannergrondbild-URL", "hero_image_url": "Hannergrondbild-URL",
"terms_privacy": "AGB & Dateschutz", "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", "privacy_policy_url": "Dateschutzrichtlinn-URL",
"program_status": "Programmstatus", "program_status": "Programmstatus",
"program_active": "Programm aktiv", "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( terms_text = Column(
Text, Text,
nullable=True, 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( privacy_url = Column(
String(500), 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") hero_image_url: str | None = Field(None, max_length=500, description="Hero image URL")
# Terms # 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") privacy_url: str | None = Field(None, max_length=500, description="Privacy policy URL")
@@ -155,6 +156,7 @@ class ProgramUpdate(BaseModel):
# Terms # Terms
terms_text: str | None = None 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) privacy_url: str | None = Field(None, max_length=500)
# Wallet integration # Wallet integration
@@ -202,6 +204,7 @@ class ProgramResponse(BaseModel):
# Terms # Terms
terms_text: str | None = None terms_text: str | None = None
terms_cms_page_slug: str | None = None
privacy_url: str | None = None privacy_url: str | None = None
# Wallet # Wallet

View File

@@ -27,10 +27,26 @@ function customerLoyaltyEnroll() {
enrolledCard: null, enrolledCard: null,
error: null, error: null,
showTerms: false, showTerms: false,
termsHtml: null,
async init() { async init() {
loyaltyEnrollLog.info('Customer loyalty enroll initializing...'); loyaltyEnrollLog.info('Customer loyalty enroll initializing...');
await this.loadProgram(); 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() { async loadProgram() {

View File

@@ -247,9 +247,11 @@
</h3> </h3>
<div class="grid gap-6 md:grid-cols-2"> <div class="grid gap-6 md:grid-cols-2">
<div> <div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ _('loyalty.shared.program_form.terms_conditions') }}</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ _('loyalty.shared.program_form.terms_cms_page') }}</label>
<textarea x-model="settings.terms_text" rows="3" <input type="text" x-model="settings.terms_cms_page_slug" maxlength="200"
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> 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>
<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> <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"> 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> </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> </div>
<!-- Program Status --> <!-- Program Status -->

View File

@@ -96,10 +96,10 @@
style="color: var(--color-primary)"> style="color: var(--color-primary)">
<span class="ml-2 text-sm text-gray-600 dark:text-gray-400"> <span class="ml-2 text-sm text-gray-600 dark:text-gray-400">
{{ _('loyalty.enrollment.form.terms_agree') }} {{ _('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> <a href="#" @click.prevent="showTerms = true" class="underline" style="color: var(--color-primary)">{{ _('loyalty.enrollment.form.terms') }}</a>
</template> </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> <span class="underline" style="color: var(--color-primary)">{{ _('loyalty.enrollment.form.terms') }}</span>
</template> </template>
</span> </span>
@@ -137,9 +137,6 @@
</div> </div>
</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 --> <!-- Terms & Conditions Modal -->
<div x-show="showTerms" x-cloak <div x-show="showTerms" x-cloak
role="dialog" aria-modal="true" aria-labelledby="terms-modal-title" role="dialog" aria-modal="true" aria-labelledby="terms-modal-title"
@@ -153,7 +150,9 @@
<span x-html="$icon('x-mark', 'w-5 h-5')"></span> <span x-html="$icon('x-mark', 'w-5 h-5')"></span>
</button> </button>
</div> </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"> <template x-if="program?.privacy_url">
<div class="px-4 pb-2"> <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> <a :href="program.privacy_url" target="_blank" class="text-sm underline" style="color: var(--color-primary)">{{ _('loyalty.enrollment.privacy_policy') }}</a>