Compare commits

..

40 Commits

Author SHA1 Message Date
3fa159ff2a fix(loyalty): sanitize earn amount input on every keystroke
Some checks failed
CI / ruff (push) Successful in 15s
CI / pytest (push) Failing after 2h19m25s
CI / validate (push) Successful in 29s
CI / dependency-scanning (push) Successful in 33s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped
Switch from type=number (leaks special chars via compose keys) to
type=text with @input sanitizer that strips non-digit/non-dot
characters and prevents multiple dots. Handles accented characters,
paste, and compose key sequences.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 17:58:42 +02:00
143248ff0f fix(loyalty): restrict earn points input to digits and decimal only
Some checks failed
CI / ruff (push) Successful in 16s
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 keypress filter to block non-numeric characters (e, +, -) and
inputmode="decimal" for mobile keyboard.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 16:11:10 +02:00
56c94ac2f4 fix(loyalty): use confirm modal for category deletion
Some checks failed
CI / ruff (push) Successful in 17s
CI / pytest (push) Failing after 2h21m18s
CI / validate (push) Successful in 29s
CI / dependency-scanning (push) Successful in 33s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped
Replace browser confirm() dialog with the shared confirm_modal
macro for category deletion. Matches the existing program delete
pattern. Shows warning about impact on existing transactions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 14:40:29 +02:00
255ac6525e fix(loyalty): translate category names in transaction history
Some checks failed
CI / ruff (push) Successful in 15s
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
Category names in transaction tables now resolve to the current
page language instead of always showing English. Updated:
- category_service.validate_category_for_store: accepts lang param,
  uses get_translated_name()
- Store transactions list route: passes request.state.language
- Card detail transactions route: passes request.state.language
- card_service.get_customer_transactions_with_store_names: accepts
  lang param for storefront route

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 14:17:43 +02:00
10e37e749b fix(loyalty): show translated category names on terminal
Some checks failed
CI / ruff (push) Successful in 14s
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 started running
Category pills in the PIN modal now display the translated name
based on the page's current_language, falling back to the default
name (English) if no translation exists.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 13:15:47 +02:00
f23990a4d9 fix(loyalty): add red star (*) to mandatory category fields
Some checks failed
CI / ruff (push) Successful in 17s
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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 12:47:12 +02:00
62b83b46a4 feat(loyalty): category view mode + mandatory translations
Some checks failed
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / ruff (push) Successful in 14s
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
- Add eye icon to expand read-only view of all translations
- View panel shows EN/FR/DE/LB values with "Edit" button to switch
  to edit mode
- All 4 language fields (EN/FR/DE/LB) now mandatory — Save button
  disabled until all are filled (both add and edit forms)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 12:34:37 +02:00
f8b2429533 fix(loyalty): rename category label "Name (default)" to "English (EN)"
Some checks failed
CI / validate (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / ruff (push) Successful in 18s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 12:28:00 +02:00
3883927be0 fix(loyalty): disable confirm button until category selected
Some checks failed
CI / ruff (push) Successful in 17s
CI / pytest (push) Failing after 2h35m37s
CI / deploy (push) Has been skipped
CI / validate (push) Successful in 41s
CI / dependency-scanning (push) Successful in 44s
CI / docs (push) Has been skipped
PIN modal confirm button stays disabled when categories exist and
the action is stamp/earn but no category is selected yet.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 22:33:26 +02:00
39e02f0d9b fix(loyalty): terminal icons, server-side i18n, category in transactions
Some checks failed
CI / ruff (push) Successful in 22s
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
- Fix icons: plus-circle → plus, backspace → arrow-left
- Convert terminal $t() calls to server-side _() for card_label,
  stamps_until_reward, reward_label, not_enough_stamps
- Inject transaction type labels as server-rendered window._txLabels
  object (eliminates all async i18n warnings on terminal page)
- Resolve category_names in store transactions list endpoint

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 22:20:52 +02:00
29593f4c61 feat(loyalty): multi-select categories on transactions
Some checks failed
CI / ruff (push) Successful in 24s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / pytest (push) Has been cancelled
Switch from single category_id to category_ids JSON array on
transactions. Sellers can now select multiple categories (e.g.,
Men + Accessories) when entering stamp/points transactions.

- Migration loyalty_009: drop category_id FK, add category_ids JSON
- Schemas: category_id → category_ids (list[int] | None)
- Services: stamp_service + points_service accept category_ids
- Terminal UI: pills are now multi-select (toggle on/off)
- Transaction response: category_names (list[str]) resolved from IDs
- Recent transactions table: new Category column showing comma-
  separated names

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 21:36:49 +02:00
220f7e3a08 fix(loyalty): replace $t() with server-side _() in program-view
Some checks failed
CI / ruff (push) Successful in 16s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / pytest (push) Has been cancelled
Convert 6 client-side $t() calls to server-rendered _() in the
shared program-view template to eliminate i18n timing warnings.
Uses .replace() for dynamic parameters (count, days).

Fixes warnings: loyalty.common.active, inactive, none, never,
loyalty.shared.program_view.x_points, x_days_inactivity, x_minutes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 19:58:03 +02:00
258aa6a34b fix(loyalty): missing i18n keys, wrong icon names in admin
Some checks failed
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
CI / ruff (push) Successful in 22s
CI / validate (push) Has been cancelled
- Add missing common keys: add, activate, copy, deactivate
- Fix icon: building-office → office-building (2 templates)
- Fix icon: pause → ban (pause not in icon set, ban used for deactivate)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 19:52:39 +02:00
51bcc9f874 feat(loyalty): inline edit for transaction categories in admin
Some checks failed
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has started running
CI / ruff (push) Successful in 21s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
Category list now has a pencil edit button that expands inline with
name + FR/DE/LB translation fields. Save updates via PATCH API.
View mode shows translations summary next to the name.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 19:27:55 +02:00
eafa086c73 feat(loyalty): translatable categories + mandatory on earn points
Some checks failed
CI / pytest (push) Failing after 2h47m45s
CI / validate (push) Successful in 39s
CI / dependency-scanning (push) Successful in 47s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped
CI / ruff (push) Successful in 21s
- Add name_translations JSON column to StoreTransactionCategory
  (migration loyalty_008). Stores {"en": "Men", "fr": "Hommes", ...}.
  Model has get_translated_name(lang) helper.
- Admin CRUD form now has FR/DE/LB translation inputs alongside the
  default name.
- Points earn: category_id is now mandatory when the store has
  active categories configured. Returns CATEGORY_REQUIRED error.
- Stamps: category remains optional (quick tap workflow).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 14:12:55 +02:00
ab2daf99bd feat(loyalty): transaction categories — admin UI + web terminal
Some checks failed
CI / ruff (push) Successful in 27s
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 started running
Admin merchant detail page:
- New "Transaction Categories" section with store selector
- Inline add form, activate/deactivate toggle, delete button
- Categories CRUD via /admin/loyalty/stores/{id}/categories API

Web terminal:
- Loads categories on init via /store/loyalty/categories
- Category pill selector shown in PIN modal before stamp/earn actions
- Selected category_id passed to stamp and points API calls
- Categories are optional (selector hidden when none configured)

4 new i18n keys (EN).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 12:28:55 +02:00
1cf9fea40a feat(loyalty): transaction categories (what was sold)
Some checks failed
CI / ruff (push) Successful in 20s
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
Merchants can configure per-store product categories (e.g., Men,
Women, Accessories, Kids) that sellers select when entering loyalty
transactions. Enables per-category sales analytics.

Backend:
- New model: StoreTransactionCategory (store-scoped, max 10 per store)
- Migration loyalty_007: creates table + adds category_id FK on
  loyalty_transactions
- New category_service.py with CRUD + validation
- New schemas/category.py (Create, Update, Response, ListResponse)
- Admin CRUD: GET/POST/PATCH/DELETE /admin/loyalty/stores/{id}/categories
- Store CRUD: GET/POST/PATCH/DELETE /store/loyalty/categories
- Stamp/Points request schemas accept optional category_id
- Stamp/Points services pass category_id to transaction creation
- TransactionResponse includes category_id + category_name

342 tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 12:23:17 +02:00
cd4f83f2cb docs: add proposal for transaction categories (what was sold)
Some checks failed
CI / deploy (push) Has been skipped
CI / ruff (push) Successful in 20s
CI / pytest (push) Failing after 2h34m11s
CI / validate (push) Successful in 35s
CI / dependency-scanning (push) Successful in 44s
CI / docs (push) Has been skipped
Client requirement: sellers must select a product category (e.g.,
Men, Women, Accessories, Kids) when entering loyalty transactions.
Categories are per-store, configured via admin/merchant CRUD.

Proposal covers: data model (StoreTransactionCategory + FK on
transactions), CRUD API for admin + store, web terminal UI, Android
terminal integration, and analytics extension path.

Priority: urgent for production launch.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 00:29:41 +02:00
457350908a fix(android): resolve build errors in terminal scaffold
Some checks failed
CI / ruff (push) Successful in 20s
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
- Fix settings.gradle.kts: dependencyResolution → dependencyResolutionManagement
- Fix Hilt version: 2.54.1 (non-existent) → 2.51.1
- Fix LoyaltyApi.kt: remove decorative unicode comments causing
  "unclosed comment" errors, fix /api/v1/store/loyalty/* pattern
  in KDoc that Kotlin parsed as block comment start
- Add placeholder launcher icons (purple square, all densities)

App now builds and runs on Pixel Tablet emulator: Setup → PIN → Terminal.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 00:11:30 +02:00
e759282116 refactor: rename apps/ to clients/ + update architecture docs
Some checks failed
CI / ruff (push) Successful in 20s
CI / pytest (push) Failing after 2h37m33s
CI / validate (push) Successful in 41s
CI / dependency-scanning (push) Successful in 42s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped
Rename apps/ → clients/ for clarity:
- app/ (singular) = Python backend (FastAPI, server-rendered web UI)
- clients/ (plural) = standalone client applications (API consumers)

The web storefront/store/admin stays in app/ because it's server-
rendered Jinja2, not a standalone frontend. clients/ is for native
apps that connect to the API externally.

Updated:
- docs/architecture/overview.md — added clients/ to project structure
- clients/terminal-android/SETUP.md — updated path references

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 18:09:24 +02:00
1df1b2bfca feat: scaffold Android terminal POS app (RewardFlow Terminal)
Some checks failed
CI / ruff (push) Successful in 25s
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 started running
Native Android tablet app for loyalty POS terminal. Replaces web
terminal for merchants who need device lockdown, camera QR scanning,
and offline operation.

Project at apps/terminal-android/ — Kotlin, Jetpack Compose, calls
the same /api/v1/store/loyalty/* API (no backend changes).

Scaffold includes:
- Gradle build (Kotlin DSL) with version catalog (libs.versions.toml)
- Hilt DI, Retrofit + Moshi networking, Room offline DB
- CameraX + ML Kit barcode scanning dependencies
- DataStore for device config persistence
- WorkManager for background sync
- Three-screen navigation: Setup → PIN → Terminal
- Stub screens with layout structure (ready to implement)
- API models matching all loyalty store endpoints
- PendingTransaction entity + DAO for offline queue
- RewardFlow brand theme (purple, light/dark)
- Landscape-only, fullscreen, Lock Task Mode ready
- SETUP.md with Android Studio installation guide
- .gitignore for Android build artifacts

Tech decisions:
- Min SDK 26 (Android 8.0, 95%+ tablet coverage)
- Firebase App Distribution for v1, Play Store later
- Staff PIN auth (no username/password on POS)
- One-time device setup via QR code from web settings

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 18:02:42 +02:00
51a2114e02 refactor(cms): migrate store theme UI from tenancy to CMS module
Move store theme admin pages, templates, and JS from tenancy module
to CMS module where the data layer (model, service, API, schemas)
already lives. Eliminates split ownership.

Moved:
- Route handlers: GET /store-themes, GET /stores/{code}/theme
- Templates: store-theme.html, store-themes.html
- JS: store-theme.js, store-themes.js
- Updated static references: tenancy_static → cms_static

Deleted old tenancy files (no remaining references).
Menu item in CMS definition already pointed to correct route.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 10:30:09 +02:00
21e4ac5124 docs(loyalty): update launch plan — Google Wallet already deployed
Some checks failed
CI / deploy (push) Has been skipped
CI / ruff (push) Successful in 25s
CI / pytest (push) Failing after 2h55m43s
CI / validate (push) Successful in 52s
CI / dependency-scanning (push) Successful in 56s
CI / docs (push) Has been skipped
Clarify Step 2: Google Wallet service account, Docker mount, and env
vars are already deployed on Hetzner (per Step 25 of server setup doc).
Only verification needed at deploy time.

Add Step 9 (post-launch): Google Wallet production access request.
Passes work in demo mode for test accounts at launch. Production
approval is a Google console step (1-3 business days, no code changes).
Google reviews the Issuer (platform), not individual merchants.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 23:04:22 +02:00
3ade1b9354 docs(loyalty): rewrite launch plan with step-by-step pre-launch checklist
Some checks failed
CI / pytest (push) Failing after 2h31m6s
CI / validate (push) Successful in 29s
CI / dependency-scanning (push) Successful in 33s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped
CI / ruff (push) Successful in 13s
Replace the old effort/critical-path sections with current status:
all dev phases 0-8 marked DONE with dates. Added a clear 8-step
pre-launch checklist (seed templates, deploy wallet certs, migrations,
translations, permissions, E2E testing, device test, go live) and a
post-launch roadmap table (Apple Wallet, marketing module, coverage,
trash UI, bulk PINs, cross-location enforcement).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 22:34:57 +02:00
b5bb9415f6 feat(cms): Phase A — page type selector, translation UI, SEO cleanup
Some checks failed
CI / ruff (push) Successful in 16s
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
Content page editor improvements:
- Page type selector: Content Page / Landing Page dropdown (sets template)
- Title language tabs: translate page titles per language (same pattern as sections)
- Content language tabs: translate page content per language
- Meta description language tabs: translatable SEO descriptions
- Template-driven section palette: template defines which sections are available
  (store landing pages hide Pricing, platform homepages show all)
- Hide content editor when Landing Page selected, hide sections when Content Page

Schema changes (migration cms_003):
- Add meta_description_translations column (JSON) to content_pages
- Drop meta_keywords column (obsolete, ignored by all search engines since 2009)
- Remove meta keywords tag from storefront and platform base templates

API + service updates:
- title_translations, content_translations, meta_description_translations
  added to create/update schemas, route handlers, and service methods

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 22:30:55 +02:00
bb3d6f0012 fix(loyalty): card detail — enrolled store name + copy buttons
Some checks failed
CI / pytest (push) Failing after 2h22m22s
CI / validate (push) Successful in 27s
CI / dependency-scanning (push) Successful in 31s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped
CI / ruff (push) Successful in 12s
- Fix "Enrolled at: Unknown" by resolving enrolled_at_store_name from
  the store service and adding it to CardDetailResponse schema.
- Add clipboard-copy buttons next to card number, customer name,
  email, and phone fields using the shared Utils.copyToClipboard()
  utility with toast feedback.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 22:31:53 +02:00
c92fe1261b fix(loyalty): use full pagination macro on card detail (match cards list)
Some checks failed
CI / ruff (push) Successful in 14s
CI / pytest (push) Has started running
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
Switch from pagination_simple to pagination — the same macro used on
the cards list page, with page number buttons and "Showing X-Y of Z".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 22:25:29 +02:00
ca152cd544 fix(loyalty): use shared pagination macro on card detail transactions
Some checks failed
CI / pytest (push) Has been cancelled
CI / ruff (push) Successful in 14s
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
Replace custom pagination with the shared pagination_simple macro
to match the cards list page pattern. Always shows "Showing X-Y of Z"
with Previous/Next — no longer hidden when only 1 page. Uses standard
Alpine.js pagination interface (pagination.page, totalPages, startIndex,
endIndex, pageNumbers, previousPage, nextPage, goToPage).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 22:18:59 +02:00
914967edcc feat(loyalty): add paginated transaction history to card detail
Some checks failed
CI / ruff (push) Successful in 15s
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
The store card detail page now shows paginated transaction history
instead of a flat list of 50. Uses PlatformSettings.getRowsPerPage()
for the page size (default 20), with Previous/Next navigation and
"Page X of Y" indicator using server-rendered i18n.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 22:13:00 +02:00
64fe58c171 fix(loyalty): normalize card id field, fix terminal redeem bug
Some checks failed
CI / ruff (push) Successful in 15s
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
The terminal redeem failed with "card not found: unknown" because
CardLookupResponse used card_id while CardDetailResponse (from
refreshCard) used id. After refresh, selectedCard.card_id was
undefined.

Fix: standardize on 'id' everywhere (the universal convention):
- CardLookupResponse: card_id → id
- _build_card_lookup_response: card_id= → id=
- loyalty-terminal.js: selectedCard.card_id → selectedCard.id (5 refs)
- Removed the card_id/model_validator approach as unnecessary

Also fixes Chart.js recursion error on analytics page (inline CDN
script instead of optional-libs.html include which caused infinite
template recursion in test context).

342 tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 22:01:26 +02:00
3044490a3e feat(storefront): section-based homepages, header action partials, fixes
Phase 1 — Section-based store homepages:
- Store defaults use template="full" with per-platform sections JSON
- OMS: shop hero + features + CTA; Loyalty: rewards hero + features + CTA
- Hosting: services hero + features + CTA
- Deep placeholder resolution for {{store_name}} inside sections JSON
- landing-full.html uses resolved page_sections from context

Phase 2 — Module-contributed header actions:
- header_template field on MenuItemDefinition + DiscoveredMenuItem
- Catalog provides header-search.html partial
- Cart provides header-cart.html partial with badge
- Base template iterates storefront_nav.actions with {% include %}
- Generic icon fallback for actions without a template

Fixes:
- Store theme API: get_store_by_code → get_store_by_code_or_subdomain

Docs:
- CMS redesign proposal: menu restructure, page types, translations UI

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 23:33:06 +02:00
adc36246b8 feat(storefront): homepage, module gating, widget protocol, i18n fixes
Some checks failed
CI / ruff (push) Successful in 14s
CI / pytest (push) Failing after 2h32m45s
CI / validate (push) Successful in 30s
CI / dependency-scanning (push) Successful in 31s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped
Storefront homepage & module gating:
- CMS owns storefront GET / (slug="home" with 3-tier resolution)
- Catalog loses GET / (keeps /products only)
- Store root redirect (GET / → /store/dashboard or /store/login)
- Route gating: non-core modules return 404 when disabled for platform
- Seed store default homepages per platform

Widget protocol for customer dashboard:
- StorefrontDashboardCard contract in widgets.py
- Widget aggregator get_storefront_dashboard_cards()
- Orders and Loyalty module widget providers
- Dashboard template renders contributed cards (no module names)

Landing template module-agnostic:
- CTAs driven by storefront_nav (not hardcoded module names)
- Header actions check nav item IDs (not enabled_modules)
- Remove hardcoded "Add Product" sidebar button
- Remove all enabled_modules checks from storefront templates

i18n fixes:
- Title placeholder resolution ({{store_name}}) for store default pages
- Storefront nav label_keys prefixed with module code
- Add storefront.account.* keys to 6 modules (en/fr/de/lb)
- Header/footer CMS pages use get_translated_title(current_language)
- Footer labels use i18n keys instead of hardcoded English

Icon cleanup:
- Standardize on map-pin (remove location-marker alias)
- Replace all location-marker references across templates and docs

Docs:
- Storefront builder vision proposal (6 phases)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 22:53:17 +02:00
dd9dc04328 feat(loyalty): add Chart.js visualizations to analytics page
Some checks failed
CI / pytest (push) Failing after 2h21m5s
CI / ruff (push) Successful in 13s
CI / validate (push) Successful in 27s
CI / dependency-scanning (push) Successful in 31s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped
Wire the Phase 7 analytics API endpoints into the store analytics
page with interactive visualizations:

- Revenue chart (Chart.js bar+line combo): monthly points earned as
  bars + active customers as line overlay with dual Y axes.
- At-risk members panel: ranked list of churning cards showing
  customer name and days inactive, with count badge.
- Cohort retention table: enrollment month rows × M0-M5 retention
  columns with color-coded percentage cells (green >60%, yellow
  >30%, red <30%).

Chart.js loaded on-demand via existing CDN loader with local fallback.
Data fetched in parallel via Promise.all for the 3 analytics endpoints.
All sections gracefully degrade to "not enough data" message when empty.

7 new i18n keys (EN only — FR/DE/LB translations to be added).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 23:30:36 +02:00
4a60d75a13 docs(loyalty): Phase 8 — runbooks, monitoring, OpenAPI tags, plan update
Some checks failed
CI / ruff (push) Successful in 12s
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / pytest (push) Has been cancelled
Final phase of the production launch plan:

- Runbook: wallet certificate management (Google + Apple rotation,
  expiry monitoring, rollback procedure)
- Runbook: point expiration task (manual execution, partial failure,
  per-merchant re-run, point restore via admin API)
- Runbook: wallet sync task (failed_card_ids interpretation, manual
  re-sync, retry behavior table)
- Monitoring: alert definitions (P0/P1/P2), key metrics, log events,
  dashboard suggestions
- OpenAPI: added tags=["Loyalty - Store"] and tags=["Loyalty - Admin"]
  to route groups for /docs discoverability
- Production launch plan: all phases 0-8 marked DONE

Coverage note: loyalty services at 70-85%, tasks at 16-29%.
Target 80% enforcement deferred — current 342 tests provide good
functional coverage. Task-level coverage requires Celery mocking
infrastructure (future sprint).

342 tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 23:07:50 +02:00
e98eddc168 feat(loyalty): Phase 7 — advanced analytics (cohort, churn, revenue)
New analytics_service.py with three analytics features:

- Cohort retention: groups cards by enrollment month, tracks % with
  any transaction in each subsequent month. Returns matrix suitable
  for Chart.js heatmap. GET /analytics/cohorts?months_back=6
- Churn detection: flags cards as "at risk" when inactive > 2x their
  average inter-transaction interval (default 60d for new cards).
  Returns ranked list. GET /analytics/churn?limit=50
- Revenue attribution: monthly and per-store aggregation of point-
  earning transactions. GET /analytics/revenue?months_back=6

Endpoints added to both admin API (/admin/loyalty/merchants/{id}/
analytics/*) and store API (/store/loyalty/analytics/*) so merchants
can see their own analytics.

342 tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 22:57:23 +02:00
8cd09f3f89 feat(loyalty): Phase 6 — admin GDPR, bulk ops, point restore, cascade
Some checks failed
CI / ruff (push) Successful in 15s
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 started running
Admin operations for production management:

- GDPR anonymization: DELETE /admin/loyalty/cards/customer/{id}
  Nulls customer_id, deactivates cards, scrubs PII from transaction
  notes. Keeps aggregate data for reporting.
- Bulk deactivate: POST /admin/loyalty/merchants/{id}/cards/bulk/deactivate
  and POST /store/loyalty/cards/bulk/deactivate (merchant_owner only).
  Deactivates multiple cards with audit trail.
- Point restore: POST /admin/loyalty/cards/{id}/restore-points
  Creates ADMIN_ADJUSTMENT transaction with positive delta. Reuses
  existing adjust_points service method.
- Cascade restore: POST /admin/loyalty/merchants/{id}/restore-deleted
  Restores all soft-deleted programs and cards for a merchant.

Service methods: anonymize_cards_for_customer, bulk_deactivate_cards,
restore_deleted_cards, restore_deleted_programs.

342 tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 22:40:34 +02:00
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
fde58bea06 perf(loyalty): Phase 3 — batched expiration + wallet sync backoff
Some checks failed
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / validate (push) Has been cancelled
CI / ruff (push) Successful in 12s
CI / dependency-scanning (push) Has been cancelled
CI / pytest (push) Has started running
Phase 3 of the production launch plan: task reliability improvements
to prevent DB lock issues at scale and handle transient wallet API
failures gracefully.

- 3.1 Batched point expiration: rewrite per-card Python loop to chunked
  processing (LIMIT 500 FOR UPDATE SKIP LOCKED). Each chunk commits
  independently, releasing row locks before processing the next batch.
  Notifications sent after commit (outside lock window). Warning emails
  also chunked with same pattern.
- 3.2 Wallet sync exponential backoff: replace time.sleep(2) single
  retry with 4 attempts using [1s, 4s, 16s] backoff delays. Per-card
  try/except ensures one failing card doesn't block the batch.
  Failed card IDs logged for observability.

342 tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 19:55:39 +02:00
52b78ce346 feat(loyalty): Phase 2A — transactional email notifications
Some checks failed
CI / ruff (push) Successful in 13s
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 async email notifications for 5 loyalty lifecycle events, using
the existing messaging module infrastructure (EmailService, EmailLog,
store template overrides).

- New seed script: scripts/seed/seed_email_templates_loyalty.py
  Seeds 5 templates × 4 locales (en/fr/de/lb) = 20 rows. Idempotent.
  Renamed existing script to seed_email_templates_core.py.
- Celery task: loyalty.send_notification_email — async dispatch with
  3 retries and 60s backoff. Opens own DB session.
- Notification service: LoyaltyNotificationService with 5 methods
  that resolve customer/card/program into template variables and
  enqueue via Celery (never blocks request handlers).
- Enrollment: sends loyalty_enrollment + loyalty_welcome_bonus (if
  bonus > 0) after card creation commit.
- Stamps: sends loyalty_reward_ready when stamp target reached.
- Expiration task: sends loyalty_points_expiring 14 days before expiry
  (tracked via new last_expiration_warning_at column to prevent dupes),
  and loyalty_points_expired after points are zeroed.
- Migration loyalty_005: adds last_expiration_warning_at to cards.
- 8 new unit tests for notification service dispatch.
- Fix: rate limiter autouse fixture in integration tests to prevent
  state bleed between tests.

Templates: loyalty_enrollment, loyalty_welcome_bonus,
loyalty_points_expiring, loyalty_points_expired, loyalty_reward_ready.
All support store-level overrides via the existing email template UI.

Birthday + re-engagement emails deferred to future marketing module
(cross-platform: OMS, loyalty, hosting).

342 tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 19:11:56 +02:00
168 changed files with 10888 additions and 4183 deletions

View File

@@ -1744,3 +1744,39 @@ def get_current_customer_optional(
except Exception:
# Invalid token, store mismatch, or other error
return None
# =============================================================================
# STOREFRONT MODULE GATING
# =============================================================================
def make_storefront_module_gate(module_code: str):
"""
Create a FastAPI dependency that gates storefront routes by module enablement.
Used by main.py at route registration time: each non-core module's storefront
router gets this dependency injected automatically. The framework already knows
which module owns each route via RouteInfo.module_code — no hardcoded path map.
Args:
module_code: The module code to check (e.g. "catalog", "orders", "loyalty")
Returns:
A FastAPI dependency function
"""
async def _check_module_enabled(
request: Request,
db: Session = Depends(get_db),
) -> None:
from app.modules.service import module_service
platform = getattr(request.state, "platform", None)
if not platform:
return # No platform context — let other middleware handle it
if not module_service.is_module_enabled(db, platform.id, module_code):
raise HTTPException(status_code=404, detail="Page not found")
return _check_module_enabled

View File

@@ -143,7 +143,7 @@
</div>
<div class="flex items-center">
<div class="p-3 mr-4 text-orange-500 bg-orange-100 rounded-full dark:text-orange-100 dark:bg-orange-500">
<span x-html="$icon('location-marker', 'w-6 h-6')"></span>
<span x-html="$icon('map-pin', 'w-6 h-6')"></span>
</div>
<div>
<p class="text-2xl font-bold text-gray-700 dark:text-gray-200" x-text="formatNumber(analytics.inventory?.total_locations || 0)"></p>

View File

@@ -95,6 +95,7 @@ class MenuItemDefinition:
requires_permission: str | None = None
badge_source: str | None = None
is_super_admin_only: bool = False
header_template: str | None = None # Optional partial for custom header rendering
@dataclass

View File

@@ -68,10 +68,11 @@ cart_module = ModuleDefinition(
items=[
MenuItemDefinition(
id="cart",
label_key="storefront.actions.cart",
label_key="cart.storefront.actions.cart",
icon="shopping-cart",
route="cart",
order=20,
header_template="cart/storefront/partials/header-cart.html",
),
],
),

View File

@@ -44,5 +44,10 @@
"view_desc": "Warenkörbe der Kunden anzeigen",
"manage": "Warenkörbe verwalten",
"manage_desc": "Warenkörbe der Kunden bearbeiten und verwalten"
},
"storefront": {
"actions": {
"cart": "Warenkorb"
}
}
}

View File

@@ -1,48 +1,53 @@
{
"title": "Shopping Cart",
"description": "Shopping cart management for customers",
"cart": {
"title": "Your Cart",
"empty": "Your cart is empty",
"empty_subtitle": "Add items to start shopping",
"continue_shopping": "Continue Shopping",
"proceed_to_checkout": "Proceed to Checkout"
},
"item": {
"product": "Product",
"quantity": "Quantity",
"price": "Price",
"total": "Total",
"remove": "Remove",
"update": "Update"
},
"summary": {
"title": "Order Summary",
"subtotal": "Subtotal",
"shipping": "Shipping",
"estimated_shipping": "Calculated at checkout",
"tax": "Tax",
"total": "Total"
},
"validation": {
"invalid_quantity": "Invalid quantity",
"min_quantity": "Minimum quantity is {min}",
"max_quantity": "Maximum quantity is {max}",
"insufficient_inventory": "Only {available} available"
},
"permissions": {
"view": "View Carts",
"view_desc": "View customer shopping carts",
"manage": "Manage Carts",
"manage_desc": "Modify and manage customer carts"
},
"messages": {
"item_added": "Item added to cart",
"item_updated": "Cart updated",
"item_removed": "Item removed from cart",
"cart_cleared": "Cart cleared",
"product_not_available": "Product not available",
"error_adding": "Error adding item to cart",
"error_updating": "Error updating cart"
}
"title": "Shopping Cart",
"description": "Shopping cart management for customers",
"cart": {
"title": "Your Cart",
"empty": "Your cart is empty",
"empty_subtitle": "Add items to start shopping",
"continue_shopping": "Continue Shopping",
"proceed_to_checkout": "Proceed to Checkout"
},
"item": {
"product": "Product",
"quantity": "Quantity",
"price": "Price",
"total": "Total",
"remove": "Remove",
"update": "Update"
},
"summary": {
"title": "Order Summary",
"subtotal": "Subtotal",
"shipping": "Shipping",
"estimated_shipping": "Calculated at checkout",
"tax": "Tax",
"total": "Total"
},
"validation": {
"invalid_quantity": "Invalid quantity",
"min_quantity": "Minimum quantity is {min}",
"max_quantity": "Maximum quantity is {max}",
"insufficient_inventory": "Only {available} available"
},
"permissions": {
"view": "View Carts",
"view_desc": "View customer shopping carts",
"manage": "Manage Carts",
"manage_desc": "Modify and manage customer carts"
},
"messages": {
"item_added": "Item added to cart",
"item_updated": "Cart updated",
"item_removed": "Item removed from cart",
"cart_cleared": "Cart cleared",
"product_not_available": "Product not available",
"error_adding": "Error adding item to cart",
"error_updating": "Error updating cart"
},
"storefront": {
"actions": {
"cart": "Cart"
}
}
}

View File

@@ -44,5 +44,10 @@
"view_desc": "Voir les paniers des clients",
"manage": "Gérer les paniers",
"manage_desc": "Modifier et gérer les paniers des clients"
},
"storefront": {
"actions": {
"cart": "Panier"
}
}
}

View File

@@ -44,5 +44,10 @@
"view_desc": "Clientekuerf kucken",
"manage": "Kuerf verwalten",
"manage_desc": "Clientekuerf änneren a verwalten"
},
"storefront": {
"actions": {
"cart": "Kuerf"
}
}
}

View File

@@ -0,0 +1,10 @@
{# cart/storefront/partials/header-cart.html #}
{# Cart icon with badge for storefront header — provided by cart module #}
<a href="{{ base_url }}cart" class="relative p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700">
<span class="w-5 h-5" x-html="$icon('shopping-cart', 'w-5 h-5')"></span>
<span x-show="cartCount > 0"
x-text="cartCount"
class="absolute -top-1 -right-1 bg-accent text-white text-xs rounded-full h-5 w-5 flex items-center justify-center"
style="background-color: var(--color-accent)">
</span>
</a>

View File

@@ -134,7 +134,7 @@ catalog_module = ModuleDefinition(
items=[
MenuItemDefinition(
id="products",
label_key="storefront.nav.products",
label_key="catalog.storefront.nav.products",
icon="shopping-bag",
route="products",
order=10,
@@ -148,10 +148,11 @@ catalog_module = ModuleDefinition(
items=[
MenuItemDefinition(
id="search",
label_key="storefront.actions.search",
label_key="catalog.storefront.actions.search",
icon="search",
route="",
order=10,
header_template="catalog/storefront/partials/header-search.html",
),
],
),

View File

@@ -89,5 +89,13 @@
"products_import_desc": "Massenimport von Produkten",
"products_export": "Produkte exportieren",
"products_export_desc": "Produktdaten exportieren"
},
"storefront": {
"nav": {
"products": "Produkte"
},
"actions": {
"search": "Suchen"
}
}
}

View File

@@ -107,5 +107,13 @@
"menu": {
"products_inventory": "Products & Inventory",
"all_products": "All Products"
},
"storefront": {
"nav": {
"products": "Products"
},
"actions": {
"search": "Search"
}
}
}

View File

@@ -89,5 +89,13 @@
"products_import_desc": "Importation en masse de produits",
"products_export": "Exporter les produits",
"products_export_desc": "Exporter les données produits"
},
"storefront": {
"nav": {
"products": "Produits"
},
"actions": {
"search": "Rechercher"
}
}
}

View File

@@ -89,5 +89,13 @@
"products_import_desc": "Massenimport vu Produiten",
"products_export": "Produiten exportéieren",
"products_export_desc": "Produitdaten exportéieren"
},
"storefront": {
"nav": {
"products": "Produkter"
},
"actions": {
"search": "Sichen"
}
}
}

View File

@@ -30,11 +30,10 @@ router = APIRouter()
# ============================================================================
@router.get("/", response_class=HTMLResponse, include_in_schema=False)
@router.get("/products", response_class=HTMLResponse, include_in_schema=False)
async def shop_products_page(request: Request, db: Session = Depends(get_db)):
"""
Render shop homepage / product catalog.
Render product catalog listing.
Shows featured products and categories.
"""
logger.debug(

View File

@@ -0,0 +1,5 @@
{# catalog/storefront/partials/header-search.html #}
{# Search button for storefront header — provided by catalog module #}
<button @click="openSearch()" class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700">
<span class="w-5 h-5" x-html="$icon('search', 'w-5 h-5')"></span>
</button>

View File

@@ -388,5 +388,15 @@
},
"confirmations": {
"delete_file": "Sind Sie sicher, dass Sie diese Datei löschen möchten? Dies kann nicht rückgängig gemacht werden."
},
"storefront": {
"my_account": "Mein Konto",
"learn_more": "Mehr erfahren",
"explore": "Entdecken",
"quick_links": "Schnellzugriff",
"information": "Informationen",
"about": "Über uns",
"contact": "Kontakt",
"faq": "FAQ"
}
}

View File

@@ -388,5 +388,15 @@
"content_pages": "Content Pages",
"store_themes": "Store Themes",
"media_library": "Media Library"
},
"storefront": {
"my_account": "My Account",
"learn_more": "Learn More",
"explore": "Explore",
"quick_links": "Quick Links",
"information": "Information",
"about": "About Us",
"contact": "Contact",
"faq": "FAQ"
}
}

View File

@@ -388,5 +388,15 @@
},
"confirmations": {
"delete_file": "Êtes-vous sûr de vouloir supprimer ce fichier ? Cette action est irréversible."
},
"storefront": {
"my_account": "Mon Compte",
"learn_more": "En savoir plus",
"explore": "Découvrir",
"quick_links": "Liens rapides",
"information": "Informations",
"about": "À propos",
"contact": "Contact",
"faq": "FAQ"
}
}

View File

@@ -388,5 +388,15 @@
},
"confirmations": {
"delete_file": "Sidd Dir sécher datt Dir dëse Fichier läsche wëllt? Dat kann net réckgängeg gemaach ginn."
},
"storefront": {
"my_account": "Mäi Kont",
"learn_more": "Méi gewuer ginn",
"explore": "Entdecken",
"quick_links": "Schnellzougrëff",
"information": "Informatiounen",
"about": "Iwwer eis",
"contact": "Kontakt",
"faq": "FAQ"
}
}

View File

@@ -0,0 +1,36 @@
"""add meta_description_translations and drop meta_keywords from content_pages
Revision ID: cms_003
Revises: cms_002
Create Date: 2026-04-15
"""
import sqlalchemy as sa
from alembic import op
revision = "cms_003"
down_revision = "cms_002"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"content_pages",
sa.Column(
"meta_description_translations",
sa.JSON(),
nullable=True,
comment="Language-keyed meta description dict for multi-language SEO",
),
)
op.drop_column("content_pages", "meta_keywords")
def downgrade() -> None:
op.add_column(
"content_pages",
sa.Column("meta_keywords", sa.String(300), nullable=True),
)
op.drop_column("content_pages", "meta_description_translations")

View File

@@ -135,7 +135,12 @@ class ContentPage(Base):
# SEO
meta_description = Column(String(300), nullable=True)
meta_keywords = Column(String(300), nullable=True)
meta_description_translations = Column(
JSON,
nullable=True,
default=None,
comment="Language-keyed meta description dict for multi-language SEO",
)
# Publishing
is_published = Column(Boolean, default=False, nullable=False)
@@ -230,6 +235,16 @@ class ContentPage(Base):
)
return self.content
def get_translated_meta_description(self, lang: str, default_lang: str = "fr") -> str:
"""Get meta description in the given language, falling back to default_lang then self.meta_description."""
if self.meta_description_translations:
return (
self.meta_description_translations.get(lang)
or self.meta_description_translations.get(default_lang)
or self.meta_description or ""
)
return self.meta_description or ""
def to_dict(self):
"""Convert to dictionary for API responses."""
return {
@@ -248,7 +263,7 @@ class ContentPage(Base):
"template": self.template,
"sections": self.sections,
"meta_description": self.meta_description,
"meta_keywords": self.meta_keywords,
"meta_description_translations": self.meta_description_translations,
"is_published": self.is_published,
"published_at": (
self.published_at.isoformat() if self.published_at else None

View File

@@ -73,7 +73,7 @@ def create_platform_page(
content_format=page_data.content_format,
template=page_data.template,
meta_description=page_data.meta_description,
meta_keywords=page_data.meta_keywords,
meta_description_translations=page_data.meta_description_translations,
is_published=page_data.is_published,
show_in_footer=page_data.show_in_footer,
show_in_header=page_data.show_in_header,
@@ -117,7 +117,7 @@ def create_store_page(
content_format=page_data.content_format,
template=page_data.template,
meta_description=page_data.meta_description,
meta_keywords=page_data.meta_keywords,
meta_description_translations=page_data.meta_description_translations,
is_published=page_data.is_published,
show_in_footer=page_data.show_in_footer,
show_in_header=page_data.show_in_header,
@@ -177,11 +177,13 @@ def update_page(
db,
page_id=page_id,
title=page_data.title,
title_translations=page_data.title_translations,
content=page_data.content,
content_translations=page_data.content_translations,
content_format=page_data.content_format,
template=page_data.template,
meta_description=page_data.meta_description,
meta_keywords=page_data.meta_keywords,
meta_description_translations=page_data.meta_description_translations,
is_published=page_data.is_published,
show_in_footer=page_data.show_in_footer,
show_in_header=page_data.show_in_header,

View File

@@ -207,7 +207,7 @@ def create_store_page(
store_id=current_user.token_store_id,
content_format=page_data.content_format,
meta_description=page_data.meta_description,
meta_keywords=page_data.meta_keywords,
meta_description_translations=getattr(page_data, "meta_description_translations", None),
is_published=page_data.is_published,
show_in_footer=page_data.show_in_footer,
show_in_header=page_data.show_in_header,
@@ -241,7 +241,7 @@ def update_store_page(
content=page_data.content,
content_format=page_data.content_format,
meta_description=page_data.meta_description,
meta_keywords=page_data.meta_keywords,
meta_description_translations=getattr(page_data, "meta_description_translations", None),
is_published=page_data.is_published,
show_in_footer=page_data.show_in_footer,
show_in_header=page_data.show_in_header,

View File

@@ -2,7 +2,8 @@
"""
CMS Admin Page Routes (HTML rendering).
Admin pages for managing platform and store content pages.
Admin pages for managing platform and store content pages,
and store theme customization.
"""
from fastapi import APIRouter, Depends, Path, Request
@@ -10,6 +11,7 @@ from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session
from app.api.deps import get_db, require_menu_access
from app.modules.core.utils.page_context import get_admin_context
from app.modules.enums import FrontendType
from app.modules.tenancy.models import User
from app.templates_config import templates
@@ -86,3 +88,49 @@ async def admin_content_page_edit(
"page_id": page_id,
},
)
# ============================================================================
# STORE THEMES
# ============================================================================
@router.get("/store-themes", response_class=HTMLResponse, include_in_schema=False)
async def admin_store_themes_page(
request: Request,
current_user: User = Depends(
require_menu_access("store-themes", FrontendType.ADMIN)
),
db: Session = Depends(get_db),
):
"""
Render store themes selection page.
Allows admins to select a store to customize their theme.
"""
return templates.TemplateResponse(
"cms/admin/store-themes.html",
get_admin_context(request, db, current_user),
)
@router.get(
"/stores/{store_code}/theme",
response_class=HTMLResponse,
include_in_schema=False,
)
async def admin_store_theme_page(
request: Request,
store_code: str = Path(..., description="Store code"),
current_user: User = Depends(
require_menu_access("store-themes", FrontendType.ADMIN)
),
db: Session = Depends(get_db),
):
"""
Render store theme customization page.
Allows admins to customize colors, fonts, layout, and branding.
"""
return templates.TemplateResponse(
"cms/admin/store-theme.html",
get_admin_context(request, db, current_user, store_code=store_code),
)

View File

@@ -28,6 +28,79 @@ ROUTE_CONFIG = {
}
# ============================================================================
# STOREFRONT HOMEPAGE
# ============================================================================
@router.get("/", response_class=HTMLResponse, include_in_schema=False)
async def storefront_homepage(
request: Request,
db: Session = Depends(get_db),
):
"""
Storefront homepage handler.
Looks for a CMS page with slug="home" (store override → store default),
and renders the appropriate landing template. Falls back to the default
landing template when no CMS homepage exists.
"""
store = getattr(request.state, "store", None)
platform = getattr(request.state, "platform", None)
store_id = store.id if store else None
if not platform:
raise HTTPException(status_code=400, detail="Platform context required")
# Try to load a homepage from CMS (store override → store default)
page = content_page_service.get_page_for_store(
db,
platform_id=platform.id,
slug="home",
store_id=store_id,
include_unpublished=False,
)
# Resolve placeholders for store default pages (title, content, sections)
page_content = None
page_title = None
page_sections = None
if page:
page_content = page.content
page_title = page.title
page_sections = page.sections
if page.is_store_default and store:
page_content = content_page_service.resolve_placeholders(
page.content, store
)
page_title = content_page_service.resolve_placeholders(
page.title, store
)
if page_sections:
page_sections = content_page_service.resolve_placeholders_deep(
page_sections, store
)
context = get_storefront_context(request, db=db, page=page)
if page_content:
context["page_content"] = page_content
if page_title:
context["page_title"] = page_title
if page_sections:
context["page_sections"] = page_sections
# Select template based on page.template field (or default)
template_map = {
"full": "cms/storefront/landing-full.html",
"modern": "cms/storefront/landing-modern.html",
"minimal": "cms/storefront/landing-minimal.html",
}
template_name = "cms/storefront/landing-default.html"
if page and page.template:
template_name = template_map.get(page.template, template_name)
return templates.TemplateResponse(template_name, context)
# ============================================================================
# DYNAMIC CONTENT PAGES (CMS)
# ============================================================================
@@ -103,10 +176,13 @@ async def generic_content_page(
# Resolve placeholders in store default pages ({{store_name}}, etc.)
page_content = page.content
page_title = page.title
if page.is_store_default and store:
page_content = content_page_service.resolve_placeholders(page.content, store)
page_title = content_page_service.resolve_placeholders(page.title, store)
context = get_storefront_context(request, db=db, page=page)
context["page_title"] = page_title
context["page_content"] = page_content
# Select template based on page.template field

View File

@@ -24,19 +24,27 @@ class ContentPageCreate(BaseModel):
description="URL-safe identifier (about, faq, contact, etc.)",
)
title: str = Field(..., max_length=200, description="Page title")
title_translations: dict[str, str] | None = Field(
None, description="Title translations keyed by language code"
)
content: str = Field(..., description="HTML or Markdown content")
content_translations: dict[str, str] | None = Field(
None, description="Content translations keyed by language code"
)
content_format: str = Field(
default="html", description="Content format: html or markdown"
)
template: str = Field(
default="default",
max_length=50,
description="Template name (default, minimal, modern)",
description="Template name (default, minimal, modern, full)",
)
meta_description: str | None = Field(
None, max_length=300, description="SEO meta description"
)
meta_keywords: str | None = Field(None, max_length=300, description="SEO keywords")
meta_description_translations: dict[str, str] | None = Field(
None, description="Meta description translations keyed by language code"
)
is_published: bool = Field(default=False, description="Publish immediately")
show_in_footer: bool = Field(default=True, description="Show in footer navigation")
show_in_header: bool = Field(default=False, description="Show in header navigation")
@@ -53,11 +61,13 @@ class ContentPageUpdate(BaseModel):
"""Schema for updating a content page (admin)."""
title: str | None = Field(None, max_length=200)
title_translations: dict[str, str] | None = None
content: str | None = None
content_translations: dict[str, str] | None = None
content_format: str | None = None
template: str | None = Field(None, max_length=50)
meta_description: str | None = Field(None, max_length=300)
meta_keywords: str | None = Field(None, max_length=300)
meta_description_translations: dict[str, str] | None = None
is_published: bool | None = None
show_in_footer: bool | None = None
show_in_header: bool | None = None
@@ -78,11 +88,13 @@ class ContentPageResponse(BaseModel):
store_name: str | None
slug: str
title: str
title_translations: dict[str, str] | None = None
content: str
content_translations: dict[str, str] | None = None
content_format: str
template: str | None = None
meta_description: str | None
meta_keywords: str | None
meta_description_translations: dict[str, str] | None = None
is_published: bool
published_at: str | None
display_order: int
@@ -135,7 +147,6 @@ class StoreContentPageCreate(BaseModel):
meta_description: str | None = Field(
None, max_length=300, description="SEO meta description"
)
meta_keywords: str | None = Field(None, max_length=300, description="SEO keywords")
is_published: bool = Field(default=False, description="Publish immediately")
show_in_footer: bool = Field(default=True, description="Show in footer navigation")
show_in_header: bool = Field(default=False, description="Show in header navigation")
@@ -152,7 +163,6 @@ class StoreContentPageUpdate(BaseModel):
content: str | None = None
content_format: str | None = None
meta_description: str | None = Field(None, max_length=300)
meta_keywords: str | None = Field(None, max_length=300)
is_published: bool | None = None
show_in_footer: bool | None = None
show_in_header: bool | None = None
@@ -187,7 +197,6 @@ class PublicContentPageResponse(BaseModel):
content: str
content_format: str
meta_description: str | None
meta_keywords: str | None
published_at: str | None

View File

@@ -24,6 +24,7 @@ Lookup Strategy for Store Storefronts:
import logging
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import and_
from sqlalchemy.orm import Session
@@ -472,7 +473,7 @@ class ContentPageService:
content_format: str = "html",
template: str = "default",
meta_description: str | None = None,
meta_keywords: str | None = None,
meta_description_translations: str | None = None,
is_published: bool = False,
show_in_footer: bool = True,
show_in_header: bool = False,
@@ -494,7 +495,7 @@ class ContentPageService:
content_format: "html" or "markdown"
template: Template name for landing pages
meta_description: SEO description
meta_keywords: SEO keywords
meta_description_translations: Meta description translations dict
is_published: Publish immediately
show_in_footer: Show in footer navigation
show_in_header: Show in header navigation
@@ -515,7 +516,7 @@ class ContentPageService:
content_format=content_format,
template=template,
meta_description=meta_description,
meta_keywords=meta_keywords,
meta_description_translations=meta_description_translations,
is_published=is_published,
published_at=datetime.now(UTC) if is_published else None,
show_in_footer=show_in_footer,
@@ -541,11 +542,13 @@ class ContentPageService:
db: Session,
page_id: int,
title: str | None = None,
title_translations: dict[str, str] | None = None,
content: str | None = None,
content_translations: dict[str, str] | None = None,
content_format: str | None = None,
template: str | None = None,
meta_description: str | None = None,
meta_keywords: str | None = None,
meta_description_translations: str | None = None,
is_published: bool | None = None,
show_in_footer: bool | None = None,
show_in_header: bool | None = None,
@@ -573,16 +576,20 @@ class ContentPageService:
# Update fields if provided
if title is not None:
page.title = title
if title_translations is not None:
page.title_translations = title_translations
if content is not None:
page.content = content
if content_translations is not None:
page.content_translations = content_translations
if content_format is not None:
page.content_format = content_format
if template is not None:
page.template = template
if meta_description is not None:
page.meta_description = meta_description
if meta_keywords is not None:
page.meta_keywords = meta_keywords
if meta_description_translations is not None:
page.meta_description_translations = meta_description_translations
if is_published is not None:
page.is_published = is_published
if is_published and not page.published_at:
@@ -698,7 +705,7 @@ class ContentPageService:
content: str | None = None,
content_format: str | None = None,
meta_description: str | None = None,
meta_keywords: str | None = None,
meta_description_translations: str | None = None,
is_published: bool | None = None,
show_in_footer: bool | None = None,
show_in_header: bool | None = None,
@@ -725,7 +732,7 @@ class ContentPageService:
content=content,
content_format=content_format,
meta_description=meta_description,
meta_keywords=meta_keywords,
meta_description_translations=meta_description_translations,
is_published=is_published,
show_in_footer=show_in_footer,
show_in_header=show_in_header,
@@ -760,7 +767,7 @@ class ContentPageService:
content: str,
content_format: str = "html",
meta_description: str | None = None,
meta_keywords: str | None = None,
meta_description_translations: str | None = None,
is_published: bool = False,
show_in_footer: bool = True,
show_in_header: bool = False,
@@ -791,7 +798,7 @@ class ContentPageService:
is_platform_page=False,
content_format=content_format,
meta_description=meta_description,
meta_keywords=meta_keywords,
meta_description_translations=meta_description_translations,
is_published=is_published,
show_in_footer=show_in_footer,
show_in_header=show_in_header,
@@ -913,11 +920,13 @@ class ContentPageService:
db: Session,
page_id: int,
title: str | None = None,
title_translations: dict[str, str] | None = None,
content: str | None = None,
content_translations: dict[str, str] | None = None,
content_format: str | None = None,
template: str | None = None,
meta_description: str | None = None,
meta_keywords: str | None = None,
meta_description_translations: str | None = None,
is_published: bool | None = None,
show_in_footer: bool | None = None,
show_in_header: bool | None = None,
@@ -935,11 +944,13 @@ class ContentPageService:
db,
page_id=page_id,
title=title,
title_translations=title_translations,
content=content,
content_translations=content_translations,
content_format=content_format,
template=template,
meta_description=meta_description,
meta_keywords=meta_keywords,
meta_description_translations=meta_description_translations,
is_published=is_published,
show_in_footer=show_in_footer,
show_in_header=show_in_header,
@@ -991,6 +1002,28 @@ class ContentPageService:
content = content.replace(placeholder, value)
return content
@staticmethod
def resolve_placeholders_deep(data, store) -> Any:
"""
Recursively resolve {{store_name}} etc. in a nested data structure
(dicts, lists, strings). Used for sections JSON in store default pages.
"""
if not data or not store:
return data
if isinstance(data, str):
return ContentPageService.resolve_placeholders(data, store)
if isinstance(data, dict):
return {
k: ContentPageService.resolve_placeholders_deep(v, store)
for k, v in data.items()
}
if isinstance(data, list):
return [
ContentPageService.resolve_placeholders_deep(item, store)
for item in data
]
return data
# =========================================================================
# Homepage Sections Management
# =========================================================================

View File

@@ -70,7 +70,7 @@ class StoreThemeService:
"""
from app.modules.tenancy.services.store_service import store_service
store = store_service.get_store_by_code(db, store_code)
store = store_service.get_store_by_code_or_subdomain(db, store_code)
if not store:
self.logger.warning(f"Store not found: {store_code}")

View File

@@ -20,11 +20,13 @@ function contentPageEditor(pageId) {
form: {
slug: '',
title: '',
title_translations: {},
content: '',
content_translations: {},
content_format: 'html',
template: 'default',
meta_description: '',
meta_keywords: '',
meta_description_translations: {},
is_published: false,
show_in_header: false,
show_in_footer: true,
@@ -42,6 +44,12 @@ function contentPageEditor(pageId) {
error: null,
successMessage: null,
// Page type: 'content' or 'landing'
pageType: 'content',
// Translation language for title/content
titleContentLang: 'fr',
// ========================================
// HOMEPAGE SECTIONS STATE
// ========================================
@@ -56,6 +64,13 @@ function contentPageEditor(pageId) {
de: 'Deutsch',
lb: 'Lëtzebuergesch'
},
// Template-driven section palette
sectionPalette: {
'default': ['hero', 'features', 'products', 'pricing', 'testimonials', 'gallery', 'contact_info', 'cta'],
'full': ['hero', 'features', 'testimonials', 'gallery', 'contact_info', 'cta'],
},
sections: {
hero: {
enabled: true,
@@ -108,8 +123,8 @@ function contentPageEditor(pageId) {
await this.loadPage();
contentPageEditLog.groupEnd();
// Load sections if this is a homepage
if (this.form.slug === 'home') {
// Load sections if this is a landing page
if (this.pageType === 'landing') {
await this.loadSections();
}
} else {
@@ -120,14 +135,86 @@ function contentPageEditor(pageId) {
contentPageEditLog.info('=== CONTENT PAGE EDITOR INITIALIZATION COMPLETE ===');
},
// Check if we should show section editor (property, not getter for Alpine compatibility)
// Check if we should show section editor
isHomepage: false,
// Update isHomepage when slug changes
// Is a section available for the current template?
isSectionAvailable(sectionName) {
const palette = this.sectionPalette[this.form.template] || this.sectionPalette['full'];
return palette.includes(sectionName);
},
// Update homepage state
updateIsHomepage() {
this.isHomepage = this.form.slug === 'home';
},
// Update template when page type changes
updatePageType() {
if (this.pageType === 'landing') {
this.form.template = 'full';
// Load sections if editing and not yet loaded
if (this.pageId && !this.sectionsLoaded) {
this.loadSections();
}
} else {
this.form.template = 'default';
}
this.updateIsHomepage();
},
// ========================================
// TITLE/CONTENT TRANSLATION HELPERS
// ========================================
getTranslatedTitle() {
if (this.titleContentLang === this.defaultLanguage) {
return this.form.title;
}
return (this.form.title_translations || {})[this.titleContentLang] || '';
},
setTranslatedTitle(value) {
if (this.titleContentLang === this.defaultLanguage) {
this.form.title = value;
} else {
if (!this.form.title_translations) this.form.title_translations = {};
this.form.title_translations[this.titleContentLang] = value;
}
},
getTranslatedContent() {
if (this.titleContentLang === this.defaultLanguage) {
return this.form.content;
}
return (this.form.content_translations || {})[this.titleContentLang] || '';
},
setTranslatedContent(value) {
if (this.titleContentLang === this.defaultLanguage) {
this.form.content = value;
} else {
if (!this.form.content_translations) this.form.content_translations = {};
this.form.content_translations[this.titleContentLang] = value;
}
},
getTranslatedMetaDescription() {
if (this.titleContentLang === this.defaultLanguage) {
return this.form.meta_description;
}
return (this.form.meta_description_translations || {})[this.titleContentLang] || '';
},
setTranslatedMetaDescription(value) {
if (this.titleContentLang === this.defaultLanguage) {
this.form.meta_description = value;
} else {
if (!this.form.meta_description_translations) this.form.meta_description_translations = {};
this.form.meta_description_translations[this.titleContentLang] = value;
}
},
// Load platforms for dropdown
async loadPlatforms() {
this.loadingPlatforms = true;
@@ -188,11 +275,13 @@ function contentPageEditor(pageId) {
this.form = {
slug: page.slug || '',
title: page.title || '',
title_translations: page.title_translations || {},
content: page.content || '',
content_translations: page.content_translations || {},
content_format: page.content_format || 'html',
template: page.template || 'default',
meta_description: page.meta_description || '',
meta_keywords: page.meta_keywords || '',
meta_description_translations: page.meta_description_translations || {},
is_published: page.is_published || false,
show_in_header: page.show_in_header || false,
show_in_footer: page.show_in_footer !== undefined ? page.show_in_footer : true,
@@ -202,6 +291,9 @@ function contentPageEditor(pageId) {
store_id: page.store_id
};
// Set page type from template
this.pageType = (this.form.template === 'full') ? 'landing' : 'content';
contentPageEditLog.info('Page loaded successfully');
// Update computed properties after loading
@@ -240,24 +332,25 @@ function contentPageEditor(pageId) {
},
// ========================================
// HOMEPAGE SECTIONS METHODS
// SECTIONS METHODS
// ========================================
// Load sections for homepage
// Load sections for landing pages
async loadSections() {
if (!this.pageId || this.form.slug !== 'home') {
contentPageEditLog.debug('Skipping section load - not a homepage');
if (!this.pageId || this.pageType !== 'landing') {
contentPageEditLog.debug('Skipping section load - not a landing page');
return;
}
try {
contentPageEditLog.info('Loading homepage sections...');
contentPageEditLog.info('Loading sections...');
const response = await apiClient.get(`/admin/content-pages/${this.pageId}/sections`);
const data = response.data || response;
this.supportedLanguages = data.supported_languages || ['fr', 'de', 'en'];
this.defaultLanguage = data.default_language || 'fr';
this.currentLang = this.defaultLanguage;
this.titleContentLang = this.defaultLanguage;
if (data.sections) {
this.sections = this.mergeWithDefaults(data.sections);
@@ -277,12 +370,18 @@ function contentPageEditor(pageId) {
mergeWithDefaults(loadedSections) {
const defaults = this.getDefaultSectionStructure();
// Deep merge each section
for (const key of ['hero', 'features', 'pricing', 'cta']) {
// Deep merge each section that exists in defaults
for (const key of Object.keys(defaults)) {
if (loadedSections[key]) {
defaults[key] = { ...defaults[key], ...loadedSections[key] };
}
}
// Also preserve any extra sections from loaded data
for (const key of Object.keys(loadedSections)) {
if (!defaults[key]) {
defaults[key] = loadedSections[key];
}
}
return defaults;
},
@@ -375,7 +474,7 @@ function contentPageEditor(pageId) {
// Save sections
async saveSections() {
if (!this.pageId || !this.isHomepage) return;
if (!this.pageId || this.pageType !== 'landing') return;
try {
contentPageEditLog.info('Saving sections...');
@@ -401,11 +500,13 @@ function contentPageEditor(pageId) {
const payload = {
slug: this.form.slug,
title: this.form.title,
title_translations: this.form.title_translations,
content: this.form.content,
content_translations: this.form.content_translations,
content_format: this.form.content_format,
template: this.form.template,
meta_description: this.form.meta_description,
meta_keywords: this.form.meta_keywords,
meta_description_translations: this.form.meta_description_translations,
is_published: this.form.is_published,
show_in_header: this.form.show_in_header,
show_in_footer: this.form.show_in_footer,
@@ -422,8 +523,8 @@ function contentPageEditor(pageId) {
// Update existing page
response = await apiClient.put(`/admin/content-pages/${this.pageId}`, payload);
// Also save sections if this is a homepage
if (this.isHomepage && this.sectionsLoaded) {
// Also save sections if this is a landing page
if (this.pageType === 'landing' && this.sectionsLoaded) {
await this.saveSections();
}

View File

@@ -57,19 +57,23 @@
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Page Title -->
<div class="md:col-span-2">
<!-- Page Type -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Page Title <span class="text-red-500">*</span>
Page Type
</label>
<input
type="text"
x-model="form.title"
required
maxlength="200"
<select
x-model="pageType"
@change="updatePageType()"
class="w-full px-3 py-2 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:border-purple-500 dark:bg-gray-700"
placeholder="About Us"
>
<option value="content">Content Page</option>
<option value="landing">Landing Page</option>
</select>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
<span x-show="pageType === 'content'">Standard page with rich text content (About, FAQ, Privacy...)</span>
<span x-show="pageType === 'landing'">Section-based page with hero, features, CTA blocks</span>
</p>
</div>
<!-- Slug -->
@@ -133,10 +137,54 @@
</div>
</div>
<!-- Content -->
<!-- Title with Language Tabs -->
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Page Title
<span class="text-sm font-normal text-gray-500 ml-2">(Multi-language)</span>
</h3>
<!-- Language Tabs for Title/Content -->
<div class="mb-4">
<div class="border-b border-gray-200 dark:border-gray-700">
<nav class="flex -mb-px space-x-4">
<template x-for="lang in supportedLanguages" :key="'tc-' + lang">
<button
type="button"
@click="titleContentLang = lang"
:class="titleContentLang === lang ? 'border-purple-500 text-purple-600 dark:text-purple-400' : 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'"
class="py-2 px-4 border-b-2 font-medium text-sm transition-colors"
>
<span x-text="languageNames[lang] || lang.toUpperCase()"></span>
<span x-show="lang === defaultLanguage" class="ml-1 text-xs text-gray-400">(default)</span>
</button>
</template>
</nav>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Title <span class="text-red-500">*</span>
<span class="font-normal text-gray-400 ml-1" x-text="'(' + (languageNames[titleContentLang] || titleContentLang) + ')'"></span>
</label>
<input
type="text"
:value="getTranslatedTitle()"
@input="setTranslatedTitle($event.target.value)"
required
maxlength="200"
class="w-full px-3 py-2 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:border-purple-500 dark:bg-gray-700"
:placeholder="'Page title in ' + (languageNames[titleContentLang] || titleContentLang)"
>
</div>
</div>
<!-- Content (only for Content Page type) -->
<div x-show="pageType === 'content'" x-cloak class="p-6 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Page Content
<span class="text-sm font-normal text-gray-500 ml-2" x-text="'(' + (languageNames[titleContentLang] || titleContentLang) + ')'"></span>
</h3>
<!-- Content Format -->
@@ -219,9 +267,9 @@
</div>
<!-- ══════════════════════════════════════════════════════════════════ -->
<!-- HOMEPAGE SECTIONS EDITOR (only for slug='home') -->
<!-- SECTIONS EDITOR (for Landing Page type) -->
<!-- ══════════════════════════════════════════════════════════════════ -->
<div x-show="isHomepage" x-cloak class="p-6 border-b border-gray-200 dark:border-gray-700">
<div x-show="pageType === 'landing'" x-cloak class="p-6 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
Homepage Sections
@@ -258,7 +306,7 @@
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- HERO SECTION -->
<!-- ═══════════════════════════════════════════════════════════ -->
<div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<div x-show="isSectionAvailable('hero')" class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<button
type="button"
@click="openSection = openSection === 'hero' ? null : 'hero'"
@@ -341,7 +389,7 @@
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- FEATURES SECTION -->
<!-- ═══════════════════════════════════════════════════════════ -->
<div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<div x-show="isSectionAvailable('features')" class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<button
type="button"
@click="openSection = openSection === 'features' ? null : 'features'"
@@ -410,7 +458,7 @@
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- PRICING SECTION -->
<!-- ═══════════════════════════════════════════════════════════ -->
<div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<div x-show="isSectionAvailable('pricing')" class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<button
type="button"
@click="openSection = openSection === 'pricing' ? null : 'pricing'"
@@ -448,7 +496,7 @@
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- CTA SECTION -->
<!-- ═══════════════════════════════════════════════════════════ -->
<div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<div x-show="isSectionAvailable('cta')" class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<button
type="button"
@click="openSection = openSection === 'cta' ? null : 'cta'"
@@ -525,6 +573,7 @@
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
SEO & Metadata
<span class="text-sm font-normal text-gray-500 ml-2" x-text="'(' + (languageNames[titleContentLang] || titleContentLang) + ')'"></span>
</h3>
<div class="space-y-4">
@@ -534,30 +583,17 @@
Meta Description
</label>
<textarea
x-model="form.meta_description"
:value="getTranslatedMetaDescription()"
@input="setTranslatedMetaDescription($event.target.value)"
rows="2"
maxlength="300"
class="w-full px-3 py-2 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:border-purple-500 dark:bg-gray-700"
placeholder="A brief description for search engines"
:placeholder="'Meta description in ' + (languageNames[titleContentLang] || titleContentLang)"
></textarea>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
<span x-text="(form.meta_description || '').length"></span>/300 characters (150-160 recommended)
150-160 characters recommended for search engines
</p>
</div>
<!-- Meta Keywords -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Meta Keywords
</label>
<input
type="text"
x-model="form.meta_keywords"
maxlength="300"
class="w-full px-3 py-2 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:border-purple-500 dark:bg-gray-700"
placeholder="keyword1, keyword2, keyword3"
>
</div>
</div>
</div>

View File

@@ -459,5 +459,5 @@
{% endblock %}
{% block extra_scripts %}
<script defer src="{{ url_for('tenancy_static', path='admin/js/store-theme.js') }}"></script>
<script defer src="{{ url_for('cms_static', path='admin/js/store-theme.js') }}"></script>
{% endblock %}

View File

@@ -125,5 +125,5 @@
{% block extra_scripts %}
<script defer src="https://cdn.jsdelivr.net/npm/tom-select@2.3.1/dist/js/tom-select.complete.min.js"></script>
<script defer src="{{ url_for('tenancy_static', path='admin/js/store-themes.js') }}"></script>
<script defer src="{{ url_for('cms_static', path='admin/js/store-themes.js') }}"></script>
{% endblock %}

View File

@@ -3,7 +3,7 @@
{% extends "storefront/base.html" %}
{# Dynamic title from CMS #}
{% block title %}{{ page.title }}{% endblock %}
{% block title %}{{ page_title or page.title }}{% endblock %}
{# SEO from CMS #}
{% block meta_description %}{{ page.meta_description or page.title }}{% endblock %}
@@ -16,13 +16,13 @@
<div class="breadcrumb mb-6">
<a href="{{ base_url }}" class="hover:text-primary">Home</a>
<span>/</span>
<span class="text-gray-900 dark:text-gray-200 font-medium">{{ page.title }}</span>
<span class="text-gray-900 dark:text-gray-200 font-medium">{{ page_title or page.title }}</span>
</div>
{# Page Header #}
<div class="mb-8">
<h1 class="text-3xl md:text-4xl font-bold text-gray-800 dark:text-gray-200 mb-4">
{{ page.title }}
{{ page_title or page.title }}
</h1>
{# Optional: Show store override badge for debugging #}

View File

@@ -1,10 +1,9 @@
{# app/templates/store/landing-default.html #}
{# standalone #}
{# app/modules/cms/templates/cms/storefront/landing-default.html #}
{# Default/Minimal Landing Page Template #}
{% extends "storefront/base.html" %}
{% block title %}{{ store.name }}{% endblock %}
{% block meta_description %}{{ page.meta_description or store.description or store.name }}{% endblock %}
{% block meta_description %}{{ page.meta_description or store.description or store.name if page else store.description or store.name }}{% endblock %}
{% block content %}
<div class="min-h-screen">
@@ -24,7 +23,7 @@
{# Title #}
<h1 class="text-4xl md:text-6xl font-bold text-gray-900 dark:text-white mb-6">
{{ page.title or store.name }}
{{ page_title or store.name }}
</h1>
{# Tagline #}
@@ -34,18 +33,31 @@
</p>
{% endif %}
{# CTA Button #}
{# CTA Buttons — driven by storefront_nav (module-agnostic) #}
{% set nav_items = storefront_nav.get('nav', []) %}
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<a href="{{ base_url }}"
{% if nav_items %}
{# Primary CTA: first nav item from enabled modules #}
<a href="{{ base_url }}{{ nav_items[0].route }}"
class="inline-flex items-center justify-center px-8 py-4 text-lg font-semibold rounded-lg text-white bg-primary hover:bg-primary-dark transition-colors shadow-lg hover:shadow-xl"
style="background-color: var(--color-primary)">
Browse Our Shop
{{ _(nav_items[0].label_key) }}
<span class="w-5 h-5 ml-2" x-html="$icon('arrow-right', 'w-5 h-5')"></span>
</a>
{% if page.content %}
{% else %}
{# Fallback: account link when no module nav items #}
<a href="{{ base_url }}account/login"
class="inline-flex items-center justify-center px-8 py-4 text-lg font-semibold rounded-lg text-white bg-primary hover:bg-primary-dark transition-colors shadow-lg hover:shadow-xl"
style="background-color: var(--color-primary)">
{{ _('cms.storefront.my_account') }}
<span class="w-5 h-5 ml-2" x-html="$icon('arrow-right', 'w-5 h-5')"></span>
</a>
{% endif %}
{% if page and page.content %}
<a href="#about"
class="inline-flex items-center justify-center px-8 py-4 text-lg font-semibold rounded-lg text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors border-2 border-gray-200 dark:border-gray-600">
Learn More
{{ _('cms.storefront.learn_more') }}
</a>
{% endif %}
</div>
@@ -54,73 +66,65 @@
</section>
{# Content Section (if provided) #}
{% if page.content %}
{% if page_content %}
<section id="about" class="py-16 bg-white dark:bg-gray-900">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="prose prose-lg dark:prose-invert max-w-none">
{{ page.content | safe }}{# sanitized: CMS content #}
{{ page_content | safe }}{# sanitized: CMS content #}
</div>
</div>
</section>
{% endif %}
{# Quick Links Section #}
{# Quick Links Section — driven by nav items and CMS pages #}
{% set account_items = storefront_nav.get('account', []) %}
{% set all_links = nav_items + account_items %}
{% if all_links or header_pages %}
<section class="py-16 bg-gray-50 dark:bg-gray-800">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<h2 class="text-3xl font-bold text-center text-gray-900 dark:text-white mb-12">
Explore
{{ _('cms.storefront.explore') }}
</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
<a href="{{ base_url }}products"
{# Module nav items (products, loyalty, etc.) #}
{% for item in all_links[:3] %}
<a href="{{ base_url }}{{ item.route }}"
class="block p-8 bg-white dark:bg-gray-900 rounded-lg shadow-md hover:shadow-xl transition-shadow text-center group">
<div class="text-4xl mb-4">🛍️</div>
<div class="mb-4">
<span class="h-10 w-10 text-primary mx-auto" style="color: var(--color-primary)"
x-html="$icon('{{ item.icon }}', 'h-10 w-10 mx-auto')"></span>
</div>
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2 group-hover:text-primary">
Shop Products
{{ _(item.label_key) }}
</h3>
<p class="text-gray-600 dark:text-gray-400">
Browse our complete catalog
</p>
</a>
{% endfor %}
{% if header_pages %}
{% for page in header_pages[:2] %}
{# Fill remaining slots with CMS header pages #}
{% set remaining = 3 - all_links[:3]|length %}
{% if remaining > 0 and header_pages %}
{% for page in header_pages[:remaining] %}
<a href="{{ base_url }}{{ page.slug }}"
class="block p-8 bg-white dark:bg-gray-900 rounded-lg shadow-md hover:shadow-xl transition-shadow text-center group">
<div class="text-4xl mb-4">📄</div>
<div class="mb-4">
<span class="h-10 w-10 text-primary mx-auto" style="color: var(--color-primary)"
x-html="$icon('document-text', 'h-10 w-10 mx-auto')"></span>
</div>
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2 group-hover:text-primary">
{{ page.title }}
</h3>
{% if page.meta_description %}
<p class="text-gray-600 dark:text-gray-400">
{{ page.meta_description or 'Learn more' }}
{{ page.meta_description }}
</p>
{% endif %}
</a>
{% endfor %}
{% else %}
<a href="{{ base_url }}about"
class="block p-8 bg-white dark:bg-gray-900 rounded-lg shadow-md hover:shadow-xl transition-shadow text-center group">
<div class="text-4xl mb-4"></div>
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2 group-hover:text-primary">
About Us
</h3>
<p class="text-gray-600 dark:text-gray-400">
Learn about our story
</p>
</a>
<a href="{{ base_url }}contact"
class="block p-8 bg-white dark:bg-gray-900 rounded-lg shadow-md hover:shadow-xl transition-shadow text-center group">
<div class="text-4xl mb-4">📧</div>
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2 group-hover:text-primary">
Contact
</h3>
<p class="text-gray-600 dark:text-gray-400">
Get in touch with us
</p>
</a>
{% endif %}
</div>
</div>
</section>
{% endif %}
</div>
{% endblock %}

View File

@@ -14,7 +14,8 @@
{# SECTION-BASED RENDERING (when page.sections is configured) #}
{# Used by POC builder templates — takes priority over hardcoded HTML #}
{# ═══════════════════════════════════════════════════════════════════ #}
{% if page and page.sections %}
{% set sections = page_sections if page_sections is defined and page_sections else (page.sections if page else none) %}
{% if sections %}
{% from 'cms/platform/sections/_hero.html' import render_hero %}
{% from 'cms/platform/sections/_features.html' import render_features %}
{% from 'cms/platform/sections/_testimonials.html' import render_testimonials %}
@@ -26,12 +27,12 @@
{% set default_lang = 'fr' %}
<div class="min-h-screen">
{% if page.sections.hero %}{{ render_hero(page.sections.hero, lang, default_lang) }}{% endif %}
{% if page.sections.features %}{{ render_features(page.sections.features, lang, default_lang) }}{% endif %}
{% if page.sections.testimonials %}{{ render_testimonials(page.sections.testimonials, lang, default_lang) }}{% endif %}
{% if page.sections.gallery %}{{ render_gallery(page.sections.gallery, lang, default_lang) }}{% endif %}
{% if page.sections.contact_info %}{{ render_contact_info(page.sections.contact_info, lang, default_lang) }}{% endif %}
{% if page.sections.cta %}{{ render_cta(page.sections.cta, lang, default_lang) }}{% endif %}
{% if sections.hero %}{{ render_hero(sections.hero, lang, default_lang) }}{% endif %}
{% if sections.features %}{{ render_features(sections.features, lang, default_lang) }}{% endif %}
{% if sections.testimonials %}{{ render_testimonials(sections.testimonials, lang, default_lang) }}{% endif %}
{% if sections.gallery %}{{ render_gallery(sections.gallery, lang, default_lang) }}{% endif %}
{% if sections.contact_info %}{{ render_contact_info(sections.contact_info, lang, default_lang) }}{% endif %}
{% if sections.cta %}{{ render_cta(sections.cta, lang, default_lang) }}{% endif %}
</div>
{% else %}

View File

@@ -80,6 +80,44 @@ class WidgetContext:
include_details: bool = False
# =============================================================================
# Storefront Dashboard Card
# =============================================================================
@dataclass
class StorefrontDashboardCard:
"""
A card contributed by a module to the storefront customer dashboard.
Modules implement get_storefront_dashboard_cards() to provide these.
The dashboard template renders them without knowing which module provided them.
Attributes:
key: Unique identifier (e.g. "orders.summary", "loyalty.points")
icon: Lucide icon name (e.g. "shopping-bag", "gift")
title: Card title (i18n key or plain text)
subtitle: Card subtitle / description
route: Link destination relative to base_url (e.g. "account/orders")
value: Primary display value (e.g. order count, points balance)
value_label: Label for the value (e.g. "Total Orders", "Points Balance")
order: Sort order (lower = shown first)
template: Optional custom template path for complex rendering
extra_data: Additional data for custom template rendering
"""
key: str
icon: str
title: str
subtitle: str
route: str
value: str | int | None = None
value_label: str | None = None
order: int = 100
template: str | None = None
extra_data: dict[str, Any] = field(default_factory=dict)
# =============================================================================
# Widget Item Types
# =============================================================================
@@ -330,6 +368,30 @@ class DashboardWidgetProviderProtocol(Protocol):
"""
...
def get_storefront_dashboard_cards(
self,
db: "Session",
store_id: int,
customer_id: int,
context: WidgetContext | None = None,
) -> list["StorefrontDashboardCard"]:
"""
Get cards for the storefront customer dashboard.
Called by the customer account dashboard. Each module contributes
its own cards (e.g. orders summary, loyalty points).
Args:
db: Database session for queries
store_id: ID of the store
customer_id: ID of the logged-in customer
context: Optional filtering/scoping context
Returns:
List of StorefrontDashboardCard objects
"""
...
__all__ = [
# Context
@@ -343,6 +405,8 @@ __all__ = [
"WidgetData",
# Main envelope
"DashboardWidget",
# Storefront
"StorefrontDashboardCard",
# Protocol
"DashboardWidgetProviderProtocol",
]

View File

@@ -9,11 +9,13 @@ Store pages for core functionality:
"""
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy.orm import Session
from app.api.deps import (
UserContext,
get_current_store_from_cookie_or_header,
get_current_store_optional,
get_db,
get_resolved_store_code,
)
@@ -24,6 +26,21 @@ from app.templates_config import templates
router = APIRouter()
# ============================================================================
# STORE ROOT REDIRECT
# ============================================================================
@router.get("/", response_class=RedirectResponse, include_in_schema=False)
async def store_root(
current_user: UserContext | None = Depends(get_current_store_optional),
):
"""Redirect /store/ based on authentication status."""
if current_user:
return RedirectResponse(url="/store/dashboard", status_code=302)
return RedirectResponse(url="/store/login", status_code=302)
# ============================================================================
# STORE DASHBOARD
# ============================================================================

View File

@@ -67,6 +67,7 @@ class DiscoveredMenuItem:
section_order: int
is_visible: bool = True
is_module_enabled: bool = True
header_template: str | None = None
@dataclass
@@ -191,6 +192,7 @@ class MenuDiscoveryService:
section_label_key=section.label_key,
section_order=section.order,
is_module_enabled=is_module_enabled,
header_template=item.header_template,
)
sections_map[section.id].items.append(discovered_item)

View File

@@ -34,6 +34,7 @@ from sqlalchemy.orm import Session
from app.modules.contracts.widgets import (
DashboardWidget,
DashboardWidgetProviderProtocol,
StorefrontDashboardCard,
WidgetContext,
)
@@ -233,6 +234,49 @@ class WidgetAggregatorService:
return widget
return None
def get_storefront_dashboard_cards(
self,
db: Session,
store_id: int,
customer_id: int,
platform_id: int,
context: WidgetContext | None = None,
) -> list[StorefrontDashboardCard]:
"""
Get dashboard cards for the storefront customer account page.
Collects cards from all enabled modules that implement
get_storefront_dashboard_cards(), sorted by order.
Args:
db: Database session
store_id: ID of the store
customer_id: ID of the logged-in customer
platform_id: Platform ID (for module enablement check)
context: Optional filtering/scoping context
Returns:
Flat list of StorefrontDashboardCard sorted by order
"""
providers = self._get_enabled_providers(db, platform_id)
cards: list[StorefrontDashboardCard] = []
for module, provider in providers:
if not hasattr(provider, "get_storefront_dashboard_cards"):
continue
try:
module_cards = provider.get_storefront_dashboard_cards(
db, store_id, customer_id, context
)
if module_cards:
cards.extend(module_cards)
except Exception as e:
logger.warning(
f"Failed to get storefront cards from module {module.code}: {e}"
)
return sorted(cards, key=lambda c: c.order)
def get_available_categories(
self, db: Session, platform_id: int
) -> list[str]:

View File

@@ -141,28 +141,28 @@ customers_module = ModuleDefinition(
items=[
MenuItemDefinition(
id="dashboard",
label_key="storefront.account.dashboard",
label_key="customers.storefront.account.dashboard",
icon="home",
route="account/dashboard",
order=10,
),
MenuItemDefinition(
id="profile",
label_key="storefront.account.profile",
label_key="customers.storefront.account.profile",
icon="user",
route="account/profile",
order=20,
),
MenuItemDefinition(
id="addresses",
label_key="storefront.account.addresses",
label_key="customers.storefront.account.addresses",
icon="map-pin",
route="account/addresses",
order=30,
),
MenuItemDefinition(
id="settings",
label_key="storefront.account.settings",
label_key="customers.storefront.account.settings",
icon="cog",
route="account/settings",
order=90,

View File

@@ -52,5 +52,13 @@
"customers_delete_desc": "Kundendatensätze entfernen",
"customers_export": "Kunden exportieren",
"customers_export_desc": "Kundendaten exportieren"
},
"storefront": {
"account": {
"dashboard": "Dashboard",
"profile": "Profil",
"addresses": "Adressen",
"settings": "Einstellungen"
}
}
}

View File

@@ -52,5 +52,13 @@
"customers_section": "Customers",
"customers": "Customers",
"all_customers": "All Customers"
},
"storefront": {
"account": {
"dashboard": "Dashboard",
"profile": "Profile",
"addresses": "Addresses",
"settings": "Settings"
}
}
}

View File

@@ -52,5 +52,13 @@
"customers_delete_desc": "Supprimer les fiches clients",
"customers_export": "Exporter les clients",
"customers_export_desc": "Exporter les données clients"
},
"storefront": {
"account": {
"dashboard": "Tableau de bord",
"profile": "Profil",
"addresses": "Adresses",
"settings": "Paramètres"
}
}
}

View File

@@ -52,5 +52,13 @@
"customers_delete_desc": "Clientedossieren ewechhuelen",
"customers_export": "Clienten exportéieren",
"customers_export_desc": "Clientedaten exportéieren"
},
"storefront": {
"account": {
"dashboard": "Dashboard",
"profile": "Profil",
"addresses": "Adressen",
"settings": "Astellungen"
}
}
}

View File

@@ -195,9 +195,25 @@ async def shop_account_dashboard_page(
},
)
# Collect dashboard cards from enabled modules via widget protocol
from app.modules.core.services.widget_aggregator import widget_aggregator
store = getattr(request.state, "store", None)
platform = getattr(request.state, "platform", None)
dashboard_cards = []
if store and platform:
dashboard_cards = widget_aggregator.get_storefront_dashboard_cards(
db,
store_id=store.id,
customer_id=current_customer.id,
platform_id=platform.id,
)
return templates.TemplateResponse(
"customers/storefront/dashboard.html",
get_storefront_context(request, db=db, user=current_customer),
get_storefront_context(
request, db=db, user=current_customer, dashboard_cards=dashboard_cards
),
)

View File

@@ -37,7 +37,7 @@
<!-- Empty State -->
<div x-show="!loading && !error && addresses.length === 0"
class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-12 text-center">
<span class="mx-auto h-12 w-12 text-gray-400 block" x-html="$icon('location-marker', 'h-12 w-12 mx-auto')"></span>
<span class="mx-auto h-12 w-12 text-gray-400 block" x-html="$icon('map-pin', 'h-12 w-12 mx-auto')"></span>
<h3 class="mt-4 text-lg font-medium text-gray-900 dark:text-white">No addresses yet</h3>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Add your first address to speed up checkout.</p>
<button @click="openAddModal()"

View File

@@ -17,25 +17,31 @@
<!-- Dashboard Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
<!-- Orders Card -->
<a href="{{ base_url }}account/orders"
{# Module-contributed cards (orders, loyalty, etc.) — rendered via widget protocol #}
{% for card in dashboard_cards|default([]) %}
<a href="{{ base_url }}{{ card.route }}"
class="block bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-lg transition-shadow p-6 border border-gray-200 dark:border-gray-700">
<div class="flex items-center mb-4">
<div class="flex-shrink-0">
<span class="h-8 w-8 text-primary" style="color: var(--color-primary)" x-html="$icon('shopping-bag', 'h-8 w-8')"></span>
<span class="h-8 w-8 text-primary" style="color: var(--color-primary)" x-html="$icon('{{ card.icon }}', 'h-8 w-8')"></span>
</div>
<div class="ml-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Orders</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">View order history</p>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ card.title }}</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">{{ card.subtitle }}</p>
</div>
</div>
{% if card.value is not none %}
<div>
<p class="text-2xl font-bold text-primary" style="color: var(--color-primary)">{{ user.total_orders }}</p>
<p class="text-sm text-gray-500 dark:text-gray-400">Total Orders</p>
<p class="text-2xl font-bold text-primary" style="color: var(--color-primary)">{{ card.value }}</p>
{% if card.value_label %}
<p class="text-sm text-gray-500 dark:text-gray-400">{{ card.value_label }}</p>
{% endif %}
</div>
{% endif %}
</a>
{% endfor %}
<!-- Profile Card -->
<!-- Profile Card (always shown — core) -->
<a href="{{ base_url }}account/profile"
class="block bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-lg transition-shadow p-6 border border-gray-200 dark:border-gray-700">
<div class="flex items-center mb-4">
@@ -52,12 +58,12 @@
</div>
</a>
<!-- Addresses Card -->
<!-- Addresses Card (always shown — core) -->
<a href="{{ base_url }}account/addresses"
class="block bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-lg transition-shadow p-6 border border-gray-200 dark:border-gray-700">
<div class="flex items-center mb-4">
<div class="flex-shrink-0">
<span class="h-8 w-8 text-primary" style="color: var(--color-primary)" x-html="$icon('location-marker', 'h-8 w-8')"></span>
<span class="h-8 w-8 text-primary" style="color: var(--color-primary)" x-html="$icon('map-pin', 'h-8 w-8')"></span>
</div>
<div class="ml-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Addresses</h3>
@@ -66,36 +72,7 @@
</div>
</a>
{% if 'loyalty' in enabled_modules %}
<!-- Loyalty Rewards Card -->
<a href="{{ base_url }}account/loyalty"
class="block bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-lg transition-shadow p-6 border border-gray-200 dark:border-gray-700"
x-data="{ points: null, loaded: false }"
x-init="fetch('/api/v1/storefront/loyalty/card').then(r => r.json()).then(d => { if (d.card) { points = d.card.points_balance; } loaded = true; }).catch(() => { loaded = true; })">
<div class="flex items-center mb-4">
<div class="flex-shrink-0">
<span class="h-8 w-8 text-primary" style="color: var(--color-primary)" x-html="$icon('gift', 'h-8 w-8')"></span>
</div>
<div class="ml-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Loyalty Rewards</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">View your points & rewards</p>
</div>
</div>
<div>
<template x-if="loaded && points !== null">
<div>
<p class="text-2xl font-bold text-primary" style="color: var(--color-primary)" x-text="points.toLocaleString()"></p>
<p class="text-sm text-gray-500 dark:text-gray-400">Points Balance</p>
</div>
</template>
<template x-if="loaded && points === null">
<p class="text-sm text-gray-500 dark:text-gray-400">Join our rewards program</p>
</template>
</div>
</a>
{% endif %}
<!-- Messages Card -->
<!-- Messages Card (always shown — messaging is core) -->
<a href="{{ base_url }}account/messages"
class="block bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-lg transition-shadow p-6 border border-gray-200 dark:border-gray-700"
x-data="{ unreadCount: 0 }"
@@ -126,10 +103,6 @@
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Customer Since</p>
<p class="text-lg font-semibold text-gray-900 dark:text-white">{{ user.created_at.strftime('%B %Y') }}</p>
</div>
<div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Total Orders</p>
<p class="text-lg font-semibold text-gray-900 dark:text-white">{{ user.total_orders }}</p>
</div>
<div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Customer Number</p>
<p class="text-lg font-semibold text-gray-900 dark:text-white">{{ user.customer_number }}</p>

View File

@@ -58,6 +58,13 @@ def _get_feature_provider():
return loyalty_feature_provider
def _get_widget_provider():
"""Lazy import of widget provider to avoid circular imports."""
from app.modules.loyalty.services.loyalty_widgets import loyalty_widget_provider
return loyalty_widget_provider
def _get_onboarding_provider():
"""Lazy import of onboarding provider to avoid circular imports."""
from app.modules.loyalty.services.loyalty_onboarding_service import (
@@ -289,7 +296,7 @@ loyalty_module = ModuleDefinition(
items=[
MenuItemDefinition(
id="loyalty",
label_key="storefront.account.loyalty",
label_key="loyalty.storefront.account.loyalty",
icon="gift",
route="account/loyalty",
order=60,
@@ -328,6 +335,8 @@ loyalty_module = ModuleDefinition(
],
# Feature provider for billing feature gating
feature_provider=_get_feature_provider,
# Widget provider for storefront dashboard cards
widget_provider=_get_widget_provider,
# Onboarding provider for post-signup checklist
onboarding_provider=_get_onboarding_provider,
)

View File

@@ -0,0 +1,64 @@
# Loyalty Module — Monitoring & Alerting
## Alert Definitions
### P0 — Page (immediate action required)
| Alert | Condition | Action |
|-------|-----------|--------|
| **Expiration task stale** | `loyalty.expire_points` last success > 26 hours ago | Check Celery worker health, inspect task logs |
| **Google Wallet service down** | Wallet sync failure rate > 50% for 2 consecutive runs | Check service account credentials, Google API status |
### P1 — Warn (investigate within business hours)
| Alert | Condition | Action |
|-------|-----------|--------|
| **Wallet sync failures** | `failed_card_ids` count > 5% of total cards synced | Check runbook-wallet-sync.md, inspect failed card IDs |
| **Email notification failures** | `loyalty_*` template send failure rate > 1% in 24h | Check SMTP config, EmailLog for errors |
| **Rate limit spikes** | 429 responses > 100/min per store | Investigate if legitimate traffic or abuse |
### P2 — Info (review in next sprint)
| Alert | Condition | Action |
|-------|-----------|--------|
| **High churn** | At-risk cards > 20% of active cards | Review re-engagement strategy (future marketing module) |
| **Low enrollment** | < 5 new cards in 7 days (per merchant with active program) | Check enrollment page accessibility, QR code placement |
## Key Metrics to Track
### Operational
- Celery task success/failure counts for `loyalty.expire_points` and `loyalty.sync_wallet_passes`
- EmailLog status distribution for `loyalty_*` template codes (sent/failed/bounced)
- Rate limiter 429 response count per store per hour
### Business
- Daily new enrollments (total + per merchant)
- Points issued vs redeemed ratio (health indicator: should be > 0.3 redemption rate)
- Stamp completion rate (% of cards reaching stamps_target)
- Cohort retention at month 3 (target: > 40%)
## Observability Integration
The loyalty module logs to the standard Python logger (`app.modules.loyalty.*`). Key log events:
| Logger | Level | Event |
|--------|-------|-------|
| `card_service` | INFO | Enrollment, deactivation, GDPR anonymization |
| `stamp_service` | INFO | Stamp add/redeem/void with card and store context |
| `points_service` | INFO | Points earn/redeem/void/adjust |
| `notification_service` | INFO | Email queued (template_code + recipient) |
| `point_expiration` | INFO | Chunk processed (cards + points count) |
| `wallet_sync` | WARNING | Per-card sync failure with retry count |
| `wallet_sync` | ERROR | Card sync exhausted all retries |
## Dashboard Suggestions
If using Grafana or similar:
1. **Enrollment funnel**: Page views → Form starts → Submissions → Success (track drop-off)
2. **Transaction volume**: Stamps + Points per hour, grouped by store
3. **Wallet adoption**: % of cards with Google/Apple Wallet passes
4. **Email delivery**: Sent → Delivered → Opened → Clicked per template
5. **Task health**: Celery task execution time + success rate over 24h

View File

@@ -100,7 +100,7 @@ All 8 decisions locked. No external blockers.
---
### Phase 2 — Notifications Infrastructure *(4d)*
### Phase 2A — Notifications Infrastructure *(✅ DONE 2026-04-11)*
#### 2.1 `LoyaltyNotificationService`
- New `app/modules/loyalty/services/notification_service.py` with methods:
@@ -144,7 +144,7 @@ All 8 decisions locked. No external blockers.
---
### Phase 3 — Task Reliability *(1.5d)*
### Phase 3 — Task Reliability *(✅ DONE 2026-04-11)*
#### 3.1 Batched point expiration
- Rewrite `tasks/point_expiration.py:154-185` from per-card loop to set-based SQL:
@@ -163,7 +163,7 @@ All 8 decisions locked. No external blockers.
---
### Phase 4 — Accessibility & T&C *(2d)*
### Phase 4 — Accessibility & T&C *(✅ DONE 2026-04-11)*
#### 4.1 T&C via store CMS integration
- Migration `loyalty_007`: add `terms_cms_page_slug: str | None` to `loyalty_programs`.
@@ -182,7 +182,7 @@ All 8 decisions locked. No external blockers.
---
### Phase 5 — Google Wallet Production Hardening *(1d)*
### Phase 5 — Google Wallet Production Hardening *(✅ UI done 2026-04-11, deploy is manual)*
#### 5.1 Cert deployment
- Place service account JSON at `~/apps/orion/google-wallet-sa.json`, app user, mode 600.
@@ -199,7 +199,7 @@ All 8 decisions locked. No external blockers.
---
### Phase 6 — Admin UX, GDPR, Bulk *(3d)*
### Phase 6 — Admin UX, GDPR, Bulk *(✅ DONE 2026-04-11)*
#### 6.1 Admin trash UI
- Trash tab on programs list and cards list, calling existing `?only_deleted=true` API.
@@ -236,7 +236,7 @@ All 8 decisions locked. No external blockers.
---
### Phase 7 — Advanced Analytics *(2.5d)*
### Phase 7 — Advanced Analytics *(✅ DONE 2026-04-11)*
#### 7.1 Cohort retention
- New `services/analytics_service.py` (or extend `program_service`).
@@ -255,7 +255,7 @@ All 8 decisions locked. No external blockers.
---
### Phase 8 — Tests, Docs, Observability *(2d)*
### Phase 8 — Tests, Docs, Observability *(✅ DONE 2026-04-11)*
#### 8.1 Coverage enforcement
- Loyalty CI job: `pytest app/modules/loyalty/tests --cov=app/modules/loyalty --cov-fail-under=80`.
@@ -294,40 +294,120 @@ Tracked separately, not blocking launch.
---
## Critical Path
## Development Status (as of 2026-04-16)
```
Phase 0 (done) ──┬─► Phase 1 ──┬─► Phase 3 ──┐
├─► Phase 2 ──┤ ├─► Phase 8 ──► LAUNCH
└─► Phase 5 ──┘ │
Phase 4, 6, 7 (parallelizable) ───────────┘
**All development phases (0-8) are COMPLETE.** 342 automated tests pass.
Phase 9 — post-launch
```
| Phase | Status | Completed |
|---|---|---|
| Phase 0 — Decisions | ✅ Done | 2026-04-09 |
| Phase 1 — Config & Security | ✅ Done | 2026-04-09 |
| Phase 1.x — Cross-store enrollment fix | ✅ Done | 2026-04-10 |
| Phase 2A — Transactional notifications (5 templates) | ✅ Done | 2026-04-11 |
| Phase 3 — Task reliability (batched expiration + wallet backoff) | ✅ Done | 2026-04-11 |
| Phase 4.1 — T&C via CMS | ✅ Done | 2026-04-11 |
| Phase 4.2 — Accessibility audit | ✅ Done | 2026-04-11 |
| Phase 5 — Wallet UI flags | ✅ Done (already handled) | 2026-04-11 |
| Phase 6 — GDPR, bulk ops, point restore, cascade restore | ✅ Done | 2026-04-11 |
| Phase 7 — Analytics (cohort, churn, revenue + Chart.js) | ✅ Done | 2026-04-11 |
| Phase 8 — Runbooks, monitoring docs, OpenAPI tags | ✅ Done | 2026-04-11 |
Phases 4, 6, 7 can run in parallel with 2/3/5 if multiple developers are available.
**Additional bugfixes during manual testing (2026-04-15):**
## Effort Summary
| Phase | Days |
|---|---|
| 0 — Decisions | done |
| 1 — Config & security | 2 |
| 2 — Notifications | 4 |
| 3 — Task reliability | 1.5 |
| 4 — A11y + CMS T&C | 2 |
| 5 — Google Wallet hardening | 1 |
| 6 — Admin / GDPR / bulk | 3 |
| 7 — Analytics | 2.5 |
| 8 — Tests / docs / observability | 2 |
| **Launch total** | **~18 days sequential, ~10 with 2 parallel tracks** |
| 9 — Apple Wallet (post-launch) | 3 |
- Terminal redeem: `card_id``id` normalization across schemas/JS
- Card detail: enrolled store name resolution, copy buttons, paginated transactions
- i18n flicker: server-rendered translations on success page
- Icon fix: `device-mobile``phone`
---
## Open Items Needing Sign-off
## Pre-Launch Checklist
1. ~~**Rate limit caps**~~ — confirmed.
2. **Email copywriting** for the 7 templates × 4 locales (Phase 2.3) — flow: I draft EN, Samir reviews, then translate.
3. ~~**`birth_date` column**~~ — confirmed missing; addressed in Phase 1.4. No backfill needed (not yet live).
Everything below must be completed before going live. Items are ordered by dependency.
### Step 1: Seed email templates on prod DB
- [ ] SSH into prod server
- [ ] Run: `python scripts/seed/seed_email_templates_loyalty.py`
- [ ] Verify: 20 rows created (5 templates × 4 locales)
- [ ] Review EN email copy — adjust subject lines/body if needed via admin UI at `/admin/email-templates`
### Step 2: Google Wallet — already deployed, verify config
The Google Wallet integration is already deployed on the Hetzner server (see Step 25 of `hetzner-server-setup.md`):
- Service account JSON at `~/apps/orion/google-wallet-sa.json`
- Docker volume mount in `docker-compose.yml` (`./google-wallet-sa.json:/app/google-wallet-sa.json:ro`) ✅
- Env vars set: `LOYALTY_GOOGLE_ISSUER_ID=3388000000023089598`, `LOYALTY_GOOGLE_SERVICE_ACCOUNT_JSON=/app/google-wallet-sa.json`
- Service account linked to Issuer with Admin role ✅
Verify after deploy:
- [ ] Restart app — confirm no startup error (config validator checks file exists)
- [ ] `GET /api/v1/admin/loyalty/wallet-status` returns `google_configured: true`
### Step 3: Apply database migrations
- [ ] Run: `alembic upgrade heads`
- [ ] Verify migrations applied: `loyalty_003` through `loyalty_006`, `customers_003`
### Step 4: FR/DE/LB translations for new analytics i18n keys
- [ ] Add translations for 7 keys in `app/modules/loyalty/locales/{fr,de,lb}.json`:
- `store.analytics.revenue_title`
- `store.analytics.at_risk_title`
- `store.analytics.cards_at_risk`
- `store.analytics.no_at_risk`
- `store.analytics.cohort_title`
- `store.analytics.cohort_month`
- `store.analytics.cohort_enrolled`
- `store.analytics.no_data_yet`
### Step 5: Investigate email template menu visibility
- [ ] Check if `messaging.manage_templates` permission is assigned to `merchant_owner` role
- [ ] If not, add it to permission discovery or default role assignments
- [ ] Verify menu appears at `/store/{store_code}/email-templates`
- [ ] Verify admin menu at `/admin/email-templates` shows loyalty templates
### Step 6: Manual E2E testing (user journeys)
Follow the **Pre-Launch E2E Test Checklist** at the bottom of `user-journeys.md`:
- [ ] **Test 1:** Customer self-enrollment (with birthday)
- [ ] **Test 2:** Cross-store re-enrollment (cross-location enabled)
- [ ] **Test 3:** Staff operations — stamps/points via terminal
- [ ] **Test 4:** Cross-store redemption (earn at store1, redeem at store2)
- [ ] **Test 5:** Customer views dashboard + transaction history
- [ ] **Test 6:** Void/return flow
- [ ] **Test 7:** Admin oversight (programs, merchants, analytics)
- [ ] **Test 8:** Cross-location disabled behavior (separate cards per store)
### Step 7: Google Wallet real-device test (demo mode)
Google Wallet currently works in **demo/test mode** — only your Google account and explicitly added test accounts can see passes. This is sufficient for launch testing.
- [ ] Enroll a test customer on prod
- [ ] Tap "Add to Google Wallet" on success page
- [ ] Open Google Wallet on Android device — verify pass renders with merchant branding
- [ ] Trigger a stamp/points transaction — verify pass auto-updates within 60s
### Step 8: Go live
- [ ] Remove any test data from prod DB (test customers, test cards)
- [ ] Verify Celery workers are running (`loyalty.expire_points`, `loyalty.sync_wallet_passes`)
- [ ] Verify SMTP is configured and test email sends work
- [ ] Enable the loyalty platform for production stores
- [ ] Monitor first 24h: check email logs, wallet sync, expiration task
### Step 9: Google Wallet production access (can be done post-launch)
Passes in demo mode only work for test accounts. To make passes available to **all Android users**:
- [ ] Go to [pay.google.com/business/console](https://pay.google.com/business/console) → **Google Wallet API****Manage**
- [ ] Click **"Request production access"**
- [ ] Fill in: business name, website URL (`rewardflow.lu`), contact info, pass type (Loyalty)
- [ ] Upload 1-2 sample pass screenshots (e.g., Fashion Hub's card with their logo/colors). Google reviews the **Issuer** (your platform), not individual merchants — once approved, all merchants on the platform can issue passes.
- [ ] Wait for Google approval (typically 1-3 business days). They check pass design complies with [brand guidelines](https://developers.google.com/wallet/loyalty/brand-guidelines).
- [ ] Once approved: **no code or infra changes needed**. Same Issuer ID and service account, passes become visible to all Android users.
---
## Post-Launch Roadmap
| Item | Priority | Effort | Notes |
|---|---|---|---|
| **Phase 9 — Apple Wallet** | P1 | 3d | Requires Apple Developer certs. See `runbook-wallet-certs.md`. |
| **Phase 2B — Marketing module** | P2 | 4d | Birthday + re-engagement emails. Cross-platform (OMS, loyalty, hosting). |
| **Coverage to 80%** | P2 | 2d | Needs Celery task mocking infrastructure for task-level tests. |
| **Admin trash UI** | P3 | 2d | Trash tab on programs/cards pages using existing `?only_deleted=true` API. The cascade restore API exists but has no UI. |
| **Bulk PIN assignment** | P3 | 1d | Batch create staff PINs. API exists for single PIN; needs bulk endpoint + UI. |
| **Cross-location enforcement** | P3 | 2d | `allow_cross_location_redemption` controls enrollment behavior but stamp/point operations don't enforce it yet. |
| **Email template menu** | P2 | 0.5d | Investigate and fix `messaging.manage_templates` permission for store owners. |

View File

@@ -0,0 +1,65 @@
# Runbook: Point Expiration Task
## Overview
The `loyalty.expire_points` Celery task runs daily at 02:00 (configured in `definition.py`). It processes all active programs with `points_expiration_days > 0`.
## What it does
1. **Warning emails** (14 days before expiry): finds cards whose last activity is past the warning threshold but not yet past the full expiration threshold. Sends `loyalty_points_expiring` email. Tracked via `last_expiration_warning_at` to prevent duplicates.
2. **Point expiration**: finds cards with `points_balance > 0` and `last_activity_at` older than `points_expiration_days`. Zeros the balance, creates `POINTS_EXPIRED` transaction, sends `loyalty_points_expired` email.
Processing is **chunked** (500 cards per batch with `FOR UPDATE SKIP LOCKED`) to avoid long-held row locks.
## Manual execution
```bash
# Run directly (outside Celery)
python -m app.modules.loyalty.tasks.point_expiration
# Via Celery
celery -A app.core.celery_config call loyalty.expire_points
```
## Partial failure handling
- Each chunk commits independently — if the task crashes mid-run, already-processed chunks are committed
- `SKIP LOCKED` means concurrent workers won't block on the same rows
- Notification failures are caught per-card and logged but don't stop the expiration
## Re-run for a specific merchant
Not currently supported via CLI. To expire points for a single merchant:
```python
from app.core.database import SessionLocal
from app.modules.loyalty.services.program_service import program_service
from app.modules.loyalty.tasks.point_expiration import _process_program
db = SessionLocal()
program = program_service.get_program_by_merchant(db, merchant_id=2)
cards, points, warnings = _process_program(db, program)
print(f"Expired {cards} cards, {points} points, {warnings} warnings")
db.close()
```
## Manual point restore
If points were expired incorrectly, use the admin API:
```
POST /api/v1/admin/loyalty/cards/{card_id}/restore-points
{
"points": 500,
"reason": "Incorrectly expired — customer was active"
}
```
This creates an `ADMIN_ADJUSTMENT` transaction and restores the balance.
## Monitoring
- Alert if `loyalty.expire_points` hasn't succeeded in 26 hours
- Check Celery flower for task status and execution time
- Expected runtime: < 1 minute for < 10k cards, scales linearly with chunk count

View File

@@ -0,0 +1,51 @@
# Runbook: Wallet Certificate Management
## Google Wallet
### Service Account JSON
**Location (prod):** `~/apps/orion/google-wallet-sa.json` (app user, mode 600)
**Validation:** The app validates this file at startup via `config.py:google_sa_path_must_exist`. If missing or unreadable, the app fails fast with a clear error message.
### Rotation
1. Generate a new service account key in [Google Cloud Console](https://console.cloud.google.com/iam-admin/serviceaccounts)
2. Download the JSON key file
3. Replace the file at the prod path: `~/apps/orion/google-wallet-sa.json`
4. Restart the app to pick up the new key
5. Verify: check `GET /api/v1/admin/loyalty/wallet-status` returns `google_configured: true`
### Expiry Monitoring
Google service account keys don't expire by default, but Google recommends rotation every 90 days. Set a calendar reminder or monitoring alert.
### Rollback
Keep the previous key file as `google-wallet-sa.json.bak`. If the new key fails, restore the backup and restart.
---
## Apple Wallet (Phase 9 — not yet configured)
### Certificates Required
1. **Pass Type ID** — from Apple Developer portal
2. **Team ID** — your Apple Developer team identifier
3. **WWDR Certificate** — Apple Worldwide Developer Relations intermediate cert
4. **Signer Certificate**`.pem` for your Pass Type ID
5. **Signer Key**`.key` private key
### Planned Location
`~/apps/orion/apple-wallet/` with files: `wwdr.pem`, `signer.pem`, `signer.key`
### Apple Cert Expiry
Apple signing certificates typically expire after 1 year. The WWDR intermediate cert expires less frequently. Monitor via:
```bash
openssl x509 -in signer.pem -noout -enddate
```
Add a monitoring alert for < 30 days to expiry.

View File

@@ -0,0 +1,57 @@
# Runbook: Wallet Sync Task
## Overview
The `loyalty.sync_wallet_passes` Celery task runs hourly (configured in `definition.py`). It catches cards that missed real-time wallet updates due to transient API errors.
## What it does
1. Finds cards with transactions in the last hour that have Google or Apple Wallet integration
2. For each card, calls `wallet_service.sync_card_to_wallets(db, card)`
3. Uses **exponential backoff** (1s, 4s, 16s) with 4 total attempts per card
4. One failing card doesn't block the batch — failures are logged and reported
## Understanding `failed_card_ids`
The task returns a `failed_card_ids` list in its result. These are cards where all 4 retry attempts failed.
**Common failure causes:**
- Google Wallet API transient 500/503 errors — usually resolve on next hourly run
- Invalid service account credentials — check `wallet-status` endpoint
- Card's Google object was deleted externally — needs manual re-creation
- Network timeout — check server connectivity to `walletobjects.googleapis.com`
## Manual re-sync
```bash
# Re-run the entire sync task
celery -A app.core.celery_config call loyalty.sync_wallet_passes
# Re-sync a specific card (Python shell)
from app.core.database import SessionLocal
from app.modules.loyalty.services import wallet_service
from app.modules.loyalty.models import LoyaltyCard
db = SessionLocal()
card = db.query(LoyaltyCard).get(card_id)
result = wallet_service.sync_card_to_wallets(db, card)
print(result)
db.close()
```
## Monitoring
- Alert if `loyalty.sync_wallet_passes` failure rate > 5% (more than 5% of cards fail after all retries)
- Check Celery flower for task execution time — should be < 30s for typical loads
- Large `failed_card_ids` lists (> 10) may indicate a systemic API issue
## Retry behavior
| Attempt | Delay before | Total elapsed |
|---------|-------------|---------------|
| 1 | 0s | 0s |
| 2 | 1s | 1s |
| 3 | 4s | 5s |
| 4 | 16s | 21s |
After attempt 4 fails, the card is added to `failed_card_ids` and will be retried on the next hourly run.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
"""loyalty 005 - add last_expiration_warning_at to loyalty_cards
Tracks when the last expiration warning email was sent to prevent
duplicate notifications. The expiration task checks this timestamp
and only sends a warning once per expiration cycle.
Revision ID: loyalty_005
Revises: loyalty_004
Create Date: 2026-04-11
"""
import sqlalchemy as sa
from alembic import op
revision = "loyalty_005"
down_revision = "loyalty_004"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"loyalty_cards",
sa.Column("last_expiration_warning_at", sa.DateTime(), nullable=True),
)
def downgrade() -> None:
op.drop_column("loyalty_cards", "last_expiration_warning_at")

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

@@ -0,0 +1,68 @@
"""loyalty 007 - add transaction categories
Adds store-scoped product categories (e.g., Men, Women, Accessories)
that sellers select when entering loyalty transactions. Also adds
category_id FK on loyalty_transactions.
Revision ID: loyalty_007
Revises: loyalty_006
Create Date: 2026-04-19
"""
import sqlalchemy as sa
from alembic import op
revision = "loyalty_007"
down_revision = "loyalty_006"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"store_transaction_categories",
sa.Column("id", sa.Integer(), primary_key=True, index=True),
sa.Column(
"store_id",
sa.Integer(),
sa.ForeignKey("stores.id"),
nullable=False,
),
sa.Column("name", sa.String(100), nullable=False),
sa.Column("display_order", sa.Integer(), nullable=False, server_default="0"),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true"),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.UniqueConstraint("store_id", "name", name="uq_store_category_name"),
)
op.create_index(
"idx_store_category_store",
"store_transaction_categories",
["store_id", "is_active"],
)
op.add_column(
"loyalty_transactions",
sa.Column(
"category_id",
sa.Integer(),
sa.ForeignKey("store_transaction_categories.id", ondelete="SET NULL"),
nullable=True,
),
)
def downgrade() -> None:
op.drop_column("loyalty_transactions", "category_id")
op.drop_index("idx_store_category_store", table_name="store_transaction_categories")
op.drop_table("store_transaction_categories")

View File

@@ -0,0 +1,33 @@
"""loyalty 008 - add name_translations to transaction categories
Adds a JSON column for multi-language category names alongside the
existing name field (used as fallback/default).
Revision ID: loyalty_008
Revises: loyalty_007
Create Date: 2026-04-19
"""
import sqlalchemy as sa
from alembic import op
revision = "loyalty_008"
down_revision = "loyalty_007"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"store_transaction_categories",
sa.Column(
"name_translations",
sa.JSON(),
nullable=True,
comment='Language-keyed name dict, e.g. {"en": "Men", "fr": "Hommes"}',
),
)
def downgrade() -> None:
op.drop_column("store_transaction_categories", "name_translations")

View File

@@ -0,0 +1,43 @@
"""loyalty 009 - replace category_id FK with category_ids JSON
Switches from single-category to multi-category support on transactions.
Not live yet so no data migration needed.
Revision ID: loyalty_009
Revises: loyalty_008
Create Date: 2026-04-19
"""
import sqlalchemy as sa
from alembic import op
revision = "loyalty_009"
down_revision = "loyalty_008"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.drop_column("loyalty_transactions", "category_id")
op.add_column(
"loyalty_transactions",
sa.Column(
"category_ids",
sa.JSON(),
nullable=True,
comment="List of category IDs selected for this transaction",
),
)
def downgrade() -> None:
op.drop_column("loyalty_transactions", "category_ids")
op.add_column(
"loyalty_transactions",
sa.Column(
"category_id",
sa.Integer(),
sa.ForeignKey("store_transaction_categories.id", ondelete="SET NULL"),
nullable=True,
),
)

View File

@@ -49,6 +49,10 @@ from app.modules.loyalty.models.staff_pin import (
# Model
StaffPin,
)
from app.modules.loyalty.models.transaction_category import (
# Model
StoreTransactionCategory,
)
__all__ = [
# Enums
@@ -62,4 +66,5 @@ __all__ = [
"StaffPin",
"AppleDeviceRegistration",
"MerchantLoyaltySettings",
"StoreTransactionCategory",
]

View File

@@ -223,6 +223,13 @@ class LoyaltyCard(Base, TimestampMixin, SoftDeleteMixin):
comment="Any activity (for expiration calculation)",
)
# Notification tracking
last_expiration_warning_at = Column(
DateTime(timezone=True),
nullable=True,
comment="When last expiration warning email was sent",
)
# =========================================================================
# Status
# =========================================================================

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

@@ -25,6 +25,7 @@ from sqlalchemy import (
String,
Text,
)
from sqlalchemy.dialects.sqlite import JSON
from sqlalchemy.orm import relationship
from app.core.database import Base
@@ -104,6 +105,11 @@ class LoyaltyTransaction(Base, TimestampMixin):
index=True,
comment="Staff PIN used for this operation",
)
category_ids = Column(
JSON,
nullable=True,
comment="List of category IDs selected for this transaction",
)
# Related transaction (for voids/returns)
related_transaction_id = Column(

View File

@@ -0,0 +1,56 @@
# app/modules/loyalty/models/transaction_category.py
"""
Store-scoped transaction categories.
Merchants configure 4-10 categories per store (e.g., Men, Women,
Accessories, Kids) that sellers select when entering transactions.
"""
from sqlalchemy import (
Boolean,
Column,
ForeignKey,
Index,
Integer,
String,
UniqueConstraint,
)
from sqlalchemy.dialects.sqlite import JSON
from sqlalchemy.orm import relationship
from app.core.database import Base
from models.database.base import TimestampMixin
class StoreTransactionCategory(Base, TimestampMixin):
"""Product category for loyalty transactions."""
__tablename__ = "store_transaction_categories"
id = Column(Integer, primary_key=True, index=True)
store_id = Column(Integer, ForeignKey("stores.id"), nullable=False)
name = Column(String(100), nullable=False)
name_translations = Column(
JSON,
nullable=True,
comment='Language-keyed name dict, e.g. {"en": "Men", "fr": "Hommes"}',
)
display_order = Column(Integer, nullable=False, default=0)
is_active = Column(Boolean, nullable=False, default=True)
# Relationships
store = relationship("Store")
__table_args__ = (
UniqueConstraint("store_id", "name", name="uq_store_category_name"),
Index("idx_store_category_store", "store_id", "is_active"),
)
def __repr__(self):
return f"<StoreTransactionCategory(id={self.id}, store={self.store_id}, name='{self.name}')>"
def get_translated_name(self, lang: str) -> str:
"""Get name in the given language, falling back to self.name."""
if self.name_translations and isinstance(self.name_translations, dict):
return self.name_translations.get(lang) or self.name
return self.name

View File

@@ -11,6 +11,7 @@ Platform admin endpoints for:
import logging
from fastapi import APIRouter, Depends, Path, Query
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.api.deps import (
@@ -38,6 +39,7 @@ from app.modules.loyalty.schemas import (
TransactionResponse,
)
from app.modules.loyalty.services import card_service, pin_service, program_service
from app.modules.loyalty.services.analytics_service import analytics_service
from app.modules.tenancy.models import User # API-007
logger = logging.getLogger(__name__)
@@ -45,6 +47,7 @@ logger = logging.getLogger(__name__)
# Admin router with module access control
router = APIRouter(
prefix="/loyalty",
tags=["Loyalty - Admin"],
dependencies=[Depends(require_module_access("loyalty", FrontendType.ADMIN))],
)
@@ -494,6 +497,123 @@ def get_platform_stats(
return program_service.get_platform_stats(db)
# =============================================================================
# Transaction Categories (admin manages on behalf of stores)
# =============================================================================
@router.get("/stores/{store_id}/categories")
def list_store_categories(
store_id: int = Path(..., gt=0),
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""List transaction categories for a store."""
from app.modules.loyalty.schemas.category import (
CategoryListResponse,
CategoryResponse,
)
from app.modules.loyalty.services.category_service import category_service
categories = category_service.list_categories(db, store_id)
return CategoryListResponse(
categories=[CategoryResponse.model_validate(c) for c in categories],
total=len(categories),
)
@router.post("/stores/{store_id}/categories", status_code=201)
def create_store_category(
data: dict,
store_id: int = Path(..., gt=0),
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""Create a transaction category for a store."""
from app.modules.loyalty.schemas.category import CategoryCreate, CategoryResponse
from app.modules.loyalty.services.category_service import category_service
category = category_service.create_category(
db, store_id, CategoryCreate(**data)
)
return CategoryResponse.model_validate(category)
@router.patch("/stores/{store_id}/categories/{category_id}")
def update_store_category(
data: dict,
store_id: int = Path(..., gt=0),
category_id: int = Path(..., gt=0),
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""Update a transaction category for a store."""
from app.modules.loyalty.schemas.category import CategoryResponse, CategoryUpdate
from app.modules.loyalty.services.category_service import category_service
category = category_service.update_category(
db, category_id, store_id, CategoryUpdate(**data)
)
return CategoryResponse.model_validate(category)
@router.delete("/stores/{store_id}/categories/{category_id}", status_code=204)
def delete_store_category(
store_id: int = Path(..., gt=0),
category_id: int = Path(..., gt=0),
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""Delete a transaction category for a store."""
from app.modules.loyalty.services.category_service import category_service
category_service.delete_category(db, category_id, store_id)
# =============================================================================
# Advanced Analytics
# =============================================================================
@router.get("/merchants/{merchant_id}/analytics/cohorts")
def get_cohort_retention(
merchant_id: int = Path(..., gt=0),
months_back: int = Query(6, ge=1, le=24),
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""Cohort retention matrix for a merchant's loyalty program."""
return analytics_service.get_cohort_retention(
db, merchant_id, months_back=months_back
)
@router.get("/merchants/{merchant_id}/analytics/churn")
def get_at_risk_cards(
merchant_id: int = Path(..., gt=0),
limit: int = Query(50, ge=1, le=200),
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""Cards at risk of churn for a merchant."""
return analytics_service.get_at_risk_cards(
db, merchant_id, limit=limit
)
@router.get("/merchants/{merchant_id}/analytics/revenue")
def get_revenue_attribution(
merchant_id: int = Path(..., gt=0),
months_back: int = Query(6, ge=1, le=24),
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""Revenue attribution from loyalty transactions."""
return analytics_service.get_revenue_attribution(
db, merchant_id, months_back=months_back
)
# =============================================================================
# Wallet Integration Status
# =============================================================================
@@ -765,3 +885,153 @@ def debug_recent_enrollments(
})
return {"enrollments": results}
# =============================================================================
# Phase 6: Admin Operations (GDPR, Bulk, Point Restore)
# =============================================================================
class GDPRAnonymizeResponse(BaseModel):
"""Response for GDPR customer anonymization."""
cards_anonymized: int
customer_id: int
message: str
class BulkDeactivateRequest(BaseModel):
"""Request for bulk card deactivation."""
card_ids: list[int] = Field(..., min_length=1, max_length=1000)
reason: str = Field(..., min_length=1, max_length=500)
class BulkDeactivateResponse(BaseModel):
cards_deactivated: int
message: str
class PointsRestoreRequest(BaseModel):
"""Request for admin point restore."""
points: int = Field(..., gt=0)
reason: str = Field(..., min_length=1, max_length=500)
class PointsRestoreResponse(BaseModel):
success: bool
card_id: int
points_restored: int
new_balance: int
message: str
@router.delete(
"/cards/customer/{customer_id}",
response_model=GDPRAnonymizeResponse,
)
def gdpr_anonymize_customer(
customer_id: int = Path(..., gt=0),
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""
GDPR: Anonymize a customer's loyalty cards.
Nulls customer_id on all cards, deactivates them, and scrubs PII
from transaction notes. Keeps transaction rows for aggregate reporting.
"""
count = card_service.anonymize_cards_for_customer(
db, customer_id, admin_user_id=current_user.id
)
return GDPRAnonymizeResponse(
cards_anonymized=count,
customer_id=customer_id,
message=f"Anonymized {count} card(s) for customer {customer_id}",
)
@router.post(
"/merchants/{merchant_id}/cards/bulk/deactivate",
response_model=BulkDeactivateResponse,
)
def bulk_deactivate_cards(
data: BulkDeactivateRequest,
merchant_id: int = Path(..., gt=0),
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""Bulk deactivate multiple loyalty cards for a merchant."""
count = card_service.bulk_deactivate_cards(
db,
card_ids=data.card_ids,
merchant_id=merchant_id,
reason=data.reason,
)
return BulkDeactivateResponse(
cards_deactivated=count,
message=f"Deactivated {count} card(s)",
)
@router.post(
"/cards/{card_id}/restore-points",
response_model=PointsRestoreResponse,
)
def restore_points(
data: PointsRestoreRequest,
card_id: int = Path(..., gt=0),
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""
Admin: Restore expired or voided points to a card.
Creates an ADMIN_ADJUSTMENT transaction with a positive delta.
"""
from app.modules.loyalty.services.points_service import points_service
result = points_service.adjust_points(
db,
card_id=card_id,
points_delta=data.points,
reason=f"Admin restore: {data.reason}",
)
return PointsRestoreResponse(
success=True,
card_id=card_id,
points_restored=data.points,
new_balance=result["points_balance"],
message=f"Restored {data.points} points",
)
@router.post(
"/merchants/{merchant_id}/restore-deleted",
)
def restore_deleted_merchant_data(
merchant_id: int = Path(..., gt=0),
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""
Cascade restore: restore all soft-deleted programs and cards
for a merchant.
"""
programs_restored = program_service.restore_deleted_programs(db, merchant_id)
cards_restored = card_service.restore_deleted_cards(db, merchant_id)
logger.info(
f"Cascade restore for merchant {merchant_id}: "
f"{programs_restored} programs, {cards_restored} cards"
)
return {
"merchant_id": merchant_id,
"programs_restored": programs_restored,
"cards_restored": cards_restored,
"message": f"Restored {programs_restored} program(s) and {cards_restored} card(s)",
}

View File

@@ -69,6 +69,7 @@ logger = logging.getLogger(__name__)
# Store router with module access control
router = APIRouter(
prefix="/loyalty",
tags=["Loyalty - Store"],
dependencies=[Depends(require_module_access("loyalty", FrontendType.STORE))],
)
@@ -198,6 +199,129 @@ def get_merchant_stats(
return MerchantStatsResponse(**stats)
@router.get("/analytics/cohorts")
def get_cohort_retention(
months_back: int = Query(6, ge=1, le=24),
current_user: User = Depends(get_current_store_api),
db: Session = Depends(get_db),
):
"""Cohort retention matrix for this merchant's loyalty program."""
from app.modules.loyalty.services.analytics_service import analytics_service
merchant_id = get_store_merchant_id(db, current_user.token_store_id)
return analytics_service.get_cohort_retention(
db, merchant_id, months_back=months_back
)
@router.get("/analytics/churn")
def get_at_risk_cards(
limit: int = Query(50, ge=1, le=200),
current_user: User = Depends(get_current_store_api),
db: Session = Depends(get_db),
):
"""Cards at risk of churn for this merchant."""
from app.modules.loyalty.services.analytics_service import analytics_service
merchant_id = get_store_merchant_id(db, current_user.token_store_id)
return analytics_service.get_at_risk_cards(
db, merchant_id, limit=limit
)
@router.get("/analytics/revenue")
def get_revenue_attribution(
months_back: int = Query(6, ge=1, le=24),
current_user: User = Depends(get_current_store_api),
db: Session = Depends(get_db),
):
"""Revenue attribution from loyalty transactions."""
from app.modules.loyalty.services.analytics_service import analytics_service
merchant_id = get_store_merchant_id(db, current_user.token_store_id)
return analytics_service.get_revenue_attribution(
db, merchant_id, months_back=months_back
)
# =============================================================================
# Transaction Categories
# =============================================================================
@router.get("/categories")
def list_categories(
current_user: User = Depends(get_current_store_api),
db: Session = Depends(get_db),
):
"""List transaction categories for this store."""
from app.modules.loyalty.schemas.category import (
CategoryListResponse,
CategoryResponse,
)
from app.modules.loyalty.services.category_service import category_service
categories = category_service.list_categories(db, current_user.token_store_id)
return CategoryListResponse(
categories=[CategoryResponse.model_validate(c) for c in categories],
total=len(categories),
)
@router.post("/categories", status_code=201)
def create_category(
data: dict,
current_user: User = Depends(get_current_store_api),
db: Session = Depends(get_db),
):
"""Create a transaction category for this store (merchant_owner only)."""
if current_user.role != "merchant_owner":
raise AuthorizationException("Only merchant owners can manage categories")
from app.modules.loyalty.schemas.category import CategoryCreate, CategoryResponse
from app.modules.loyalty.services.category_service import category_service
category = category_service.create_category(
db, current_user.token_store_id, CategoryCreate(**data)
)
return CategoryResponse.model_validate(category)
@router.patch("/categories/{category_id}")
def update_category(
category_id: int,
data: dict,
current_user: User = Depends(get_current_store_api),
db: Session = Depends(get_db),
):
"""Update a transaction category (merchant_owner only)."""
if current_user.role != "merchant_owner":
raise AuthorizationException("Only merchant owners can manage categories")
from app.modules.loyalty.schemas.category import CategoryResponse, CategoryUpdate
from app.modules.loyalty.services.category_service import category_service
category = category_service.update_category(
db, category_id, current_user.token_store_id, CategoryUpdate(**data)
)
return CategoryResponse.model_validate(category)
@router.delete("/categories/{category_id}", status_code=204)
def delete_category(
category_id: int,
current_user: User = Depends(get_current_store_api),
db: Session = Depends(get_db),
):
"""Delete a transaction category (merchant_owner only)."""
if current_user.role != "merchant_owner":
raise AuthorizationException("Only merchant owners can manage categories")
from app.modules.loyalty.services.category_service import category_service
category_service.delete_category(db, category_id, current_user.token_store_id)
# =============================================================================
# Staff PINs
# =============================================================================
@@ -374,7 +498,7 @@ def _build_card_lookup_response(card, db=None) -> CardLookupResponse:
available_rewards.append(reward)
return CardLookupResponse(
card_id=card.id,
id=card.id,
card_number=card.card_number,
customer_id=card.customer_id,
customer_name=card.customer.full_name if card.customer else None,
@@ -468,6 +592,17 @@ def get_card_detail(
program = card.program
customer = card.customer
# Resolve enrolled store name
enrolled_store_name = None
if card.enrolled_at_store_id:
from app.modules.tenancy.services.store_service import store_service
enrolled_store = store_service.get_store_by_id_optional(
db, card.enrolled_at_store_id
)
if enrolled_store:
enrolled_store_name = enrolled_store.name
return CardDetailResponse(
id=card.id,
card_number=card.card_number,
@@ -475,6 +610,7 @@ def get_card_detail(
merchant_id=card.merchant_id,
program_id=card.program_id,
enrolled_at_store_id=card.enrolled_at_store_id,
enrolled_at_store_name=enrolled_store_name,
customer_name=customer.full_name if customer else None,
customer_email=customer.email if customer else None,
merchant_name=card.merchant.name if card.merchant else None,
@@ -503,6 +639,7 @@ def get_card_detail(
@router.get("/transactions", response_model=TransactionListResponse)
def list_store_transactions(
request: Request,
skip: int = Query(0, ge=0),
limit: int = Query(10, ge=1, le=100),
current_user: User = Depends(get_current_store_api),
@@ -511,16 +648,28 @@ def list_store_transactions(
"""List recent transactions for this merchant's loyalty program."""
store_id = current_user.token_store_id
merchant_id = get_store_merchant_id(db, store_id)
lang = getattr(request.state, "language", "en") or "en"
transactions, total = card_service.get_store_transactions(
db, merchant_id, skip=skip, limit=limit
)
from app.modules.loyalty.services.category_service import category_service
tx_responses = []
for t in transactions:
tx = TransactionResponse.model_validate(t)
if t.card and t.card.customer:
tx.customer_name = t.card.customer.full_name
if t.category_ids and isinstance(t.category_ids, list):
names = []
for cid in t.category_ids:
name = category_service.validate_category_for_store(
db, cid, t.store_id or 0, lang=lang
)
if name:
names.append(name)
tx.category_names = names if names else None
tx_responses.append(tx)
return TransactionListResponse(transactions=tx_responses, total=total)
@@ -580,6 +729,7 @@ def enroll_customer(
@router.get("/cards/{card_id}/transactions", response_model=TransactionListResponse)
def get_card_transactions(
request: Request,
card_id: int = Path(..., gt=0),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
@@ -588,6 +738,7 @@ def get_card_transactions(
):
"""Get transaction history for a card."""
store_id = current_user.token_store_id
lang = getattr(request.state, "language", "en") or "en"
# Verify card belongs to this merchant (raises LoyaltyCardNotFoundException if not found)
card_service.lookup_card_for_store(db, store_id, card_id=card_id)
@@ -596,10 +747,23 @@ def get_card_transactions(
db, card_id, skip=skip, limit=limit
)
return TransactionListResponse(
transactions=[TransactionResponse.model_validate(t) for t in transactions],
total=total,
)
from app.modules.loyalty.services.category_service import category_service
tx_responses = []
for t in transactions:
tx = TransactionResponse.model_validate(t)
if t.category_ids and isinstance(t.category_ids, list):
names = []
for cid in t.category_ids:
name = category_service.validate_category_for_store(
db, cid, t.store_id or 0, lang=lang
)
if name:
names.append(name)
tx.category_names = names if names else None
tx_responses.append(tx)
return TransactionListResponse(transactions=tx_responses, total=total)
# =============================================================================
@@ -626,6 +790,7 @@ def add_stamp(
qr_code=data.qr_code,
card_number=data.card_number,
staff_pin=data.staff_pin,
category_ids=data.category_ids,
ip_address=ip,
user_agent=user_agent,
notes=data.notes,
@@ -716,6 +881,7 @@ def earn_points(
purchase_amount_cents=data.purchase_amount_cents,
order_reference=data.order_reference,
staff_pin=data.staff_pin,
category_ids=data.category_ids,
ip_address=ip,
user_agent=user_agent,
notes=data.notes,
@@ -810,3 +976,39 @@ def adjust_points(
)
return PointsAdjustResponse(**result)
# =============================================================================
# Bulk Operations (Merchant Owner only)
# =============================================================================
@router.post("/cards/bulk/deactivate")
@rate_limit(max_requests=10, window_seconds=60)
def bulk_deactivate_cards(
request: Request,
data: dict,
current_user: User = Depends(get_current_store_api),
db: Session = Depends(get_db),
):
"""Bulk deactivate multiple loyalty cards (merchant_owner only)."""
if current_user.role != "merchant_owner":
raise AuthorizationException("Only merchant owners can bulk deactivate cards")
from app.modules.tenancy.services.store_service import store_service
store = store_service.get_store_by_id_optional(db, current_user.token_store_id)
if not store:
raise AuthorizationException("Store not found")
card_ids = data.get("card_ids", [])
reason = data.get("reason", "Merchant bulk deactivation")
count = card_service.bulk_deactivate_cards(
db,
card_ids=card_ids,
merchant_id=store.merchant_id,
reason=reason,
)
return {"cards_deactivated": count, "message": f"Deactivated {count} card(s)"}

View File

@@ -100,6 +100,7 @@ class CardDetailResponse(CardResponse):
# Merchant info
merchant_name: str | None = None
enrolled_at_store_name: str | None = None
# Program info
program_name: str
@@ -128,7 +129,7 @@ class CardLookupResponse(BaseModel):
"""Schema for card lookup by QR code or card number."""
# Card info
card_id: int
id: int
card_number: str
# Customer
@@ -187,6 +188,8 @@ class TransactionResponse(BaseModel):
order_reference: str | None = None
reward_id: str | None = None
reward_description: str | None = None
category_ids: list[int] | None = None
category_names: list[str] | None = None
notes: str | None = None
# Customer

View File

@@ -0,0 +1,48 @@
# app/modules/loyalty/schemas/category.py
"""Pydantic schemas for transaction categories."""
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
class CategoryCreate(BaseModel):
"""Schema for creating a transaction category."""
name: str = Field(..., min_length=1, max_length=100)
name_translations: dict[str, str] | None = Field(
None,
description='Translations keyed by language: {"en": "Men", "fr": "Hommes"}',
)
display_order: int = Field(default=0, ge=0)
class CategoryUpdate(BaseModel):
"""Schema for updating a transaction category."""
name: str | None = Field(None, min_length=1, max_length=100)
name_translations: dict[str, str] | None = None
display_order: int | None = Field(None, ge=0)
is_active: bool | None = None
class CategoryResponse(BaseModel):
"""Schema for transaction category response."""
model_config = ConfigDict(from_attributes=True)
id: int
store_id: int
name: str
name_translations: dict[str, str] | None = None
display_order: int
is_active: bool
created_at: datetime
updated_at: datetime
class CategoryListResponse(BaseModel):
"""Schema for listing categories."""
categories: list[CategoryResponse]
total: int

View File

@@ -47,6 +47,12 @@ class PointsEarnRequest(BaseModel):
description="Staff PIN for verification",
)
# Categories (what was sold — multi-select)
category_ids: list[int] | None = Field(
None,
description="Transaction category IDs",
)
# Optional metadata
notes: str | None = Field(
None,

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

@@ -37,6 +37,12 @@ class StampRequest(BaseModel):
description="Staff PIN for verification",
)
# Categories (what was sold — multi-select)
category_ids: list[int] | None = Field(
None,
description="Transaction category IDs",
)
# Optional metadata
notes: str | None = Field(
None,

View File

@@ -0,0 +1,338 @@
# app/modules/loyalty/services/analytics_service.py
"""
Loyalty analytics service.
Advanced analytics beyond basic stats:
- Cohort retention (enrollment month → % active per subsequent month)
- Churn detection (at-risk cards based on inactivity)
- Revenue attribution (loyalty vs non-loyalty per store)
"""
import logging
from datetime import UTC, datetime, timedelta
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.modules.loyalty.models import LoyaltyCard, LoyaltyTransaction
logger = logging.getLogger(__name__)
class AnalyticsService:
"""Advanced loyalty analytics."""
def get_cohort_retention(
self,
db: Session,
merchant_id: int,
months_back: int = 6,
) -> dict:
"""
Cohort retention matrix.
Groups cards by enrollment month and tracks what % had any
transaction in each subsequent month.
Returns:
{
"cohorts": [
{
"month": "2026-01",
"enrolled": 50,
"retention": [100, 80, 65, 55, ...] # % active per month
}
]
}
"""
now = datetime.now(UTC)
start_date = now - timedelta(days=months_back * 31)
# Get enrollment month for each card
cards = (
db.query(
LoyaltyCard.id,
func.date_trunc("month", LoyaltyCard.created_at).label(
"enrollment_month"
),
)
.filter(
LoyaltyCard.merchant_id == merchant_id,
LoyaltyCard.created_at >= start_date,
)
.all()
)
if not cards:
return {"cohorts": [], "months_back": months_back}
# Group cards by enrollment month
cohort_cards: dict[str, list[int]] = {}
for card_id, enrollment_month in cards:
month_key = enrollment_month.strftime("%Y-%m")
cohort_cards.setdefault(month_key, []).append(card_id)
# For each cohort, check activity in subsequent months
cohorts = []
for month_key in sorted(cohort_cards.keys()):
card_ids = cohort_cards[month_key]
enrolled_count = len(card_ids)
# Calculate months since enrollment
cohort_start = datetime.strptime(month_key, "%Y-%m").replace(
tzinfo=UTC
)
months_since = max(
1,
(now.year - cohort_start.year) * 12
+ (now.month - cohort_start.month),
)
retention = []
for month_offset in range(min(months_since, months_back)):
period_start = cohort_start + timedelta(days=month_offset * 30)
period_end = period_start + timedelta(days=30)
# Count cards with any transaction in this period
active_count = (
db.query(func.count(func.distinct(LoyaltyTransaction.card_id)))
.filter(
LoyaltyTransaction.card_id.in_(card_ids),
LoyaltyTransaction.transaction_at >= period_start,
LoyaltyTransaction.transaction_at < period_end,
)
.scalar()
or 0
)
pct = round(active_count / enrolled_count * 100) if enrolled_count else 0
retention.append(pct)
cohorts.append(
{
"month": month_key,
"enrolled": enrolled_count,
"retention": retention,
}
)
return {"cohorts": cohorts, "months_back": months_back}
def get_at_risk_cards(
self,
db: Session,
merchant_id: int,
inactivity_multiplier: float = 2.0,
limit: int = 50,
) -> dict:
"""
Simple churn detection.
A card is "at risk" when its inactivity period exceeds
`inactivity_multiplier` × its average inter-transaction interval.
Falls back to 60 days for cards with fewer than 2 transactions.
Returns:
{
"at_risk_count": int,
"cards": [
{
"card_id": int,
"card_number": str,
"customer_name": str,
"days_inactive": int,
"avg_interval_days": int,
"points_balance": int,
}
]
}
"""
now = datetime.now(UTC)
default_threshold_days = 60
# Get active cards with their last activity
cards = (
db.query(LoyaltyCard)
.filter(
LoyaltyCard.merchant_id == merchant_id,
LoyaltyCard.is_active == True, # noqa: E712
LoyaltyCard.last_activity_at.isnot(None),
)
.all()
)
at_risk = []
for card in cards:
days_inactive = (now - card.last_activity_at).days
# Calculate average interval from transaction history
tx_dates = (
db.query(LoyaltyTransaction.transaction_at)
.filter(LoyaltyTransaction.card_id == card.id)
.order_by(LoyaltyTransaction.transaction_at)
.all()
)
if len(tx_dates) >= 2:
intervals = [
(tx_dates[i + 1][0] - tx_dates[i][0]).days
for i in range(len(tx_dates) - 1)
]
avg_interval = sum(intervals) / len(intervals) if intervals else default_threshold_days
else:
avg_interval = default_threshold_days
threshold = avg_interval * inactivity_multiplier
if days_inactive > threshold:
customer_name = None
if card.customer:
customer_name = card.customer.full_name
at_risk.append(
{
"card_id": card.id,
"card_number": card.card_number,
"customer_name": customer_name,
"days_inactive": days_inactive,
"avg_interval_days": round(avg_interval),
"points_balance": card.points_balance,
}
)
# Sort by days_inactive descending
at_risk.sort(key=lambda x: x["days_inactive"], reverse=True)
return {
"at_risk_count": len(at_risk),
"cards": at_risk[:limit],
"total_cards_checked": len(cards),
}
def get_revenue_attribution(
self,
db: Session,
merchant_id: int,
months_back: int = 6,
) -> dict:
"""
Revenue attribution from loyalty point-earning transactions.
Compares revenue from transactions with order references
(loyalty customers) against total enrollment metrics.
Groups by month and store.
Returns:
{
"monthly": [
{
"month": "2026-01",
"transactions_count": int,
"total_points_earned": int,
"estimated_revenue_cents": int,
"unique_customers": int,
}
],
"by_store": [
{
"store_id": int,
"store_name": str,
"transactions_count": int,
"total_points_earned": int,
}
]
}
"""
from app.modules.loyalty.models.loyalty_transaction import TransactionType
from app.modules.tenancy.services.store_service import store_service
now = datetime.now(UTC)
start_date = now - timedelta(days=months_back * 31)
# Monthly aggregation of point-earning transactions
monthly_rows = (
db.query(
func.date_trunc("month", LoyaltyTransaction.transaction_at).label(
"month"
),
func.count(LoyaltyTransaction.id).label("tx_count"),
func.coalesce(
func.sum(LoyaltyTransaction.points_delta), 0
).label("points_earned"),
func.count(
func.distinct(LoyaltyTransaction.card_id)
).label("unique_cards"),
)
.filter(
LoyaltyTransaction.merchant_id == merchant_id,
LoyaltyTransaction.transaction_at >= start_date,
LoyaltyTransaction.transaction_type.in_(
[
TransactionType.POINTS_EARNED.value,
TransactionType.STAMP_EARNED.value,
]
),
LoyaltyTransaction.points_delta > 0,
)
.group_by("month")
.order_by("month")
.all()
)
monthly = []
for row in monthly_rows:
monthly.append(
{
"month": row.month.strftime("%Y-%m"),
"transactions_count": row.tx_count,
"total_points_earned": row.points_earned,
"unique_customers": row.unique_cards,
}
)
# Per-store breakdown
store_rows = (
db.query(
LoyaltyTransaction.store_id,
func.count(LoyaltyTransaction.id).label("tx_count"),
func.coalesce(
func.sum(LoyaltyTransaction.points_delta), 0
).label("points_earned"),
)
.filter(
LoyaltyTransaction.merchant_id == merchant_id,
LoyaltyTransaction.transaction_at >= start_date,
LoyaltyTransaction.transaction_type.in_(
[
TransactionType.POINTS_EARNED.value,
TransactionType.STAMP_EARNED.value,
]
),
LoyaltyTransaction.points_delta > 0,
LoyaltyTransaction.store_id.isnot(None),
)
.group_by(LoyaltyTransaction.store_id)
.all()
)
by_store = []
for row in store_rows:
store = store_service.get_store_by_id_optional(db, row.store_id)
by_store.append(
{
"store_id": row.store_id,
"store_name": store.name if store else f"Store {row.store_id}",
"transactions_count": row.tx_count,
"total_points_earned": row.points_earned,
}
)
return {
"monthly": monthly,
"by_store": by_store,
"months_back": months_back,
}
# Singleton
analytics_service = AnalyticsService()

View File

@@ -624,6 +624,23 @@ class CardService:
wallet_service.create_wallet_objects(db, card)
# Send notification emails (async via Celery)
try:
from app.modules.loyalty.services.notification_service import (
notification_service,
)
notification_service.send_enrollment_confirmation(db, card)
if program.welcome_bonus_points > 0:
notification_service.send_welcome_bonus(
db, card, program.welcome_bonus_points
)
except Exception:
logger.warning(
f"Failed to queue enrollment notification for card {card.id}",
exc_info=True,
)
logger.info(
f"Enrolled customer {customer_id} in merchant {merchant_id} loyalty program "
f"(card: {card.card_number}, bonus: {program.welcome_bonus_points} pts)"
@@ -790,6 +807,7 @@ class CardService:
*,
skip: int = 0,
limit: int = 20,
lang: str = "en",
) -> tuple[list[dict], int]:
"""
Get transaction history for a card with store names resolved.
@@ -819,6 +837,8 @@ class CardService:
"transaction_at": tx.transaction_at.isoformat() if tx.transaction_at else None,
"notes": tx.notes,
"store_name": None,
"category_ids": tx.category_ids,
"category_names": None,
}
if tx.store_id:
@@ -826,10 +846,166 @@ class CardService:
if store_obj:
tx_data["store_name"] = store_obj.name
if tx.category_ids and isinstance(tx.category_ids, list):
from app.modules.loyalty.services.category_service import (
category_service,
)
names = []
for cid in tx.category_ids:
name = category_service.validate_category_for_store(
db, cid, tx.store_id or 0, lang=lang
)
if name:
names.append(name)
tx_data["category_names"] = names if names else None
tx_responses.append(tx_data)
return tx_responses, total
# =========================================================================
# Admin Operations
# =========================================================================
def anonymize_cards_for_customer(
self,
db: Session,
customer_id: int,
admin_user_id: int,
) -> int:
"""
GDPR anonymization: null out customer_id and scrub PII on all
loyalty cards belonging to this customer.
Transaction rows are kept for aggregate reporting but notes
containing PII are scrubbed.
Args:
customer_id: Customer to anonymize
admin_user_id: Admin performing the action (for audit)
Returns:
Number of cards anonymized
"""
cards = (
db.query(LoyaltyCard)
.filter(LoyaltyCard.customer_id == customer_id)
.all()
)
if not cards:
return 0
now = datetime.now(UTC)
count = 0
for card in cards:
# Create audit transaction before nulling customer_id
db.add(
LoyaltyTransaction(
card_id=card.id,
merchant_id=card.merchant_id,
transaction_type=TransactionType.ADMIN_ADJUSTMENT.value,
notes=f"GDPR anonymization by admin {admin_user_id}",
transaction_at=now,
)
)
# Null the customer reference
card.customer_id = None
card.is_active = False
count += 1
# Scrub notes on existing transactions that might contain PII
db.query(LoyaltyTransaction).filter(
LoyaltyTransaction.card_id.in_([c.id for c in cards]),
LoyaltyTransaction.notes.isnot(None),
).update(
{LoyaltyTransaction.notes: "GDPR scrubbed"},
synchronize_session=False,
)
db.commit()
logger.info(
f"GDPR: anonymized {count} cards for customer {customer_id} "
f"by admin {admin_user_id}"
)
return count
def bulk_deactivate_cards(
self,
db: Session,
card_ids: list[int],
merchant_id: int,
reason: str,
) -> int:
"""
Deactivate multiple cards at once.
Only deactivates cards belonging to the specified merchant.
Returns:
Number of cards deactivated
"""
now = datetime.now(UTC)
cards = (
db.query(LoyaltyCard)
.filter(
LoyaltyCard.id.in_(card_ids),
LoyaltyCard.merchant_id == merchant_id,
LoyaltyCard.is_active == True, # noqa: E712
)
.all()
)
for card in cards:
card.is_active = False
db.add(
LoyaltyTransaction(
card_id=card.id,
merchant_id=merchant_id,
transaction_type=TransactionType.ADMIN_ADJUSTMENT.value,
notes=f"Bulk deactivation: {reason}",
transaction_at=now,
)
)
db.commit()
logger.info(
f"Bulk deactivated {len(cards)} cards for merchant {merchant_id}: {reason}"
)
return len(cards)
def restore_deleted_cards(
self,
db: Session,
merchant_id: int,
) -> int:
"""Restore all soft-deleted cards for a merchant.
Returns number of cards restored.
"""
from sqlalchemy import update
result = db.execute(
update(LoyaltyCard)
.where(
LoyaltyCard.merchant_id == merchant_id,
LoyaltyCard.deleted_at.isnot(None),
)
.values(deleted_at=None, deleted_by_id=None)
.execution_options(include_deleted=True)
)
db.commit()
count = result.rowcount
if count:
logger.info(f"Restored {count} soft-deleted cards for merchant {merchant_id}")
return count
# Singleton instance
card_service = CardService()

View File

@@ -0,0 +1,162 @@
# app/modules/loyalty/services/category_service.py
"""
Transaction category CRUD service.
Store-scoped categories (e.g., Men, Women, Accessories) that sellers
select when entering loyalty transactions.
"""
import logging
from sqlalchemy.orm import Session
from app.modules.loyalty.models.transaction_category import StoreTransactionCategory
from app.modules.loyalty.schemas.category import CategoryCreate, CategoryUpdate
logger = logging.getLogger(__name__)
MAX_CATEGORIES_PER_STORE = 10
class CategoryService:
"""CRUD operations for store transaction categories."""
def list_categories(
self, db: Session, store_id: int, active_only: bool = False
) -> list[StoreTransactionCategory]:
"""List categories for a store, ordered by display_order."""
query = db.query(StoreTransactionCategory).filter(
StoreTransactionCategory.store_id == store_id
)
if active_only:
query = query.filter(StoreTransactionCategory.is_active == True) # noqa: E712
return query.order_by(StoreTransactionCategory.display_order).all()
def create_category(
self, db: Session, store_id: int, data: CategoryCreate
) -> StoreTransactionCategory:
"""Create a new category for a store."""
# Check max limit
count = (
db.query(StoreTransactionCategory)
.filter(StoreTransactionCategory.store_id == store_id)
.count()
)
if count >= MAX_CATEGORIES_PER_STORE:
from app.modules.loyalty.exceptions import LoyaltyException
raise LoyaltyException(
message=f"Maximum {MAX_CATEGORIES_PER_STORE} categories per store",
error_code="MAX_CATEGORIES_REACHED",
)
# Check duplicate name
existing = (
db.query(StoreTransactionCategory)
.filter(
StoreTransactionCategory.store_id == store_id,
StoreTransactionCategory.name == data.name,
)
.first()
)
if existing:
from app.modules.loyalty.exceptions import LoyaltyException
raise LoyaltyException(
message=f"Category '{data.name}' already exists",
error_code="DUPLICATE_CATEGORY",
)
category = StoreTransactionCategory(
store_id=store_id,
name=data.name,
display_order=data.display_order,
)
db.add(category)
db.commit()
db.refresh(category)
logger.info(f"Created category '{data.name}' for store {store_id}")
return category
def update_category(
self,
db: Session,
category_id: int,
store_id: int,
data: CategoryUpdate,
) -> StoreTransactionCategory:
"""Update a category (ownership check via store_id)."""
category = (
db.query(StoreTransactionCategory)
.filter(
StoreTransactionCategory.id == category_id,
StoreTransactionCategory.store_id == store_id,
)
.first()
)
if not category:
from app.modules.loyalty.exceptions import LoyaltyException
raise LoyaltyException(
message="Category not found",
error_code="CATEGORY_NOT_FOUND",
status_code=404,
)
update_data = data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(category, field, value)
db.commit()
db.refresh(category)
return category
def delete_category(
self, db: Session, category_id: int, store_id: int
) -> None:
"""Delete a category (ownership check via store_id)."""
category = (
db.query(StoreTransactionCategory)
.filter(
StoreTransactionCategory.id == category_id,
StoreTransactionCategory.store_id == store_id,
)
.first()
)
if not category:
from app.modules.loyalty.exceptions import LoyaltyException
raise LoyaltyException(
message="Category not found",
error_code="CATEGORY_NOT_FOUND",
status_code=404,
)
db.delete(category)
db.commit()
logger.info(f"Deleted category {category_id} from store {store_id}")
def validate_category_for_store(
self, db: Session, category_id: int, store_id: int, lang: str = "en"
) -> str | None:
"""Validate that a category belongs to the store.
Returns the translated category name if valid, None if not found.
"""
category = (
db.query(StoreTransactionCategory)
.filter(
StoreTransactionCategory.id == category_id,
StoreTransactionCategory.store_id == store_id,
StoreTransactionCategory.is_active == True, # noqa: E712
)
.first()
)
if not category:
return None
return category.get_translated_name(lang)
# Singleton
category_service = CategoryService()

View File

@@ -0,0 +1,81 @@
# app/modules/loyalty/services/loyalty_widgets.py
"""
Loyalty dashboard widget provider.
Provides storefront dashboard cards for loyalty-related data.
Implements get_storefront_dashboard_cards from DashboardWidgetProviderProtocol.
"""
import logging
from sqlalchemy.orm import Session
from app.modules.contracts.widgets import (
DashboardWidget,
StorefrontDashboardCard,
WidgetContext,
)
logger = logging.getLogger(__name__)
class LoyaltyWidgetProvider:
"""Widget provider for loyalty module."""
@property
def widgets_category(self) -> str:
return "loyalty"
def get_store_widgets(
self,
db: Session,
store_id: int,
context: WidgetContext | None = None,
) -> list[DashboardWidget]:
return []
def get_platform_widgets(
self,
db: Session,
platform_id: int,
context: WidgetContext | None = None,
) -> list[DashboardWidget]:
return []
def get_storefront_dashboard_cards(
self,
db: Session,
store_id: int,
customer_id: int,
context: WidgetContext | None = None,
) -> list[StorefrontDashboardCard]:
"""Provide the Loyalty Rewards card for the customer dashboard."""
from app.modules.loyalty.models.loyalty_card import LoyaltyCard
card = (
db.query(LoyaltyCard)
.filter(
LoyaltyCard.customer_id == customer_id,
LoyaltyCard.is_active.is_(True),
)
.first()
)
points = card.points_balance if card else None
subtitle = "View your points & rewards" if card else "Join our rewards program"
return [
StorefrontDashboardCard(
key="loyalty.rewards",
icon="gift",
title="Loyalty Rewards",
subtitle=subtitle,
route="account/loyalty",
value=points,
value_label="Points Balance" if points is not None else None,
order=30,
),
]
loyalty_widget_provider = LoyaltyWidgetProvider()

View File

@@ -0,0 +1,137 @@
# app/modules/loyalty/services/notification_service.py
"""
Loyalty notification service.
Thin wrapper that resolves customer/card/program data into template
variables and dispatches emails asynchronously via the Celery task.
"""
import logging
from sqlalchemy.orm import Session
from app.modules.loyalty.models import LoyaltyCard
logger = logging.getLogger(__name__)
class LoyaltyNotificationService:
"""Dispatches loyalty email notifications."""
def _resolve_context(self, db: Session, card: LoyaltyCard) -> dict | None:
"""Load customer, store, and program info for a card.
Returns None if the customer has no email (can't send).
"""
from app.modules.customers.services.customer_service import customer_service
from app.modules.tenancy.services.store_service import store_service
customer = customer_service.get_customer_by_id(db, card.customer_id)
if not customer or not customer.email:
return None
store = store_service.get_store_by_id_optional(db, card.enrolled_at_store_id)
program = card.program
return {
"customer": customer,
"store": store,
"program": program,
"to_email": customer.email,
"to_name": customer.full_name,
"store_id": card.enrolled_at_store_id,
"customer_id": customer.id,
"customer_name": customer.full_name,
"program_name": program.display_name if program else "Loyalty Program",
"store_name": store.name if store else "",
}
def _dispatch(
self, template_code: str, ctx: dict, extra_vars: dict | None = None
):
"""Enqueue a notification email via Celery."""
from app.modules.loyalty.tasks.notifications import send_notification_email
variables = {
"customer_name": ctx["customer_name"],
"program_name": ctx["program_name"],
"store_name": ctx["store_name"],
}
if extra_vars:
variables.update(extra_vars)
send_notification_email.delay(
template_code=template_code,
to_email=ctx["to_email"],
to_name=ctx["to_name"],
variables=variables,
store_id=ctx["store_id"],
customer_id=ctx["customer_id"],
)
logger.info(
f"Queued {template_code} for {ctx['to_email']}"
)
def send_enrollment_confirmation(self, db: Session, card: LoyaltyCard):
"""Send enrollment confirmation email."""
ctx = self._resolve_context(db, card)
if not ctx:
return
self._dispatch("loyalty_enrollment", ctx, {
"card_number": card.card_number,
})
def send_welcome_bonus(self, db: Session, card: LoyaltyCard, points: int):
"""Send welcome bonus notification (only if points > 0)."""
if points <= 0:
return
ctx = self._resolve_context(db, card)
if not ctx:
return
self._dispatch("loyalty_welcome_bonus", ctx, {
"points": str(points),
})
def send_points_expiration_warning(
self,
db: Session,
card: LoyaltyCard,
expiring_points: int,
days_remaining: int,
expiration_date: str,
):
"""Send points expiring warning email."""
ctx = self._resolve_context(db, card)
if not ctx:
return
self._dispatch("loyalty_points_expiring", ctx, {
"points": str(expiring_points),
"days_remaining": str(days_remaining),
"expiration_date": expiration_date,
})
def send_points_expired(
self, db: Session, card: LoyaltyCard, expired_points: int
):
"""Send points expired notification email."""
ctx = self._resolve_context(db, card)
if not ctx:
return
self._dispatch("loyalty_points_expired", ctx, {
"expired_points": str(expired_points),
})
def send_reward_available(
self, db: Session, card: LoyaltyCard, reward_name: str
):
"""Send reward earned notification email."""
ctx = self._resolve_context(db, card)
if not ctx:
return
self._dispatch("loyalty_reward_ready", ctx, {
"reward_name": reward_name,
})
# Singleton
notification_service = LoyaltyNotificationService()

View File

@@ -23,6 +23,7 @@ from app.modules.loyalty.exceptions import (
InsufficientPointsException,
InvalidRewardException,
LoyaltyCardInactiveException,
LoyaltyException,
LoyaltyProgramInactiveException,
OrderReferenceRequiredException,
StaffPinRequiredException,
@@ -48,6 +49,7 @@ class PointsService:
purchase_amount_cents: int,
order_reference: str | None = None,
staff_pin: str | None = None,
category_ids: list[int] | None = None,
ip_address: str | None = None,
user_agent: str | None = None,
notes: str | None = None,
@@ -101,6 +103,19 @@ class PointsService:
if settings and settings.require_order_reference and not order_reference:
raise OrderReferenceRequiredException()
# Category is mandatory when the store has categories configured
if not category_ids:
from app.modules.loyalty.services.category_service import category_service
store_categories = category_service.list_categories(
db, store_id, active_only=True
)
if store_categories:
raise LoyaltyException(
message="Please select a product category",
error_code="CATEGORY_REQUIRED",
)
# Idempotency guard: if same order_reference already earned points on this card, return existing result
if order_reference:
existing_tx = (
@@ -181,6 +196,7 @@ class PointsService:
card_id=card.id,
store_id=store_id,
staff_pin_id=verified_pin.id if verified_pin else None,
category_ids=category_ids,
transaction_type=TransactionType.POINTS_EARNED.value,
points_delta=points_earned,
stamps_balance_after=card.stamp_count,

View File

@@ -1127,5 +1127,30 @@ class ProgramService:
return stats
def restore_deleted_programs(self, db: Session, merchant_id: int) -> int:
"""Restore all soft-deleted programs for a merchant.
Returns number of programs restored.
"""
from sqlalchemy import update
result = db.execute(
update(LoyaltyProgram)
.where(
LoyaltyProgram.merchant_id == merchant_id,
LoyaltyProgram.deleted_at.isnot(None),
)
.values(deleted_at=None, deleted_by_id=None)
.execution_options(include_deleted=True)
)
db.commit()
count = result.rowcount
if count:
logger.info(
f"Restored {count} soft-deleted programs for merchant {merchant_id}"
)
return count
# Singleton instance
program_service = ProgramService()

View File

@@ -46,6 +46,7 @@ class StampService:
qr_code: str | None = None,
card_number: str | None = None,
staff_pin: str | None = None,
category_ids: list[int] | None = None,
ip_address: str | None = None,
user_agent: str | None = None,
notes: str | None = None,
@@ -143,6 +144,7 @@ class StampService:
card_id=card.id,
store_id=store_id,
staff_pin_id=verified_pin.id if verified_pin else None,
category_ids=category_ids,
transaction_type=TransactionType.STAMP_EARNED.value,
stamps_delta=1,
stamps_balance_after=card.stamp_count,
@@ -162,6 +164,22 @@ class StampService:
wallet_service.sync_card_to_wallets(db, card)
# Notify customer when they've earned a reward
if reward_earned:
try:
from app.modules.loyalty.services.notification_service import (
notification_service,
)
notification_service.send_reward_available(
db, card, program.stamps_reward_description or "Reward"
)
except Exception:
logger.warning(
f"Failed to queue reward notification for card {card.id}",
exc_info=True,
)
stamps_today += 1
logger.info(

View File

@@ -33,6 +33,18 @@ function adminLoyaltyMerchantDetail() {
settings: null,
locations: [],
// Transaction categories
selectedCategoryStoreId: '',
storeCategories: [],
showAddCategory: false,
newCategoryName: '',
newCategoryTranslations: { fr: '', de: '', lb: '' },
viewingCategoryId: null,
editingCategoryId: null,
showDeleteCategoryModal: false,
categoryToDelete: null,
editCategoryData: { name: '', translations: { fr: '', de: '', lb: '' } },
// State
loading: false,
error: null,
@@ -258,6 +270,103 @@ function adminLoyaltyMerchantDetail() {
formatNumber(num) {
if (num === null || num === undefined) return '0';
return new Intl.NumberFormat('en-US').format(num);
},
// Transaction categories
async loadCategoriesForStore() {
if (!this.selectedCategoryStoreId) {
this.storeCategories = [];
return;
}
try {
const response = await apiClient.get(`/admin/loyalty/stores/${this.selectedCategoryStoreId}/categories`);
this.storeCategories = response?.categories || [];
} catch (error) {
loyaltyMerchantDetailLog.warn('Failed to load categories:', error.message);
this.storeCategories = [];
}
},
async createCategory() {
if (!this.newCategoryName || !this.selectedCategoryStoreId) return;
try {
// Build translations dict (only include non-empty values)
const translations = {};
if (this.newCategoryName) translations.en = this.newCategoryName;
for (const [lang, val] of Object.entries(this.newCategoryTranslations)) {
if (val) translations[lang] = val;
}
await apiClient.post(`/admin/loyalty/stores/${this.selectedCategoryStoreId}/categories`, {
name: this.newCategoryName,
name_translations: Object.keys(translations).length > 0 ? translations : null,
display_order: this.storeCategories.length,
});
this.newCategoryName = '';
this.newCategoryTranslations = { fr: '', de: '', lb: '' };
this.showAddCategory = false;
await this.loadCategoriesForStore();
Utils.showToast('Category created', 'success');
} catch (error) {
Utils.showToast(error.message || 'Failed to create category', 'error');
}
},
startEditCategory(cat) {
this.editingCategoryId = cat.id;
this.editCategoryData = {
name: cat.name,
translations: {
fr: cat.name_translations?.fr || '',
de: cat.name_translations?.de || '',
lb: cat.name_translations?.lb || '',
},
};
},
async saveEditCategory(catId) {
if (!this.editCategoryData.name) return;
try {
const translations = { en: this.editCategoryData.name };
for (const [lang, val] of Object.entries(this.editCategoryData.translations)) {
if (val) translations[lang] = val;
}
await apiClient.patch(`/admin/loyalty/stores/${this.selectedCategoryStoreId}/categories/${catId}`, {
name: this.editCategoryData.name,
name_translations: Object.keys(translations).length > 0 ? translations : null,
});
this.editingCategoryId = null;
await this.loadCategoriesForStore();
Utils.showToast('Category updated', 'success');
} catch (error) {
Utils.showToast(error.message || 'Failed to update category', 'error');
}
},
async toggleCategoryActive(cat) {
try {
await apiClient.patch(`/admin/loyalty/stores/${this.selectedCategoryStoreId}/categories/${cat.id}`, {
is_active: !cat.is_active,
});
await this.loadCategoriesForStore();
} catch (error) {
Utils.showToast(error.message || 'Failed to update category', 'error');
}
},
async confirmDeleteCategory() {
if (!this.categoryToDelete) return;
try {
await apiClient.delete(`/admin/loyalty/stores/${this.selectedCategoryStoreId}/categories/${this.categoryToDelete}`);
await this.loadCategoriesForStore();
Utils.showToast('Category deleted', 'success');
} catch (error) {
Utils.showToast(error.message || 'Failed to delete category', 'error');
} finally {
this.showDeleteCategoryModal = false;
this.categoryToDelete = null;
}
}
};
}

View File

@@ -24,6 +24,12 @@ function storeLoyaltyAnalytics() {
estimated_liability_cents: 0,
},
// Advanced analytics
cohortData: { cohorts: [] },
churnData: { at_risk_count: 0, cards: [] },
revenueData: { monthly: [], by_store: [] },
revenueChart: null,
loading: false,
error: null,
@@ -56,6 +62,7 @@ function storeLoyaltyAnalytics() {
await this.loadProgram();
if (this.program) {
await this.loadStats();
this.loadAdvancedAnalytics();
}
loyaltyAnalyticsLog.info('=== LOYALTY ANALYTICS PAGE INITIALIZATION COMPLETE ===');
},
@@ -99,6 +106,72 @@ function storeLoyaltyAnalytics() {
}
},
async loadAdvancedAnalytics() {
try {
const [cohort, churn, revenue] = await Promise.all([
apiClient.get('/store/loyalty/analytics/cohorts'),
apiClient.get('/store/loyalty/analytics/churn'),
apiClient.get('/store/loyalty/analytics/revenue'),
]);
if (cohort) this.cohortData = cohort;
if (churn) this.churnData = churn;
if (revenue) {
this.revenueData = revenue;
this.$nextTick(() => this.renderRevenueChart());
}
loyaltyAnalyticsLog.info('Advanced analytics loaded');
} catch (error) {
loyaltyAnalyticsLog.warn('Advanced analytics failed:', error.message);
}
},
renderRevenueChart() {
const canvas = document.getElementById('revenueChart');
if (!canvas || !window.Chart || !this.revenueData.monthly.length) return;
if (this.revenueChart) this.revenueChart.destroy();
const labels = this.revenueData.monthly.map(m => m.month);
const pointsData = this.revenueData.monthly.map(m => m.total_points_earned);
const customersData = this.revenueData.monthly.map(m => m.unique_customers);
this.revenueChart = new Chart(canvas, {
type: 'bar',
data: {
labels,
datasets: [
{
label: 'Points Earned',
data: pointsData,
backgroundColor: 'rgba(99, 102, 241, 0.7)',
borderRadius: 4,
yAxisID: 'y',
},
{
label: 'Active Customers',
data: customersData,
type: 'line',
borderColor: '#10b981',
backgroundColor: 'rgba(16, 185, 129, 0.1)',
fill: true,
tension: 0.3,
yAxisID: 'y1',
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: { legend: { position: 'bottom' } },
scales: {
y: { position: 'left', title: { display: true, text: 'Points' } },
y1: { position: 'right', title: { display: true, text: 'Customers' }, grid: { drawOnChartArea: false } },
},
},
});
},
formatNumber(num) {
if (num === null || num === undefined) return '0';
return new Intl.NumberFormat('en-US').format(num);

View File

@@ -11,6 +11,7 @@ function storeLoyaltyCardDetail() {
cardId: null,
card: null,
transactions: [],
pagination: { page: 1, per_page: 20, total: 0 },
loading: false,
error: null,
@@ -38,6 +39,13 @@ function storeLoyaltyCardDetail() {
return;
}
// Use platform pagination setting if available
if (window.PlatformSettings) {
try {
this.pagination.per_page = await window.PlatformSettings.getRowsPerPage();
} catch (e) { /* use default */ }
}
await this.loadData();
loyaltyCardDetailLog.info('=== LOYALTY CARD DETAIL PAGE INITIALIZATION COMPLETE ===');
},
@@ -67,18 +75,49 @@ function storeLoyaltyCardDetail() {
}
},
async loadTransactions() {
async loadTransactions(page = 1) {
try {
const response = await apiClient.get(`/store/loyalty/cards/${this.cardId}/transactions?limit=50`);
const skip = (page - 1) * this.pagination.per_page;
const response = await apiClient.get(
`/store/loyalty/cards/${this.cardId}/transactions?skip=${skip}&limit=${this.pagination.per_page}`
);
if (response && response.transactions) {
this.transactions = response.transactions;
loyaltyCardDetailLog.info(`Loaded ${this.transactions.length} transactions`);
this.pagination.total = response.total || 0;
this.pagination.page = page;
loyaltyCardDetailLog.info(`Loaded ${this.transactions.length} of ${this.pagination.total} transactions (page ${page})`);
}
} catch (error) {
loyaltyCardDetailLog.warn('Failed to load transactions:', error.message);
}
},
// Standard pagination interface (matches shared pagination macro)
get totalPages() {
return Math.max(1, Math.ceil(this.pagination.total / this.pagination.per_page));
},
get startIndex() {
if (this.pagination.total === 0) return 0;
return (this.pagination.page - 1) * this.pagination.per_page + 1;
},
get endIndex() {
return Math.min(this.pagination.page * this.pagination.per_page, this.pagination.total);
},
get pageNumbers() {
const pages = [];
for (let i = 1; i <= this.totalPages; i++) {
if (i === 1 || i === this.totalPages || Math.abs(i - this.pagination.page) <= 1) {
pages.push(i);
} else if (pages[pages.length - 1] !== '...') {
pages.push('...');
}
}
return pages;
},
previousPage() { if (this.pagination.page > 1) this.loadTransactions(this.pagination.page - 1); },
nextPage() { if (this.pagination.page < this.totalPages) this.loadTransactions(this.pagination.page + 1); },
goToPage(p) { if (p >= 1 && p <= this.totalPages) this.loadTransactions(p); },
formatNumber(num) {
return num == null ? '0' : new Intl.NumberFormat('en-US').format(num);
},

View File

@@ -31,6 +31,8 @@ function storeLoyaltyTerminal() {
// Transaction inputs
earnAmount: null,
selectedReward: '',
selectedCategories: [],
categories: [],
// PIN entry
showPinEntry: false,
@@ -63,6 +65,7 @@ function storeLoyaltyTerminal() {
}
await this.loadData();
await this.loadCategories();
loyaltyTerminalLog.info('=== LOYALTY TERMINAL INITIALIZATION COMPLETE ===');
},
@@ -279,13 +282,25 @@ function storeLoyaltyTerminal() {
}
},
// Load categories for this store
async loadCategories() {
try {
const response = await apiClient.get('/store/loyalty/categories');
this.categories = (response?.categories || []).filter(c => c.is_active);
loyaltyTerminalLog.info(`Loaded ${this.categories.length} categories`);
} catch (error) {
loyaltyTerminalLog.warn('Failed to load categories:', error.message);
}
},
// Add stamp
async addStamp() {
loyaltyTerminalLog.info('Adding stamp...');
await apiClient.post('/store/loyalty/stamp', {
card_id: this.selectedCard.card_id,
staff_pin: this.pinDigits
card_id: this.selectedCard.id,
staff_pin: this.pinDigits,
category_ids: this.selectedCategories.length > 0 ? this.selectedCategories : undefined,
});
Utils.showToast(I18n.t('loyalty.store.terminal.stamp_added'), 'success');
@@ -296,8 +311,8 @@ function storeLoyaltyTerminal() {
loyaltyTerminalLog.info('Redeeming stamps...');
await apiClient.post('/store/loyalty/stamp/redeem', {
card_id: this.selectedCard.card_id,
staff_pin: this.pinDigits
card_id: this.selectedCard.id,
staff_pin: this.pinDigits,
});
Utils.showToast(I18n.t('loyalty.store.terminal.stamps_redeemed'), 'success');
@@ -308,9 +323,10 @@ function storeLoyaltyTerminal() {
loyaltyTerminalLog.info('Earning points...', { amount: this.earnAmount });
const response = await apiClient.post('/store/loyalty/points/earn', {
card_id: this.selectedCard.card_id,
card_id: this.selectedCard.id,
purchase_amount_cents: Math.round(this.earnAmount * 100),
staff_pin: this.pinDigits
staff_pin: this.pinDigits,
category_ids: this.selectedCategories.length > 0 ? this.selectedCategories : undefined,
});
const pointsEarned = response.points_earned || Math.floor(this.earnAmount * (this.program?.points_per_euro || 1));
@@ -327,9 +343,9 @@ function storeLoyaltyTerminal() {
loyaltyTerminalLog.info('Redeeming reward...', { reward: reward.name });
await apiClient.post('/store/loyalty/points/redeem', {
card_id: this.selectedCard.card_id,
card_id: this.selectedCard.id,
reward_id: this.selectedReward,
staff_pin: this.pinDigits
staff_pin: this.pinDigits,
});
Utils.showToast(I18n.t('loyalty.store.terminal.reward_redeemed', {name: reward.name}), 'success');
@@ -340,7 +356,7 @@ function storeLoyaltyTerminal() {
// Refresh card data
async refreshCard() {
try {
const response = await apiClient.get(`/store/loyalty/cards/${this.selectedCard.card_id}`);
const response = await apiClient.get(`/store/loyalty/cards/${this.selectedCard.id}`);
if (response) {
this.selectedCard = response;
}
@@ -353,9 +369,11 @@ function storeLoyaltyTerminal() {
getTransactionLabel(tx) {
const type = tx.transaction_type;
if (type) {
return I18n.t('loyalty.transactions.' + type, {defaultValue: type.replace(/_/g, ' ')});
// Use server-rendered labels (no async flicker)
if (window._txLabels && window._txLabels[type]) return window._txLabels[type];
return type.replace(/_/g, ' ');
}
return I18n.t('loyalty.common.unknown');
return 'Unknown';
},
getTransactionColor(tx) {

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

@@ -0,0 +1,72 @@
# app/modules/loyalty/tasks/notifications.py
"""
Async email notification dispatch for loyalty events.
All loyalty notification emails are sent asynchronously via this Celery
task to avoid blocking request handlers. The task opens its own DB
session and calls EmailService.send_template() which handles language
resolution, store overrides, Jinja2 rendering, and EmailLog creation.
"""
import logging
from celery import shared_task
logger = logging.getLogger(__name__)
@shared_task(
name="loyalty.send_notification_email",
bind=True,
max_retries=3,
default_retry_delay=60,
)
def send_notification_email(
self,
template_code: str,
to_email: str,
to_name: str | None = None,
variables: dict | None = None,
store_id: int | None = None,
customer_id: int | None = None,
language: str | None = None,
):
"""
Send a loyalty notification email asynchronously.
Args:
template_code: Email template code (e.g. 'loyalty_enrollment')
to_email: Recipient email address
to_name: Recipient display name
variables: Template variables dict
store_id: Store ID for branding and template overrides
customer_id: Customer ID for language resolution
language: Explicit language override (otherwise auto-resolved)
"""
from app.core.database import SessionLocal
from app.modules.messaging.services.email_service import EmailService
db = SessionLocal()
try:
email_service = EmailService(db)
email_log = email_service.send_template(
template_code=template_code,
to_email=to_email,
to_name=to_name,
language=language,
variables=variables or {},
store_id=store_id,
customer_id=customer_id,
)
logger.info(
f"Loyalty notification sent: {template_code} to {to_email} "
f"(log_id={email_log.id if email_log else 'none'})"
)
except Exception as exc:
logger.error(
f"Loyalty notification failed: {template_code} to {to_email}: {exc}"
)
db.rollback()
raise self.retry(exc=exc)
finally:
db.close()

View File

@@ -6,12 +6,16 @@ Handles expiring points that are older than the configured
expiration period based on card inactivity.
Runs daily at 02:00 via the scheduled task configuration in definition.py.
Processing is chunked (LIMIT 500 + FOR UPDATE SKIP LOCKED) to avoid
holding long-running row locks on the loyalty_cards table.
"""
import logging
from datetime import UTC, datetime, timedelta
from celery import shared_task
from sqlalchemy import or_
from sqlalchemy.orm import Session
from app.core.database import SessionLocal
@@ -20,6 +24,8 @@ from app.modules.loyalty.models.loyalty_transaction import TransactionType
logger = logging.getLogger(__name__)
CHUNK_SIZE = 500
@shared_task(name="loyalty.expire_points")
def expire_points() -> dict:
@@ -27,10 +33,9 @@ def expire_points() -> dict:
Expire points that are past their expiration date based on card inactivity.
For each program with points_expiration_days configured:
1. Find cards that haven't had activity in the expiration period
2. Expire all points on those cards
3. Create POINTS_EXPIRED transaction records
4. Update card balances
1. Send 14-day warning emails to cards approaching expiry
2. Expire points in chunks of 500, committing after each chunk
3. Send expired notifications
Returns:
Summary of expired points
@@ -40,10 +45,10 @@ def expire_points() -> dict:
db: Session = SessionLocal()
try:
result = _process_point_expiration(db)
db.commit()
logger.info(
f"Point expiration complete: {result['cards_processed']} cards, "
f"{result['points_expired']} points expired"
f"{result['points_expired']} points expired, "
f"{result['warnings_sent']} warnings sent"
)
return result
except Exception as e:
@@ -54,30 +59,23 @@ def expire_points() -> dict:
"error": str(e),
"cards_processed": 0,
"points_expired": 0,
"warnings_sent": 0,
}
finally:
db.close()
def _process_point_expiration(db: Session) -> dict:
"""
Process point expiration for all programs.
Args:
db: Database session
Returns:
Summary of expired points
"""
total_cards_processed = 0
total_points_expired = 0
"""Process point expiration for all programs."""
total_cards = 0
total_points = 0
total_warnings = 0
programs_processed = 0
# Find all active programs with point expiration configured
programs = (
db.query(LoyaltyProgram)
.filter(
LoyaltyProgram.is_active == True,
LoyaltyProgram.is_active == True, # noqa: E712
LoyaltyProgram.points_expiration_days.isnot(None),
LoyaltyProgram.points_expiration_days > 0,
)
@@ -87,104 +85,238 @@ def _process_point_expiration(db: Session) -> dict:
logger.info(f"Found {len(programs)} programs with point expiration configured")
for program in programs:
cards_count, points_count = _expire_points_for_program(db, program)
total_cards_processed += cards_count
total_points_expired += points_count
cards, points, warnings = _process_program(db, program)
total_cards += cards
total_points += points
total_warnings += warnings
programs_processed += 1
logger.debug(
f"Program {program.id} (merchant {program.merchant_id}): "
f"{cards_count} cards, {points_count} points expired"
)
return {
"status": "success",
"programs_processed": programs_processed,
"cards_processed": total_cards_processed,
"points_expired": total_points_expired,
"cards_processed": total_cards,
"points_expired": total_points,
"warnings_sent": total_warnings,
}
def _expire_points_for_program(db: Session, program: LoyaltyProgram) -> tuple[int, int]:
"""
Expire points for a specific loyalty program.
def _process_program(
db: Session, program: LoyaltyProgram
) -> tuple[int, int, int]:
"""Process warnings + expiration for a single program.
Args:
db: Database session
program: Loyalty program to process
Returns:
Tuple of (cards_processed, points_expired)
Returns (cards_expired, points_expired, warnings_sent).
"""
if not program.points_expiration_days:
return 0, 0
return 0, 0, 0
# Calculate expiration threshold
expiration_threshold = datetime.now(UTC) - timedelta(days=program.points_expiration_days)
now = datetime.now(UTC)
expiration_threshold = now - timedelta(days=program.points_expiration_days)
logger.debug(
f"Processing program {program.id}: expiration after {program.points_expiration_days} days "
f"(threshold: {expiration_threshold})"
# --- Phase 1: Send 14-day warning emails (chunked) ---
warning_days = 14
warning_threshold = now - timedelta(
days=program.points_expiration_days - warning_days
)
warnings_sent = _send_expiration_warnings_chunked(
db, program, warning_threshold, expiration_threshold, warning_days, now
)
# Find cards with:
# - Points balance > 0
# - Last activity before expiration threshold
# - Belonging to this program's merchant
cards_to_expire = (
db.query(LoyaltyCard)
.filter(
LoyaltyCard.merchant_id == program.merchant_id,
LoyaltyCard.points_balance > 0,
LoyaltyCard.last_activity_at < expiration_threshold,
LoyaltyCard.is_active == True,
)
.all()
# --- Phase 2: Expire points (chunked) ---
cards_expired, points_expired = _expire_points_chunked(
db, program, expiration_threshold, now
)
if not cards_to_expire:
logger.debug(f"No cards to expire for program {program.id}")
return 0, 0
return cards_expired, points_expired, warnings_sent
logger.info(f"Found {len(cards_to_expire)} cards to expire for program {program.id}")
cards_processed = 0
points_expired = 0
# =========================================================================
# Chunked expiration
# =========================================================================
for card in cards_to_expire:
if card.points_balance <= 0:
continue
expired_points = card.points_balance
def _expire_points_chunked(
db: Session,
program: LoyaltyProgram,
expiration_threshold: datetime,
now: datetime,
) -> tuple[int, int]:
"""Expire points in chunks to avoid long-held row locks.
# Create expiration transaction
transaction = LoyaltyTransaction(
card_id=card.id,
merchant_id=program.merchant_id,
store_id=None, # System action, no store
transaction_type=TransactionType.POINTS_EXPIRED.value,
points_delta=-expired_points,
points_balance_after=0,
stamps_delta=0,
stamps_balance_after=card.stamp_count,
notes=f"Points expired after {program.points_expiration_days} days of inactivity",
transaction_at=datetime.now(UTC),
)
db.add(transaction) # noqa: PERF006
Each chunk:
1. SELECT ... LIMIT 500 FOR UPDATE SKIP LOCKED
2. Create POINTS_EXPIRED transactions
3. Update card balances
4. Commit (releases locks for this chunk)
# Update card balance and voided tracking
card.expire_points(expired_points)
# Note: We don't update last_activity_at for expiration
Returns (total_cards, total_points).
"""
total_cards = 0
total_points = 0
cards_processed += 1
points_expired += expired_points
logger.debug(
f"Expired {expired_points} points from card {card.id} "
f"(last activity: {card.last_activity_at})"
while True:
# Fetch next chunk with row-level locks; SKIP LOCKED means
# concurrent workers won't block on the same rows.
card_ids_and_balances = (
db.query(LoyaltyCard.id, LoyaltyCard.points_balance, LoyaltyCard.stamp_count)
.filter(
LoyaltyCard.merchant_id == program.merchant_id,
LoyaltyCard.points_balance > 0,
LoyaltyCard.last_activity_at < expiration_threshold,
LoyaltyCard.is_active == True, # noqa: E712
)
.limit(CHUNK_SIZE)
.with_for_update(skip_locked=True)
.all()
)
return cards_processed, points_expired
if not card_ids_and_balances:
break
chunk_cards = 0
chunk_points = 0
for card_id, balance, stamp_count in card_ids_and_balances:
if balance <= 0:
continue
# Create expiration transaction
db.add(
LoyaltyTransaction(
card_id=card_id,
merchant_id=program.merchant_id,
store_id=None,
transaction_type=TransactionType.POINTS_EXPIRED.value,
points_delta=-balance,
points_balance_after=0,
stamps_delta=0,
stamps_balance_after=stamp_count,
notes=(
f"Points expired after {program.points_expiration_days} "
f"days of inactivity"
),
transaction_at=now,
)
)
# Bulk-update the card in the same transaction
db.query(LoyaltyCard).filter(LoyaltyCard.id == card_id).update(
{
LoyaltyCard.points_balance: 0,
LoyaltyCard.total_points_voided: (
LoyaltyCard.total_points_voided + balance
),
},
synchronize_session=False,
)
chunk_cards += 1
chunk_points += balance
# Commit this chunk — releases row locks
db.commit()
# Send notifications AFTER commit (outside the lock window)
for card_id, balance, _stamp_count in card_ids_and_balances:
if balance <= 0:
continue
try:
card = db.query(LoyaltyCard).get(card_id)
if card:
from app.modules.loyalty.services.notification_service import (
notification_service,
)
notification_service.send_points_expired(db, card, balance)
except Exception:
logger.warning(
f"Failed to queue expiration notification for card {card_id}",
exc_info=True,
)
total_cards += chunk_cards
total_points += chunk_points
logger.info(
f"Program {program.id}: expired chunk of {chunk_cards} cards "
f"({chunk_points} pts), total so far: {total_cards} cards"
)
return total_cards, total_points
# =========================================================================
# Chunked expiration warnings
# =========================================================================
def _send_expiration_warnings_chunked(
db: Session,
program: LoyaltyProgram,
warning_threshold: datetime,
expiration_threshold: datetime,
warning_days: int,
now: datetime,
) -> int:
"""Send expiration warning emails in chunks.
Only sends one warning per expiration cycle (tracked via
last_expiration_warning_at on the card).
"""
total_warnings = 0
expiration_date = (now + timedelta(days=warning_days)).strftime("%Y-%m-%d")
while True:
cards = (
db.query(LoyaltyCard)
.filter(
LoyaltyCard.merchant_id == program.merchant_id,
LoyaltyCard.points_balance > 0,
LoyaltyCard.is_active == True, # noqa: E712
LoyaltyCard.last_activity_at < warning_threshold,
LoyaltyCard.last_activity_at >= expiration_threshold,
or_(
LoyaltyCard.last_expiration_warning_at.is_(None),
LoyaltyCard.last_expiration_warning_at < warning_threshold,
),
)
.limit(CHUNK_SIZE)
.all()
)
if not cards:
break
chunk_warnings = 0
for card in cards:
try:
from app.modules.loyalty.services.notification_service import (
notification_service,
)
notification_service.send_points_expiration_warning(
db,
card,
expiring_points=card.points_balance,
days_remaining=warning_days,
expiration_date=expiration_date,
)
card.last_expiration_warning_at = now
chunk_warnings += 1
except Exception:
logger.warning(
f"Failed to queue expiration warning for card {card.id}",
exc_info=True,
)
db.commit()
total_warnings += chunk_warnings
if total_warnings:
logger.info(
f"Sent {total_warnings} expiration warnings for program {program.id}"
)
return total_warnings
# Allow running directly for testing

View File

@@ -4,14 +4,22 @@ Wallet synchronization task.
Handles syncing loyalty card data to Google Wallet and Apple Wallet
for cards that may have missed real-time updates.
Uses exponential backoff (1s, 4s, 16s) per card to handle transient
API failures without blocking the entire batch.
"""
import logging
import time
from celery import shared_task
logger = logging.getLogger(__name__)
# Exponential backoff delays in seconds: 1s, 4s, 16s
_RETRY_DELAYS = [1, 4, 16]
_MAX_ATTEMPTS = len(_RETRY_DELAYS) + 1 # 4 total attempts
@shared_task(name="loyalty.sync_wallet_passes")
def sync_wallet_passes() -> dict:
@@ -35,7 +43,6 @@ def sync_wallet_passes() -> dict:
# Find cards with transactions in the last hour that have wallet IDs
one_hour_ago = datetime.now(UTC) - timedelta(hours=1)
# Get card IDs with recent transactions
recent_tx_card_ids = (
db.query(LoyaltyTransaction.card_id)
.filter(LoyaltyTransaction.transaction_at >= one_hour_ago)
@@ -51,9 +58,9 @@ def sync_wallet_passes() -> dict:
"cards_checked": 0,
"google_synced": 0,
"apple_synced": 0,
"failed_card_ids": [],
}
# Get cards with wallet integrations
cards = (
db.query(LoyaltyCard)
.filter(
@@ -69,31 +76,21 @@ def sync_wallet_passes() -> dict:
failed_card_ids = []
for card in cards:
synced = False
for attempt in range(2): # 1 retry
try:
results = wallet_service.sync_card_to_wallets(db, card)
if results.get("google_wallet"):
google_synced += 1
if results.get("apple_wallet"):
apple_synced += 1
synced = True
break
except Exception as e:
if attempt == 0:
logger.warning(
f"Failed to sync card {card.id} (attempt 1/2), "
f"retrying in 2s: {e}"
)
import time
time.sleep(2)
else:
logger.error(
f"Failed to sync card {card.id} after 2 attempts: {e}"
)
if not synced:
success, google, apple = _sync_card_with_backoff(
wallet_service, db, card
)
if success:
google_synced += google
apple_synced += apple
else:
failed_card_ids.append(card.id)
if failed_card_ids:
logger.error(
f"Wallet sync: {len(failed_card_ids)} cards failed after "
f"{_MAX_ATTEMPTS} attempts each: {failed_card_ids}"
)
logger.info(
f"Wallet sync complete: {len(cards)} cards checked, "
f"{google_synced} Google, {apple_synced} Apple, "
@@ -113,6 +110,37 @@ def sync_wallet_passes() -> dict:
return {
"status": "error",
"error": str(e),
"failed_card_ids": [],
}
finally:
db.close()
def _sync_card_with_backoff(wallet_service, db, card) -> tuple[bool, int, int]:
"""Sync a single card with exponential backoff.
Returns (success, google_count, apple_count).
"""
last_error = None
for attempt in range(_MAX_ATTEMPTS):
try:
results = wallet_service.sync_card_to_wallets(db, card)
google = 1 if results.get("google_wallet") else 0
apple = 1 if results.get("apple_wallet") else 0
return True, google, apple
except Exception as e:
last_error = e
if attempt < len(_RETRY_DELAYS):
delay = _RETRY_DELAYS[attempt]
logger.warning(
f"Card {card.id} sync failed (attempt {attempt + 1}/"
f"{_MAX_ATTEMPTS}), retrying in {delay}s: {e}"
)
time.sleep(delay)
logger.error(
f"Card {card.id} sync failed after {_MAX_ATTEMPTS} attempts: "
f"{last_error}"
)
return False, 0, 0

View File

@@ -166,7 +166,7 @@
</a>
<a href="/admin/merchants"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-blue-600 bg-blue-100 rounded-lg hover:bg-blue-200 dark:text-blue-300 dark:bg-blue-900/30 dark:hover:bg-blue-900/50">
<span x-html="$icon('building-office', 'w-4 h-4 mr-2')"></span>
<span x-html="$icon('office-building', 'w-4 h-4 mr-2')"></span>
{{ _('loyalty.admin.analytics.manage_merchants') }}
</a>
</div>

View File

@@ -43,7 +43,7 @@
<a
:href="`/admin/merchants/${merchant?.id}?back=/admin/loyalty/merchants/${merchantId}`"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-gray-700 transition-colors duration-150 bg-gray-100 border border-gray-300 rounded-lg hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-600">
<span x-html="$icon('building-office', 'w-4 h-4 mr-2')"></span>
<span x-html="$icon('office-building', 'w-4 h-4 mr-2')"></span>
{{ _('loyalty.admin.merchant_detail.view_merchant') }}
</a>
<a x-show="program"
@@ -152,7 +152,7 @@
</div>
</div>
<!-- Delete Confirmation Modal -->
<!-- Delete Program Confirmation Modal -->
{{ confirm_modal(
'deleteProgramModal',
_('loyalty.admin.merchant_detail.delete_title'),
@@ -164,6 +164,18 @@
'danger'
) }}
<!-- Delete Category Confirmation Modal -->
{{ confirm_modal(
'deleteCategoryModal',
_('loyalty.common.delete'),
_('loyalty.admin.merchant_detail.delete_category_message'),
'confirmDeleteCategory()',
'showDeleteCategoryModal',
_('loyalty.common.delete'),
_('loyalty.common.cancel'),
'danger'
) }}
<!-- Location Breakdown -->
<div x-show="locations.length > 0" class="px-4 py-3 mb-8 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
@@ -201,6 +213,175 @@
{% endcall %}
</div>
<!-- Transaction Categories (per store) -->
<div x-show="locations.length > 0" class="px-4 py-3 mb-8 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
<span x-html="$icon('tag', 'inline w-5 h-5 mr-2')"></span>
{{ _('loyalty.admin.merchant_detail.transaction_categories') }}
</h3>
<!-- Store selector -->
<div class="mb-4">
<select x-model="selectedCategoryStoreId" @change="loadCategoriesForStore()"
class="w-full md:w-auto px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 dark:text-gray-300">
<option value="">{{ _('loyalty.admin.merchant_detail.select_store') }}</option>
<template x-for="loc in locations" :key="loc.store_id">
<option :value="loc.store_id" x-text="loc.store_name"></option>
</template>
</select>
</div>
<!-- Categories list -->
<div x-show="selectedCategoryStoreId">
<div class="flex items-center justify-between mb-3">
<p class="text-sm text-gray-500 dark:text-gray-400" x-text="storeCategories.length + ' categories'"></p>
<button @click="showAddCategory = true" type="button"
class="px-3 py-1.5 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700">
<span x-html="$icon('plus', 'inline w-4 h-4 mr-1')"></span>
{{ _('loyalty.common.add') }}
</button>
</div>
<!-- Add category inline form -->
<div x-show="showAddCategory" class="mb-4 p-4 border border-purple-200 dark:border-purple-800 rounded-lg bg-purple-50 dark:bg-purple-900/20">
<div class="grid gap-3 md:grid-cols-2 mb-3">
<div>
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">English (EN) <span class="text-red-500">*</span></label>
<input type="text" x-model="newCategoryName" maxlength="100" placeholder="e.g. Men"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 dark:text-gray-300">
</div>
<div>
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">French (FR) <span class="text-red-500">*</span></label>
<input type="text" x-model="newCategoryTranslations.fr" maxlength="100" placeholder="e.g. Hommes"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 dark:text-gray-300">
</div>
<div>
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">German (DE) <span class="text-red-500">*</span></label>
<input type="text" x-model="newCategoryTranslations.de" maxlength="100" placeholder="e.g. Herren"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 dark:text-gray-300">
</div>
<div>
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Luxembourgish (LB) <span class="text-red-500">*</span></label>
<input type="text" x-model="newCategoryTranslations.lb" maxlength="100" placeholder="e.g. Hären"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 dark:text-gray-300">
</div>
</div>
<div class="flex justify-end gap-3">
<button @click="showAddCategory = false; newCategoryName = ''; newCategoryTranslations = {fr:'',de:'',lb:''}" type="button"
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="createCategory()" :disabled="!newCategoryName || !newCategoryTranslations.fr || !newCategoryTranslations.de || !newCategoryTranslations.lb"
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50">
{{ _('loyalty.common.save') }}
</button>
</div>
</div>
<!-- Categories table -->
<div class="space-y-2">
<template x-for="cat in storeCategories" :key="cat.id">
<div class="border border-gray-200 dark:border-gray-700 rounded-lg">
<!-- List mode -->
<div x-show="viewingCategoryId !== cat.id && editingCategoryId !== cat.id" class="flex items-center justify-between p-3">
<div class="flex items-center gap-3">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300" x-text="cat.name"></span>
<span x-show="!cat.is_active" class="px-2 py-0.5 text-xs rounded-full bg-gray-100 text-gray-500 dark:bg-gray-700">{{ _('loyalty.common.inactive') }}</span>
</div>
<div class="flex items-center gap-2">
<button @click="viewingCategoryId = (viewingCategoryId === cat.id ? null : cat.id)" type="button"
aria-label="{{ _('loyalty.common.view') }}"
class="text-blue-500 hover:text-blue-700">
<span x-html="$icon('eye', 'w-4 h-4')"></span>
</button>
<button @click="toggleCategoryActive(cat)" type="button"
:aria-label="cat.is_active ? '{{ _('loyalty.common.deactivate') }}' : '{{ _('loyalty.common.activate') }}'"
class="text-sm" :class="cat.is_active ? 'text-orange-500 hover:text-orange-700' : 'text-green-500 hover:text-green-700'">
<span x-html="$icon(cat.is_active ? 'ban' : 'play', 'w-4 h-4')"></span>
</button>
<button @click="categoryToDelete = cat.id; showDeleteCategoryModal = true" type="button"
aria-label="{{ _('loyalty.common.delete') }}"
class="text-red-500 hover:text-red-700">
<span x-html="$icon('trash', 'w-4 h-4')"></span>
</button>
</div>
</div>
<!-- View mode (read-only) -->
<div x-show="viewingCategoryId === cat.id && editingCategoryId !== cat.id" class="p-3 bg-gray-50 dark:bg-gray-900/20">
<div class="grid gap-2 md:grid-cols-2 mb-3">
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">English (EN) <span class="text-red-500">*</span></p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="cat.name || '-'"></p>
</div>
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">French (FR) <span class="text-red-500">*</span></p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="cat.name_translations?.fr || '-'"></p>
</div>
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">German (DE) <span class="text-red-500">*</span></p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="cat.name_translations?.de || '-'"></p>
</div>
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">Luxembourgish (LB) <span class="text-red-500">*</span></p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="cat.name_translations?.lb || '-'"></p>
</div>
</div>
<div class="flex justify-end gap-2">
<button @click="viewingCategoryId = null" type="button"
class="px-3 py-1.5 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.close') }}
</button>
<button @click="viewingCategoryId = null; startEditCategory(cat)" type="button"
class="px-3 py-1.5 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700">
<span x-html="$icon('pencil', 'inline w-3.5 h-3.5 mr-1')"></span>
{{ _('loyalty.common.edit') }}
</button>
</div>
</div>
<!-- Edit mode -->
<div x-show="editingCategoryId === cat.id" class="p-3 bg-purple-50 dark:bg-purple-900/20">
<div class="grid gap-2 md:grid-cols-2 mb-3">
<div>
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">English (EN) <span class="text-red-500">*</span></label>
<input type="text" x-model="editCategoryData.name" maxlength="100"
class="w-full px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 dark:text-gray-300">
</div>
<div>
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">French (FR) <span class="text-red-500">*</span></label>
<input type="text" x-model="editCategoryData.translations.fr" maxlength="100"
class="w-full px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 dark:text-gray-300">
</div>
<div>
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">German (DE) <span class="text-red-500">*</span></label>
<input type="text" x-model="editCategoryData.translations.de" maxlength="100"
class="w-full px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 dark:text-gray-300">
</div>
<div>
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Luxembourgish (LB) <span class="text-red-500">*</span></label>
<input type="text" x-model="editCategoryData.translations.lb" maxlength="100"
class="w-full px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 dark:text-gray-300">
</div>
</div>
<div class="flex justify-end gap-2">
<button @click="editingCategoryId = null" type="button"
class="px-3 py-1.5 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="saveEditCategory(cat.id)" :disabled="!editCategoryData.name || !editCategoryData.translations.fr || !editCategoryData.translations.de || !editCategoryData.translations.lb"
class="px-3 py-1.5 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50">
{{ _('loyalty.common.save') }}
</button>
</div>
</div>
</div>
</template>
<p x-show="storeCategories.length === 0" class="text-sm text-gray-500 dark:text-gray-400 py-4 text-center">
{{ _('loyalty.admin.merchant_detail.no_categories') }}
</p>
</div>
</div>
</div>
<!-- Merchant Settings (Admin-controlled) -->
<div class="px-4 py-3 mb-8 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">

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,28 +224,28 @@
<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>
<span x-html="$icon(program.is_active ? 'ban' : 'play', 'w-5 h-5')"></span>
</button>
</div>
</td>

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

@@ -28,7 +28,7 @@
x-text="program?.loyalty_type || 'unknown'"></span>
<span class="inline-flex items-center px-2 py-1 text-xs font-semibold leading-tight rounded-full"
:class="program?.is_active ? 'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100' : 'text-gray-700 bg-gray-100 dark:text-gray-100 dark:bg-gray-700'">
<span x-text="program?.is_active ? $t('loyalty.common.active') : $t('loyalty.common.inactive')"></span>
<span x-text="program?.is_active ? '{{ _('loyalty.common.active') }}' : '{{ _('loyalty.common.inactive') }}'"></span>
</span>
{% if show_edit_button is not defined or show_edit_button %}
<a href="{{ edit_url }}"
@@ -92,22 +92,22 @@
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">{{ _('loyalty.shared.program_view.welcome_bonus') }}</p>
<p class="text-sm font-medium text-gray-700 dark:text-gray-300"
x-text="program?.welcome_bonus_points ? $t('loyalty.shared.program_view.x_points', {count: program.welcome_bonus_points}) : $t('loyalty.common.none')">-</p>
x-text="program?.welcome_bonus_points ? '{{ _('loyalty.shared.program_view.x_points') }}'.replace('{count}', program.welcome_bonus_points) : '{{ _('loyalty.common.none') }}'">-</p>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">{{ _('loyalty.shared.program_view.minimum_redemption') }}</p>
<p class="text-sm font-medium text-gray-700 dark:text-gray-300"
x-text="program?.minimum_redemption_points ? $t('loyalty.shared.program_view.x_points', {count: program.minimum_redemption_points}) : $t('loyalty.common.none')">-</p>
x-text="program?.minimum_redemption_points ? '{{ _('loyalty.shared.program_view.x_points') }}'.replace('{count}', program.minimum_redemption_points) : '{{ _('loyalty.common.none') }}'">-</p>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">{{ _('loyalty.shared.program_view.minimum_purchase') }}</p>
<p class="text-sm font-medium text-gray-700 dark:text-gray-300"
x-text="program?.minimum_purchase_cents ? '€' + (program.minimum_purchase_cents / 100).toFixed(2) : $t('loyalty.common.none')">-</p>
x-text="program?.minimum_purchase_cents ? '€' + (program.minimum_purchase_cents / 100).toFixed(2) : '{{ _('loyalty.common.none') }}'">-</p>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">{{ _('loyalty.shared.program_view.points_expiration') }}</p>
<p class="text-sm font-medium text-gray-700 dark:text-gray-300"
x-text="program?.points_expiration_days ? $t('loyalty.shared.program_view.x_days_inactivity', {days: program.points_expiration_days}) : $t('loyalty.common.never')">-</p>
x-text="program?.points_expiration_days ? '{{ _('loyalty.shared.program_view.x_days_inactivity') }}'.replace('{days}', program.points_expiration_days) : '{{ _('loyalty.common.never') }}'">-</p>
</div>
</div>
</div>
@@ -153,7 +153,7 @@
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">{{ _('loyalty.shared.program_view.cooldown') }}</p>
<p class="text-sm font-medium text-gray-700 dark:text-gray-300"
x-text="program?.cooldown_minutes ? $t('loyalty.shared.program_view.x_minutes', {count: program.cooldown_minutes}) : $t('loyalty.common.none')">-</p>
x-text="program?.cooldown_minutes ? '{{ _('loyalty.shared.program_view.x_minutes') }}'.replace('{count}', program.cooldown_minutes) : '{{ _('loyalty.common.none') }}'">-</p>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">{{ _('loyalty.shared.program_view.max_daily_stamps') }}</p>

View File

@@ -46,6 +46,90 @@
{% set show_merchants_metric = false %}
{% include "loyalty/shared/analytics-stats.html" %}
<!-- Advanced Analytics Charts -->
<div class="grid gap-6 md:grid-cols-2 mb-6">
<!-- Revenue Chart -->
<div class="px-4 py-5 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
<span x-html="$icon('chart-bar', 'inline w-5 h-5 mr-2')"></span>
{{ _('loyalty.store.analytics.revenue_title') }}
</h3>
<div x-show="revenueData.monthly.length > 0" style="height: 250px;">
<canvas id="revenueChart"></canvas>
</div>
<p x-show="revenueData.monthly.length === 0" class="text-sm text-gray-500 dark:text-gray-400 py-8 text-center">
{{ _('loyalty.store.analytics.no_data_yet') }}
</p>
</div>
<!-- Churn / At-Risk Cards -->
<div class="px-4 py-5 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
<span x-html="$icon('exclamation-triangle', 'inline w-5 h-5 mr-2')"></span>
{{ _('loyalty.store.analytics.at_risk_title') }}
</h3>
<div x-show="churnData.at_risk_count > 0">
<p class="text-sm text-gray-500 dark:text-gray-400 mb-3">
<span class="text-2xl font-bold text-orange-600" x-text="churnData.at_risk_count"></span>
{{ _('loyalty.store.analytics.cards_at_risk') }}
</p>
<div class="space-y-2 max-h-48 overflow-y-auto">
<template x-for="card in churnData.cards?.slice(0, 10)" :key="card.card_id">
<div class="flex items-center justify-between text-sm py-1 border-b border-gray-100 dark:border-gray-700">
<span class="text-gray-700 dark:text-gray-300" x-text="card.customer_name || card.card_number"></span>
<span class="text-orange-600 font-medium" x-text="card.days_inactive + 'd inactive'"></span>
</div>
</template>
</div>
</div>
<p x-show="churnData.at_risk_count === 0" class="text-sm text-green-600 dark:text-green-400 py-8 text-center">
{{ _('loyalty.store.analytics.no_at_risk') }}
</p>
</div>
</div>
<!-- Cohort Retention -->
<div class="px-4 py-5 bg-white rounded-lg shadow-md dark:bg-gray-800 mb-6">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
<span x-html="$icon('table-cells', 'inline w-5 h-5 mr-2')"></span>
{{ _('loyalty.store.analytics.cohort_title') }}
</h3>
<div x-show="cohortData.cohorts?.length > 0" class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr>
<th class="px-3 py-2 text-left text-gray-600 dark:text-gray-400">{{ _('loyalty.store.analytics.cohort_month') }}</th>
<th class="px-3 py-2 text-center text-gray-600 dark:text-gray-400">{{ _('loyalty.store.analytics.cohort_enrolled') }}</th>
<template x-for="(_, i) in Array(6)" :key="i">
<th class="px-3 py-2 text-center text-gray-600 dark:text-gray-400" x-text="'M' + i"></th>
</template>
</tr>
</thead>
<tbody>
<template x-for="cohort in cohortData.cohorts" :key="cohort.month">
<tr class="border-t border-gray-100 dark:border-gray-700">
<td class="px-3 py-2 font-medium text-gray-900 dark:text-white" x-text="cohort.month"></td>
<td class="px-3 py-2 text-center text-gray-600 dark:text-gray-400" x-text="cohort.enrolled"></td>
<template x-for="(pct, i) in cohort.retention.slice(0, 6)" :key="i">
<td class="px-3 py-2 text-center">
<span class="inline-block px-2 py-1 rounded text-xs font-medium"
:class="pct >= 60 ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400' : pct >= 30 ? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400' : 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400'"
x-text="pct + '%'"></span>
</td>
</template>
<template x-for="i in Math.max(0, 6 - cohort.retention.length)" :key="'empty-' + i">
<td class="px-3 py-2 text-center text-gray-300">-</td>
</template>
</tr>
</template>
</tbody>
</table>
</div>
<p x-show="!cohortData.cohorts?.length" class="text-sm text-gray-500 dark:text-gray-400 py-8 text-center">
{{ _('loyalty.store.analytics.no_data_yet') }}
</p>
</div>
<!-- Quick Actions -->
<div class="px-4 py-5 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">{{ _('loyalty.store.analytics.quick_actions') }}</h3>
@@ -71,5 +155,6 @@
{% endblock %}
{% block extra_scripts %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<script defer src="{{ url_for('loyalty_static', path='store/js/loyalty-analytics.js') }}"></script>
{% endblock %}

Some files were not shown because too many files have changed in this diff Show More