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

@@ -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

@@ -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>