feat(loyalty): Phase 4.1 — T&C via CMS integration
Some checks failed
Some checks failed
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:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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,9 +137,6 @@
|
||||
</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"
|
||||
@@ -153,7 +150,9 @@
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user