Compare commits

..

23 Commits

Author SHA1 Message Date
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
89 changed files with 3601 additions and 269 deletions

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

@@ -72,6 +72,7 @@ cart_module = ModuleDefinition(
icon="shopping-cart",
route="cart",
order=20,
header_template="cart/storefront/partials/header-cart.html",
),
],
),

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

@@ -152,6 +152,7 @@ catalog_module = ModuleDefinition(
icon="search",
route="",
order=10,
header_template="catalog/storefront/partials/header-search.html",
),
],
),

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

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

@@ -60,12 +60,14 @@ async def storefront_homepage(
include_unpublished=False,
)
# Resolve placeholders for store default pages (title + content)
# 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
@@ -73,12 +75,18 @@ async def storefront_homepage(
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 = {

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

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

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

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

@@ -146,13 +146,17 @@
}
},
"common": {
"activate": "Activate",
"active": "Active",
"add": "Add",
"all_stores": "All Stores",
"at": "at",
"back": "Back",
"cancel": "Cancel",
"confirm": "Confirm",
"continue": "Continue",
"copy": "Copy",
"deactivate": "Deactivate",
"delete": "Delete",
"disabled": "Disabled",
"edit": "Edit",
@@ -480,6 +484,9 @@
"table_points_earned": "Points Earned",
"table_points_redeemed": "Points Redeemed",
"table_transactions_30d": "Transactions (30d)",
"transaction_categories": "Transaction Categories",
"select_store": "Select a store...",
"no_categories": "No categories configured. Click Add to create one.",
"admin_policy_settings": "Admin Policy Settings",
"staff_pin_policy": "Staff PIN Policy",
"self_enrollment": "Self Enrollment",
@@ -709,6 +716,7 @@
"reward_redeemed": "Reward redeemed: {name}",
"card_label": "Card",
"confirm": "Confirm",
"select_category": "Category (what was sold)",
"pin_authorize_text": "Enter your staff PIN to authorize this transaction",
"free_item": "Free item",
"reward_label": "Reward",
@@ -761,7 +769,8 @@
"col_location": "Location",
"col_notes": "Notes",
"no_transactions": "No transactions yet",
"card_label": "Card"
"card_label": "Card",
"page_x_of_y": "Page {page} of {pages}"
},
"enroll": {
"title": "Enroll Customer",

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

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

@@ -497,6 +497,79 @@ 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
# =============================================================================

View File

@@ -244,6 +244,84 @@ def get_revenue_attribution(
)
# =============================================================================
# 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
# =============================================================================
@@ -420,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,
@@ -514,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,
@@ -521,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,
@@ -562,11 +652,22 @@ def list_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
)
if name:
names.append(name)
tx.category_names = names if names else None
tx_responses.append(tx)
return TransactionListResponse(transactions=tx_responses, total=total)
@@ -672,6 +773,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,
@@ -762,6 +864,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,

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

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

@@ -836,6 +836,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:
@@ -843,6 +845,20 @@ 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
)
if name:
names.append(name)
tx_data["category_names"] = names if names else None
tx_responses.append(tx_data)
return tx_responses, total

View File

@@ -0,0 +1,160 @@
# 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
) -> str | None:
"""Validate that a category belongs to the store.
Returns the 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()
)
return category.name if category else None
# Singleton
category_service = CategoryService()

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

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

View File

@@ -33,6 +33,15 @@ function adminLoyaltyMerchantDetail() {
settings: null,
locations: [],
// Transaction categories
selectedCategoryStoreId: '',
storeCategories: [],
showAddCategory: false,
newCategoryName: '',
newCategoryTranslations: { fr: '', de: '', lb: '' },
editingCategoryId: null,
editCategoryData: { name: '', translations: { fr: '', de: '', lb: '' } },
// State
loading: false,
error: null,
@@ -258,6 +267,100 @@ 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 deleteCategory(catId) {
if (!confirm('Delete this category?')) return;
try {
await apiClient.delete(`/admin/loyalty/stores/${this.selectedCategoryStoreId}/categories/${catId}`);
await this.loadCategoriesForStore();
Utils.showToast('Category deleted', 'success');
} catch (error) {
Utils.showToast(error.message || 'Failed to delete category', 'error');
}
}
};
}

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

@@ -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"
@@ -201,6 +201,146 @@
{% 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">Name (default)</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)</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)</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)</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"
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">
<!-- View mode -->
<div x-show="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>
<template x-if="cat.name_translations">
<span class="text-xs text-gray-400" x-text="Object.entries(cat.name_translations || {}).filter(([k,v]) => v && k !== 'en').map(([k,v]) => k.toUpperCase() + ': ' + v).join(' · ')"></span>
</template>
<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="startEditCategory(cat)" type="button"
aria-label="{{ _('loyalty.common.edit') }}"
class="text-purple-500 hover:text-purple-700">
<span x-html="$icon('pencil', '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="deleteCategory(cat.id)" 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>
<!-- 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">Name (default)</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)</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)</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)</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"
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

@@ -245,7 +245,7 @@
: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'"
: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

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

@@ -155,7 +155,6 @@
{% endblock %}
{% block extra_scripts %}
{% include 'shared/includes/optional-libs.html' with context %}
{{ chartjs_loader() }}
<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 %}

View File

@@ -3,6 +3,7 @@
{% from 'shared/macros/headers.html' import detail_page_header %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% from 'shared/macros/tables.html' import table_wrapper, table_header %}
{% from 'shared/macros/pagination.html' import pagination %}
{% block title %}{{ _('loyalty.store.card_detail.title') }}{% endblock %}
@@ -69,15 +70,30 @@
<div class="space-y-3">
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.store.card_detail.name') }}</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="card?.customer_name || '-'">-</p>
<div class="flex items-center gap-2">
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="card?.customer_name || '-'">-</p>
<button x-show="card?.customer_name" @click="Utils.copyToClipboard(card.customer_name)" type="button" aria-label="Copy" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<span x-html="$icon('clipboard-copy', 'w-3.5 h-3.5')"></span>
</button>
</div>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.store.card_detail.email') }}</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="card?.customer_email || '-'">-</p>
<div class="flex items-center gap-2">
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="card?.customer_email || '-'">-</p>
<button x-show="card?.customer_email" @click="Utils.copyToClipboard(card.customer_email)" type="button" aria-label="Copy" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<span x-html="$icon('clipboard-copy', 'w-3.5 h-3.5')"></span>
</button>
</div>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.store.card_detail.phone') }}</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="card?.customer_phone || '-'">-</p>
<div class="flex items-center gap-2">
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="card?.customer_phone || '-'">-</p>
<button x-show="card?.customer_phone" @click="Utils.copyToClipboard(card.customer_phone)" type="button" aria-label="Copy" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<span x-html="$icon('clipboard-copy', 'w-3.5 h-3.5')"></span>
</button>
</div>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.store.card_detail.birthday') }}</p>
@@ -95,7 +111,12 @@
<div class="space-y-3">
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.store.card_detail.card_number') }}</p>
<p class="text-sm font-mono text-gray-700 dark:text-gray-300" x-text="card?.card_number">-</p>
<div class="flex items-center gap-2">
<p class="text-sm font-mono text-gray-700 dark:text-gray-300" x-text="card?.card_number">-</p>
<button x-show="card?.card_number" @click="Utils.copyToClipboard(card.card_number)" type="button" aria-label="Copy" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<span x-html="$icon('clipboard-copy', 'w-3.5 h-3.5')"></span>
</button>
</div>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.store.card_detail.status') }}</p>
@@ -151,6 +172,8 @@
</template>
</tbody>
{% endcall %}
{{ pagination() }}
</div>
</div>
{% endblock %}

View File

@@ -129,7 +129,7 @@
<div class="ml-4 flex-1">
<p class="font-semibold text-gray-700 dark:text-gray-200" x-text="selectedCard?.customer_name || 'Unknown'"></p>
<p class="text-sm text-gray-500 dark:text-gray-400" x-text="selectedCard?.customer_email"></p>
<p class="text-xs text-gray-400 dark:text-gray-500" x-text="$t('loyalty.store.terminal.card_label') + ': ' + selectedCard?.card_number"></p>
<p class="text-xs text-gray-400 dark:text-gray-500" x-text="'{{ _('loyalty.store.terminal.card_label') }}' + ': ' + selectedCard?.card_number"></p>
</div>
<button @click="clearCustomer()" type="button" aria-label="{{ _('loyalty.common.close') }}" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<span x-html="$icon('x', 'w-5 h-5')"></span>
@@ -156,7 +156,7 @@
:style="'color: ' + (program?.card_color || '#4F46E5')"
x-text="(selectedCard?.stamp_count || 0) + ' / ' + (program?.stamps_target || 10)"></p>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1"
x-text="selectedCard?.stamps_until_reward > 0 ? $t('loyalty.store.terminal.more_for_reward', {count: selectedCard.stamps_until_reward}) : $t('loyalty.store.terminal.ready_to_redeem')"></p>
x-text="selectedCard?.stamps_until_reward > 0 ? '{{ _('loyalty.store.terminal.more_for_reward') }}'.replace('{count}', selectedCard.stamps_until_reward) : '{{ _('loyalty.store.terminal.ready_to_redeem') }}'"></p>
</div>
</template>
</div>
@@ -168,7 +168,7 @@
<template x-if="program?.is_stamps_enabled">
<div class="p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
<h4 class="font-medium text-gray-700 dark:text-gray-300 mb-3">
<span x-html="$icon('plus-circle', 'inline w-4 h-4 mr-1 text-green-500')"></span>
<span x-html="$icon('plus', 'inline w-4 h-4 mr-1 text-green-500')"></span>
{{ _('loyalty.store.terminal.add_stamp') }}
</h4>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
@@ -192,7 +192,7 @@
{{ _('loyalty.store.terminal.redeem_stamps') }}
</h4>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3"
x-text="selectedCard?.can_redeem_stamps ? $t('loyalty.store.terminal.reward_label') + ': ' + (selectedCard?.stamp_reward_description || program?.stamps_reward_description || $t('loyalty.store.terminal.free_item')) : $t('loyalty.store.terminal.not_enough_stamps')"></p>
x-text="selectedCard?.can_redeem_stamps ? '{{ _('loyalty.store.terminal.reward_label') }}' + ': ' + (selectedCard?.stamp_reward_description || program?.stamps_reward_description || '{{ _('loyalty.store.terminal.free_item') }}') : '{{ _('loyalty.store.terminal.not_enough_stamps') }}'"></p>
<button @click="showPinModal('redeemStamps')"
:disabled="!selectedCard?.can_redeem_stamps"
class="w-full px-4 py-2 text-sm font-medium text-white bg-orange-600 rounded-lg hover:bg-orange-700 disabled:opacity-50">
@@ -205,7 +205,7 @@
<template x-if="program?.is_points_enabled">
<div class="p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
<h4 class="font-medium text-gray-700 dark:text-gray-300 mb-3">
<span x-html="$icon('plus-circle', 'inline w-4 h-4 mr-1 text-green-500')"></span>
<span x-html="$icon('plus', 'inline w-4 h-4 mr-1 text-green-500')"></span>
{{ _('loyalty.store.terminal.earn_points') }}
</h4>
<div class="mb-3">
@@ -286,13 +286,14 @@
<th class="px-4 py-3">{{ _('loyalty.store.terminal.col_customer') }}</th>
<th class="px-4 py-3">{{ _('loyalty.store.terminal.col_type') }}</th>
<th class="px-4 py-3 text-right">{{ _('loyalty.store.terminal.col_points') }}</th>
<th class="px-4 py-3">{{ _('loyalty.store.terminal.select_category') }}</th>
<th class="px-4 py-3">{{ _('loyalty.store.terminal.col_notes') }}</th>
</tr>
</thead>
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<template x-if="recentTransactions.length === 0">
<tr>
<td colspan="5" class="px-4 py-6 text-center text-gray-500 dark:text-gray-400">
<td colspan="6" class="px-4 py-6 text-center text-gray-500 dark:text-gray-400">
{{ _('loyalty.store.terminal.no_recent_transactions') }}
</td>
</tr>
@@ -309,6 +310,7 @@
<td class="px-4 py-3 text-sm text-right font-medium"
:class="tx.points_delta > 0 ? 'text-green-600' : 'text-orange-600'"
x-text="(tx.points_delta > 0 ? '+' : '') + formatNumber(tx.points_delta)"></td>
<td class="px-4 py-3 text-sm text-gray-500" x-text="tx.category_names?.join(', ') || '-'"></td>
<td class="px-4 py-3 text-sm text-gray-500" x-text="tx.notes || '-'"></td>
</tr>
</template>
@@ -324,6 +326,24 @@
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
{{ _('loyalty.store.terminal.pin_authorize_text') }}
</p>
<!-- Category selector (only shown when categories exist and action is stamp/earn) -->
<div x-show="categories.length > 0 && (pendingAction === 'stamp' || pendingAction === 'earn')" class="mb-4">
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-2">{{ _('loyalty.store.terminal.select_category') }}</label>
<div class="flex flex-wrap gap-2">
<template x-for="cat in categories" :key="cat.id">
<button type="button"
@click="selectedCategories.includes(cat.id) ? selectedCategories = selectedCategories.filter(id => id !== cat.id) : selectedCategories.push(cat.id)"
class="px-3 py-1.5 text-sm font-medium rounded-full border transition-colors"
:class="selectedCategories.includes(cat.id)
? 'bg-purple-600 text-white border-purple-600'
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600'"
x-text="cat.name">
</button>
</template>
</div>
</div>
<div class="flex justify-center mb-4" aria-live="polite" aria-atomic="true">
<div class="flex gap-2" role="status" :aria-label="pinDigits.length + ' of 4 digits entered'">
<template x-for="i in 4">
@@ -350,7 +370,7 @@
</button>
<button @click="removePinDigit()" type="button" aria-label="{{ _('loyalty.common.back') }}"
class="h-14 text-sm font-medium rounded-lg bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600">
<span x-html="$icon('backspace', 'w-6 h-6 mx-auto')"></span>
<span x-html="$icon('arrow-left', 'w-6 h-6 mx-auto')"></span>
</button>
</div>
<div class="mt-4 flex justify-end gap-3">
@@ -359,7 +379,7 @@
{{ _('loyalty.common.cancel') }}
</button>
<button @click="submitTransaction()"
:disabled="pinDigits.length !== 4 || processing"
:disabled="pinDigits.length !== 4 || processing || (categories.length > 0 && (pendingAction === 'stamp' || pendingAction === 'earn') && selectedCategories.length === 0)"
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50">
<span x-show="processing" x-html="$icon('spinner', 'w-4 h-4 mr-2 inline animate-spin')"></span>
<span x-text="processing ? '{{ _('loyalty.store.terminal.processing')|replace("'", "\\'") }}' : '{{ _('loyalty.store.terminal.confirm')|replace("'", "\\'") }}'"></span>
@@ -370,5 +390,21 @@
{% endblock %}
{% block extra_scripts %}
<script>
// Server-rendered transaction type labels (avoids async i18n flicker)
window._txLabels = {
card_created: {{ _('loyalty.transactions.card_created')|tojson }},
welcome_bonus: {{ _('loyalty.transactions.welcome_bonus')|tojson }},
stamp_earned: {{ _('loyalty.transactions.stamp_earned')|tojson }},
stamp_redeemed: {{ _('loyalty.transactions.stamp_redeemed')|tojson }},
stamp_voided: {{ _('loyalty.transactions.stamp_voided')|tojson }},
points_earned: {{ _('loyalty.transactions.points_earned')|tojson }},
points_redeemed: {{ _('loyalty.transactions.points_redeemed')|tojson }},
points_voided: {{ _('loyalty.transactions.points_voided')|tojson }},
points_adjustment: {{ _('loyalty.transactions.points_adjustment')|tojson }},
points_expired: {{ _('loyalty.transactions.points_expired')|tojson }},
reward_redeemed: {{ _('loyalty.transactions.reward_redeemed')|tojson }},
};
</script>
<script defer src="{{ url_for('loyalty_static', path='store/js/loyalty-terminal.js') }}"></script>
{% endblock %}

View File

@@ -243,52 +243,6 @@ async def admin_store_roles_page(
)
# ============================================================================
# STORE THEMES ROUTES
# ============================================================================
@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(
"tenancy/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(
"tenancy/admin/store-theme.html",
get_admin_context(request, db, current_user, store_code=store_code),
)
# ============================================================================
# MERCHANT USER MANAGEMENT ROUTES (All Admins)
# ============================================================================

View File

@@ -11,7 +11,6 @@
{# SEO Meta Tags #}
<meta name="description" content="{% block meta_description %}{{ platform.get_translated_description(current_language|default('fr'), platform.default_language|default('fr')) if platform else '' }}{% endblock %}">
<meta name="keywords" content="{% block meta_keywords %}letzshop, order management, oms, luxembourg, e-commerce, invoicing, inventory{% endblock %}">
{# Favicon #}
<link rel="icon" type="image/x-icon" href="{{ url_for('static', path='favicon.ico') }}">

View File

@@ -14,7 +14,6 @@
{# SEO Meta Tags #}
<meta name="description" content="{% block meta_description %}{{ store.description or 'Shop at ' + store.name }}{% endblock %}">
<meta name="keywords" content="{% block meta_keywords %}{{ store.name }}, online shop{% endblock %}">
{# Favicon - store-specific or default #}
{% if theme.branding.favicon %}
@@ -105,36 +104,17 @@
{% endfor %}
</nav>
{# Right side actions #}
{# Right side actions — module-provided via header_template #}
<div class="flex items-center space-x-4">
{# Action items from enabled modules (search, cart, etc.) #}
{% set action_ids = storefront_nav.get('actions', [])|map(attribute='id')|list %}
{% if 'search' in action_ids %}
{# Search #}
<button @click="openSearch()" class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
</button>
{% endif %}
{% if 'cart' in action_ids %}
{# Cart #}
<a href="{{ base_url }}cart" class="relative p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z"></path>
</svg>
<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>
{% endif %}{# cart #}
{% for item in storefront_nav.get('actions', []) %}
{% if item.header_template %}
{% include item.header_template %}
{% else %}
<a href="{{ base_url }}{{ item.route }}" class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700" title="{{ _(item.label_key) }}">
<span class="w-5 h-5" x-html="$icon('{{ item.icon }}', 'w-5 h-5')"></span>
</a>
{% endif %}
{% endfor %}
{# Theme toggle #}
<button @click="toggleTheme()"

14
clients/terminal-android/.gitignore vendored Normal file
View File

@@ -0,0 +1,14 @@
*.iml
.gradle
/local.properties
/.idea
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties
/app/build
/app/release
*.apk
*.aab

View File

@@ -0,0 +1,100 @@
# RewardFlow Terminal — Android Setup
## Prerequisites
### 1. Install Android Studio
```bash
# Download Android Studio (Xubuntu/Ubuntu)
sudo snap install android-studio --classic
# Or via apt (if snap not available)
# Download from https://developer.android.com/studio
# Extract and run: ./android-studio/bin/studio.sh
```
Android Studio bundles:
- JDK 17 (no need to install separately)
- Android SDK
- Gradle
- Android Emulator
### 2. First-time Android Studio setup
1. Open Android Studio
2. Choose "Standard" installation
3. Accept SDK licenses: `Tools → SDK Manager → SDK Platforms → Android 15 (API 35)`
4. Install: `Tools → SDK Manager → SDK Tools`:
- Android SDK Build-Tools
- Android SDK Platform-Tools
- Android Emulator
- Google Play services (for ML Kit barcode scanning)
### 3. Open the project
1. `File → Open` → navigate to `clients/terminal-android/`
2. Wait for Gradle sync (first time downloads ~500MB of dependencies)
3. If prompted about Gradle JDK, select the bundled JDK 17
### 4. Create a tablet emulator
1. `Tools → Device Manager → Create Virtual Device`
2. Category: **Tablet** → pick **Pixel Tablet** or **Nexus 10**
3. System image: **API 35** (download if needed)
4. Finish
### 5. Run the app
1. Select the tablet emulator in the device dropdown
2. Click ▶️ Run
3. The app opens in landscape fullscreen
## Project structure
```
clients/terminal-android/
├── app/
│ ├── build.gradle.kts # App dependencies (like requirements.txt)
│ ├── src/main/
│ │ ├── AndroidManifest.xml # App permissions & config
│ │ ├── java/lu/rewardflow/terminal/
│ │ │ ├── RewardFlowApp.kt # Application entry point
│ │ │ ├── MainActivity.kt # Single activity (Compose)
│ │ │ ├── ui/ # Screens (Setup, PIN, Terminal)
│ │ │ ├── data/ # API, DB, models, sync
│ │ │ └── di/ # Dependency injection (Hilt)
│ │ └── res/ # Resources (strings, themes, icons)
│ └── proguard-rules.pro # Release build obfuscation rules
├── gradle/
│ ├── libs.versions.toml # Version catalog (all dependency versions)
│ └── wrapper/ # Gradle wrapper (pinned version)
├── build.gradle.kts # Root build file
├── settings.gradle.kts # Project settings
└── .gitignore
```
## Key dependencies (in gradle/libs.versions.toml)
| Library | Purpose |
|---------|---------|
| Jetpack Compose | UI framework (declarative, like SwiftUI) |
| Retrofit + Moshi | HTTP client + JSON parsing (calls the Orion API) |
| Room | SQLite ORM (offline transaction queue) |
| WorkManager | Background sync (retry pending transactions) |
| Hilt | Dependency injection |
| CameraX + ML Kit | Camera preview + QR/barcode scanning |
| DataStore | Key-value persistence (device config, auth token) |
## API connection
- **Debug builds**: connect to `http://10.0.2.2:8000` (Android emulator's localhost alias)
- **Release builds**: connect to `https://rewardflow.lu`
- Configure in `app/build.gradle.kts``buildConfigField("DEFAULT_API_URL", ...)`
## Building a release APK
```bash
cd apps/terminal-android
./gradlew assembleRelease
# APK at: app/build/outputs/apk/release/app-release.apk
```

View File

@@ -0,0 +1,108 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.ksp)
alias(libs.plugins.hilt)
}
android {
namespace = "lu.rewardflow.terminal"
compileSdk = 35
defaultConfig {
applicationId = "lu.rewardflow.terminal"
minSdk = 26 // Android 8.0 — covers 95%+ of tablets
targetSdk = 35
versionCode = 1
versionName = "1.0.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
debug {
buildConfigField("String", "DEFAULT_API_URL", "\"http://10.0.2.2:8000\"")
}
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
buildConfigField("String", "DEFAULT_API_URL", "\"https://rewardflow.lu\"")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
buildConfig = true
}
}
dependencies {
// AndroidX Core
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat)
implementation(libs.material)
// Compose
implementation(platform(libs.compose.bom))
implementation(libs.compose.ui)
implementation(libs.compose.ui.graphics)
implementation(libs.compose.ui.tooling.preview)
implementation(libs.compose.material3)
implementation(libs.compose.material.icons)
implementation(libs.activity.compose)
implementation(libs.navigation.compose)
implementation(libs.lifecycle.runtime.compose)
implementation(libs.lifecycle.viewmodel.compose)
debugImplementation(libs.compose.ui.tooling)
// Networking (Retrofit + Moshi)
implementation(libs.retrofit)
implementation(libs.retrofit.moshi)
implementation(libs.okhttp)
implementation(libs.okhttp.logging)
implementation(libs.moshi)
ksp(libs.moshi.codegen)
// Room (offline database)
implementation(libs.room.runtime)
implementation(libs.room.ktx)
ksp(libs.room.compiler)
// WorkManager (background sync)
implementation(libs.work.runtime.ktx)
// Hilt (dependency injection)
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
implementation(libs.hilt.navigation.compose)
implementation(libs.hilt.work)
// CameraX + ML Kit (QR scanning)
implementation(libs.camera.core)
implementation(libs.camera.camera2)
implementation(libs.camera.lifecycle)
implementation(libs.camera.view)
implementation(libs.mlkit.barcode)
// DataStore (device config persistence)
implementation(libs.datastore.preferences)
// Testing
testImplementation(libs.junit)
androidTestImplementation(libs.junit.android)
androidTestImplementation(libs.espresso.core)
}

View File

@@ -0,0 +1,7 @@
# Moshi
-keep class lu.rewardflow.terminal.data.model.** { *; }
-keepclassmembers class lu.rewardflow.terminal.data.model.** { *; }
# Retrofit
-keepattributes Signature
-keepattributes *Annotation*

View File

@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Network access for API calls -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Camera for QR/barcode scanning -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<application
android:name=".RewardFlowApp"
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.RewardFlowTerminal"
tools:targetApi="35">
<activity
android:name=".MainActivity"
android:exported="true"
android:screenOrientation="landscape"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
android:windowSoftInputMode="adjustResize"
android:lockTaskMode="if_whitelisted">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.HOME" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -0,0 +1,33 @@
package lu.rewardflow.terminal
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import dagger.hilt.android.AndroidEntryPoint
import lu.rewardflow.terminal.ui.RewardFlowNavHost
import lu.rewardflow.terminal.ui.theme.RewardFlowTheme
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
RewardFlowTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
RewardFlowNavHost()
}
}
}
}
}

View File

@@ -0,0 +1,7 @@
package lu.rewardflow.terminal
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class RewardFlowApp : Application()

View File

@@ -0,0 +1,50 @@
package lu.rewardflow.terminal.data.api
import lu.rewardflow.terminal.data.model.*
import retrofit2.http.*
/**
* Retrofit interface for the Orion Loyalty Store API.
*
* Mirrors the endpoints at /api/v1/store/loyalty/
* Auth via Bearer token (obtained during device setup).
*/
interface LoyaltyApi {
// Program
@GET("api/v1/store/loyalty/program")
suspend fun getProgram(): ProgramResponse
// Card Lookup
@GET("api/v1/store/loyalty/cards/lookup")
suspend fun lookupCard(@Query("q") query: String): CardLookupResponse
@GET("api/v1/store/loyalty/cards/{cardId}")
suspend fun getCardDetail(@Path("cardId") cardId: Int): CardDetailResponse
// Enrollment
@POST("api/v1/store/loyalty/cards/enroll")
suspend fun enrollCustomer(@Body request: EnrollRequest): CardResponse
// Stamps
@POST("api/v1/store/loyalty/stamp")
suspend fun addStamp(@Body request: StampRequest): StampResponse
@POST("api/v1/store/loyalty/stamp/redeem")
suspend fun redeemStamps(@Body request: StampRedeemRequest): StampRedeemResponse
// Points
@POST("api/v1/store/loyalty/points/earn")
suspend fun earnPoints(@Body request: PointsEarnRequest): PointsEarnResponse
@POST("api/v1/store/loyalty/points/redeem")
suspend fun redeemPoints(@Body request: PointsRedeemRequest): PointsRedeemResponse
// PINs (for caching staff PINs locally)
@GET("api/v1/store/loyalty/pins")
suspend fun listPins(): PinListResponse
// Auth
@POST("api/v1/store/auth/login")
suspend fun login(@Body request: LoginRequest): LoginResponse
}

View File

@@ -0,0 +1,15 @@
package lu.rewardflow.terminal.data.db
import androidx.room.Database
import androidx.room.RoomDatabase
import lu.rewardflow.terminal.data.db.dao.PendingTransactionDao
import lu.rewardflow.terminal.data.db.entity.PendingTransaction
@Database(
entities = [PendingTransaction::class],
version = 1,
exportSchema = false,
)
abstract class AppDatabase : RoomDatabase() {
abstract fun pendingTransactionDao(): PendingTransactionDao
}

View File

@@ -0,0 +1,29 @@
package lu.rewardflow.terminal.data.db.dao
import androidx.room.*
import lu.rewardflow.terminal.data.db.entity.PendingTransaction
@Dao
interface PendingTransactionDao {
@Insert
suspend fun insert(transaction: PendingTransaction): Long
@Query("SELECT * FROM pending_transactions WHERE status = 'pending' ORDER BY createdAt ASC")
suspend fun getPending(): List<PendingTransaction>
@Query("SELECT COUNT(*) FROM pending_transactions WHERE status = 'pending'")
suspend fun getPendingCount(): Int
@Update
suspend fun update(transaction: PendingTransaction)
@Query("DELETE FROM pending_transactions WHERE status = 'synced'")
suspend fun deleteSynced()
@Query("UPDATE pending_transactions SET status = 'failed', lastError = :error, retryCount = retryCount + 1 WHERE id = :id")
suspend fun markFailed(id: Long, error: String)
@Query("UPDATE pending_transactions SET status = 'synced' WHERE id = :id")
suspend fun markSynced(id: Long)
}

View File

@@ -0,0 +1,36 @@
package lu.rewardflow.terminal.data.db.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
/**
* Offline transaction queue.
*
* When the device is offline, transactions (stamp, points, enrollment)
* are stored here and synced via WorkManager when connectivity returns.
*/
@Entity(tableName = "pending_transactions")
data class PendingTransaction(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
/** Type: "stamp", "points_earn", "enroll" */
val type: String,
/** JSON payload matching the API request body */
val requestJson: String,
/** Staff PIN ID who initiated the transaction */
val staffPinId: Int,
/** When the transaction was created locally */
val createdAt: Long = System.currentTimeMillis(),
/** Number of sync attempts */
val retryCount: Int = 0,
/** Last sync error (null if not yet attempted) */
val lastError: String? = null,
/** "pending", "syncing", "synced", "failed" */
val status: String = "pending",
)

View File

@@ -0,0 +1,189 @@
package lu.rewardflow.terminal.data.model
import com.squareup.moshi.JsonClass
// ── Auth ────────────────────────────────────────────────────────────────
@JsonClass(generateAdapter = true)
data class LoginRequest(
val email_or_username: String,
val password: String,
)
@JsonClass(generateAdapter = true)
data class LoginResponse(
val access_token: String,
val token_type: String,
val expires_in: Int,
)
// ── Program ─────────────────────────────────────────────────────────────
@JsonClass(generateAdapter = true)
data class ProgramResponse(
val id: Int,
val merchant_id: Int,
val loyalty_type: String,
val display_name: String,
val card_name: String,
val card_color: String,
val card_secondary_color: String? = null,
val logo_url: String? = null,
val stamps_target: Int = 10,
val points_per_euro: Int = 10,
val welcome_bonus_points: Int = 0,
val stamps_reward_description: String? = null,
val require_staff_pin: Boolean = false,
val is_active: Boolean = true,
)
// ── Card ────────────────────────────────────────────────────────────────
@JsonClass(generateAdapter = true)
data class CardResponse(
val id: Int,
val card_number: String,
val customer_id: Int,
val merchant_id: Int,
val points_balance: Int = 0,
val stamp_count: Int = 0,
val is_active: Boolean = true,
)
@JsonClass(generateAdapter = true)
data class CardLookupResponse(
val id: Int,
val card_number: String,
val customer_id: Int,
val customer_name: String? = null,
val customer_email: String = "",
val merchant_id: Int,
val stamp_count: Int = 0,
val stamps_target: Int = 10,
val stamps_until_reward: Int = 10,
val points_balance: Int = 0,
val can_redeem_stamps: Boolean = false,
val stamp_reward_description: String? = null,
val available_rewards: List<RewardItem> = emptyList(),
val can_stamp: Boolean = true,
val stamps_today: Int = 0,
val max_daily_stamps: Int = 5,
)
@JsonClass(generateAdapter = true)
data class CardDetailResponse(
val id: Int,
val card_number: String,
val customer_name: String? = null,
val customer_email: String? = null,
val points_balance: Int = 0,
val stamp_count: Int = 0,
val stamps_target: Int = 10,
val total_points_earned: Int = 0,
val total_stamps_earned: Int = 0,
val is_active: Boolean = true,
)
@JsonClass(generateAdapter = true)
data class RewardItem(
val id: String,
val name: String,
val points_required: Int,
val is_active: Boolean = true,
)
// ── Enrollment ──────────────────────────────────────────────────────────
@JsonClass(generateAdapter = true)
data class EnrollRequest(
val customer_id: Int? = null,
val email: String? = null,
val customer_name: String? = null,
val customer_phone: String? = null,
val customer_birthday: String? = null,
)
// ── Stamps ──────────────────────────────────────────────────────────────
@JsonClass(generateAdapter = true)
data class StampRequest(
val card_id: Int? = null,
val qr_code: String? = null,
val card_number: String? = null,
val staff_pin: String? = null,
val notes: String? = null,
)
@JsonClass(generateAdapter = true)
data class StampResponse(
val success: Boolean,
val stamp_count: Int = 0,
val stamps_target: Int = 10,
val reward_earned: Boolean = false,
val reward_description: String? = null,
)
@JsonClass(generateAdapter = true)
data class StampRedeemRequest(
val card_id: Int? = null,
val qr_code: String? = null,
val card_number: String? = null,
val staff_pin: String? = null,
)
@JsonClass(generateAdapter = true)
data class StampRedeemResponse(
val success: Boolean,
val stamp_count: Int = 0,
)
// ── Points ──────────────────────────────────────────────────────────────
@JsonClass(generateAdapter = true)
data class PointsEarnRequest(
val card_id: Int? = null,
val qr_code: String? = null,
val card_number: String? = null,
val purchase_amount_cents: Int,
val order_reference: String? = null,
val staff_pin: String? = null,
val notes: String? = null,
)
@JsonClass(generateAdapter = true)
data class PointsEarnResponse(
val success: Boolean,
val points_earned: Int = 0,
val points_balance: Int = 0,
)
@JsonClass(generateAdapter = true)
data class PointsRedeemRequest(
val card_id: Int? = null,
val qr_code: String? = null,
val card_number: String? = null,
val reward_id: String,
val staff_pin: String? = null,
)
@JsonClass(generateAdapter = true)
data class PointsRedeemResponse(
val success: Boolean,
val points_balance: Int = 0,
)
// ── PINs ────────────────────────────────────────────────────────────────
@JsonClass(generateAdapter = true)
data class PinListResponse(
val pins: List<PinItem>,
val total: Int,
)
@JsonClass(generateAdapter = true)
data class PinItem(
val id: Int,
val name: String,
val is_active: Boolean,
val is_locked: Boolean,
)

View File

@@ -0,0 +1,80 @@
package lu.rewardflow.terminal.di
import android.content.Context
import androidx.room.Room
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import lu.rewardflow.terminal.BuildConfig
import lu.rewardflow.terminal.data.api.LoyaltyApi
import lu.rewardflow.terminal.data.db.AppDatabase
import lu.rewardflow.terminal.data.db.dao.PendingTransactionDao
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import java.util.concurrent.TimeUnit
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
@Singleton
fun provideMoshi(): Moshi = Moshi.Builder()
.addLast(KotlinJsonAdapterFactory())
.build()
@Provides
@Singleton
fun provideOkHttpClient(): OkHttpClient {
val builder = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
if (BuildConfig.DEBUG) {
val logging = HttpLoggingInterceptor()
logging.level = HttpLoggingInterceptor.Level.BODY
builder.addInterceptor(logging)
}
return builder.build()
}
@Provides
@Singleton
fun provideRetrofit(okHttpClient: OkHttpClient, moshi: Moshi): Retrofit {
return Retrofit.Builder()
.baseUrl(BuildConfig.DEFAULT_API_URL + "/")
.client(okHttpClient)
.addConverterFactory(MoshiConverterFactory.create(moshi))
.build()
}
@Provides
@Singleton
fun provideLoyaltyApi(retrofit: Retrofit): LoyaltyApi {
return retrofit.create(LoyaltyApi::class.java)
}
@Provides
@Singleton
fun provideDatabase(@ApplicationContext context: Context): AppDatabase {
return Room.databaseBuilder(
context,
AppDatabase::class.java,
"rewardflow_terminal.db",
).build()
}
@Provides
fun providePendingTransactionDao(db: AppDatabase): PendingTransactionDao {
return db.pendingTransactionDao()
}
}

View File

@@ -0,0 +1,25 @@
package lu.rewardflow.terminal.di
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStore
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "device_config")
@Module
@InstallIn(SingletonComponent::class)
object DataStoreModule {
@Provides
@Singleton
fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> {
return context.dataStore
}
}

View File

@@ -0,0 +1,69 @@
package lu.rewardflow.terminal.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import lu.rewardflow.terminal.ui.pin.PinScreen
import lu.rewardflow.terminal.ui.setup.SetupScreen
import lu.rewardflow.terminal.ui.setup.SetupViewModel
import lu.rewardflow.terminal.ui.terminal.TerminalScreen
/**
* Navigation flow:
*
* 1. SetupScreen — first-time device setup (scan QR from web)
* 2. PinScreen — staff enters PIN to unlock terminal
* 3. TerminalScreen — main POS terminal (scan, search, transact)
*
* After setup, the app always starts at PinScreen.
* After 2 min idle on TerminalScreen, auto-lock back to PinScreen.
*/
@Composable
fun RewardFlowNavHost() {
val navController = rememberNavController()
val setupViewModel: SetupViewModel = hiltViewModel()
val isSetUp by setupViewModel.isDeviceSetUp.collectAsState(initial = false)
val startDestination = if (isSetUp) "pin" else "setup"
NavHost(navController = navController, startDestination = startDestination) {
composable("setup") {
SetupScreen(
onSetupComplete = {
navController.navigate("pin") {
popUpTo("setup") { inclusive = true }
}
},
)
}
composable("pin") {
PinScreen(
onPinVerified = { staffPinId, staffName ->
navController.navigate("terminal/$staffPinId/$staffName") {
popUpTo("pin") { inclusive = true }
}
},
)
}
composable("terminal/{staffPinId}/{staffName}") { backStackEntry ->
val staffPinId = backStackEntry.arguments?.getString("staffPinId")?.toIntOrNull() ?: 0
val staffName = backStackEntry.arguments?.getString("staffName") ?: ""
TerminalScreen(
staffPinId = staffPinId,
staffName = staffName,
onLockScreen = {
navController.navigate("pin") {
popUpTo("terminal/{staffPinId}/{staffName}") { inclusive = true }
}
},
)
}
}
}

View File

@@ -0,0 +1,103 @@
package lu.rewardflow.terminal.ui.pin
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/**
* PIN entry screen — staff enters their 4-digit PIN to unlock the terminal.
*
* The PIN identifies the staff member (unique per store).
* PINs are cached locally and refreshed periodically from the API.
*/
@Composable
fun PinScreen(
onPinVerified: (staffPinId: Int, staffName: String) -> Unit,
) {
var pinDigits by remember { mutableStateOf("") }
var error by remember { mutableStateOf<String?>(null) }
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(48.dp),
) {
Text(
text = "Enter Staff PIN",
style = MaterialTheme.typography.headlineMedium,
)
Spacer(modifier = Modifier.height(24.dp))
// PIN dots display
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
repeat(4) { i ->
Surface(
modifier = Modifier.size(56.dp),
shape = MaterialTheme.shapes.medium,
color = if (pinDigits.length > i)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.surfaceVariant,
) {
Box(contentAlignment = Alignment.Center) {
if (pinDigits.length > i) {
Text("", fontSize = 32.sp, color = MaterialTheme.colorScheme.onPrimary)
}
}
}
}
}
error?.let {
Spacer(modifier = Modifier.height(12.dp))
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
}
Spacer(modifier = Modifier.height(32.dp))
// Number pad
val buttons = listOf(
listOf("1", "2", "3"),
listOf("4", "5", "6"),
listOf("7", "8", "9"),
listOf("C", "0", ""),
)
buttons.forEach { row ->
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
row.forEach { label ->
FilledTonalButton(
onClick = {
when (label) {
"C" -> { pinDigits = ""; error = null }
"" -> { if (pinDigits.isNotEmpty()) pinDigits = pinDigits.dropLast(1) }
else -> {
if (pinDigits.length < 4) {
pinDigits += label
if (pinDigits.length == 4) {
// TODO: verify PIN against cached list
// For now, placeholder callback
onPinVerified(1, "Staff")
}
}
}
}
},
modifier = Modifier.size(72.dp),
) {
Text(label, fontSize = 24.sp)
}
}
}
Spacer(modifier = Modifier.height(8.dp))
}
}
}
}

View File

@@ -0,0 +1,49 @@
package lu.rewardflow.terminal.ui.setup
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
/**
* First-time device setup screen.
*
* Flow:
* 1. Show "Scan QR Code" prompt
* 2. Merchant owner scans QR from web settings page
* 3. QR contains: API URL + store auth token + store_id + store_code
* 4. App downloads store config and enters kiosk mode
*/
@Composable
fun SetupScreen(
onSetupComplete: () -> Unit,
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(48.dp),
) {
Text(
text = "RewardFlow Terminal",
style = MaterialTheme.typography.headlineLarge,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Scan the setup QR code from your store settings page to configure this device.",
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(32.dp))
// TODO: CameraX QR scanner for setup code
Button(onClick = onSetupComplete) {
Text("Setup Complete (placeholder)")
}
}
}
}

View File

@@ -0,0 +1,24 @@
package lu.rewardflow.terminal.ui.setup
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
@HiltViewModel
class SetupViewModel @Inject constructor(
private val dataStore: DataStore<Preferences>,
) : ViewModel() {
companion object {
val IS_SET_UP = booleanPreferencesKey("is_device_set_up")
}
val isDeviceSetUp: Flow<Boolean> = dataStore.data.map { prefs ->
prefs[IS_SET_UP] ?: false
}
}

View File

@@ -0,0 +1,95 @@
package lu.rewardflow.terminal.ui.terminal
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/**
* Main POS terminal screen.
*
* Layout (landscape tablet):
* ┌──────────────────────────────────────────────┐
* │ [Staff: Jane] [Store: Fashion Hub] [Lock] │
* ├──────────────────┬───────────────────────────┤
* │ │ │
* │ Customer Search │ Card Details │
* │ + QR Scanner │ Points/Stamps balance │
* │ │ Quick actions │
* │ │ (Earn, Redeem, Enroll) │
* │ │ │
* └──────────────────┴───────────────────────────┘
*/
@Composable
fun TerminalScreen(
staffPinId: Int,
staffName: String,
onLockScreen: () -> Unit,
) {
Column(modifier = Modifier.fillMaxSize()) {
// Top bar
Surface(
color = MaterialTheme.colorScheme.primaryContainer,
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "Staff: $staffName",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
)
// TODO: show store name, offline indicator, pending sync count
FilledTonalButton(onClick = onLockScreen) {
Text("Lock")
}
}
}
// Main content — two-pane layout
Row(modifier = Modifier.fillMaxSize()) {
// Left pane: search + scan
Surface(
modifier = Modifier
.weight(0.4f)
.fillMaxHeight()
.padding(16.dp),
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceVariant,
) {
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text("Search Customer", style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(16.dp))
// TODO: Search field + QR camera preview
Text("QR Scanner / Search field", style = MaterialTheme.typography.bodyMedium)
}
}
// Right pane: card details + actions
Surface(
modifier = Modifier
.weight(0.6f)
.fillMaxHeight()
.padding(16.dp),
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surface,
) {
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text("Select a customer to begin", style = MaterialTheme.typography.bodyLarge)
// TODO: Show card details, balance, quick action buttons
}
}
}
}
}

View File

@@ -0,0 +1,60 @@
package lu.rewardflow.terminal.ui.theme
import android.app.Activity
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
// RewardFlow brand colors
private val Purple600 = Color(0xFF7C3AED)
private val Purple700 = Color(0xFF6D28D9)
private val Purple50 = Color(0xFFF5F3FF)
private val LightColorScheme = lightColorScheme(
primary = Purple600,
onPrimary = Color.White,
primaryContainer = Purple50,
onPrimaryContainer = Purple700,
secondary = Color(0xFF10B981),
background = Color(0xFFF9FAFB),
surface = Color.White,
error = Color(0xFFEF4444),
)
private val DarkColorScheme = darkColorScheme(
primary = Color(0xFFA78BFA),
onPrimary = Color(0xFF1E1B4B),
primaryContainer = Color(0xFF312E81),
onPrimaryContainer = Color(0xFFE0E7FF),
secondary = Color(0xFF34D399),
background = Color(0xFF111827),
surface = Color(0xFF1F2937),
error = Color(0xFFF87171),
)
@Composable
fun RewardFlowTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) {
val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
window.statusBarColor = colorScheme.primaryContainer.toArgb()
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
}
}
MaterialTheme(
colorScheme = colorScheme,
content = content,
)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 414 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 547 B

View File

@@ -0,0 +1,3 @@
<resources>
<string name="app_name">RewardFlow Terminal</string>
</resources>

View File

@@ -0,0 +1,5 @@
<resources>
<style name="Theme.RewardFlowTerminal" parent="Theme.Material3.DayNight.NoActionBar">
<item name="android:windowFullscreen">true</item>
</style>
</resources>

View File

@@ -0,0 +1,8 @@
// Top-level build file
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.ksp) apply false
alias(libs.plugins.hilt) apply false
}

View File

@@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true

View File

@@ -0,0 +1,113 @@
# Version Catalog — single source of truth for all dependency versions.
# This is the Android equivalent of requirements.txt.
#
# Usage in build.gradle.kts:
# implementation(libs.androidx.core.ktx)
# implementation(libs.retrofit)
[versions]
# Android / Kotlin
agp = "8.7.3"
kotlin = "2.1.0"
ksp = "2.1.0-1.0.29"
# Compose
composeBom = "2025.01.01"
activityCompose = "1.10.1"
navigationCompose = "2.8.7"
lifecycleCompose = "2.8.7"
# AndroidX
coreKtx = "1.15.0"
appcompat = "1.7.0"
material = "1.12.0"
# Networking
retrofit = "2.11.0"
okhttp = "4.12.0"
moshi = "1.15.1"
# Database (offline queue)
room = "2.6.1"
# Background sync
workManager = "2.10.0"
# Dependency Injection
hilt = "2.51.1"
hiltNavigationCompose = "1.2.0"
# Camera & QR scanning
cameraX = "1.4.1"
mlkitBarcode = "17.3.0"
# DataStore (preferences)
datastore = "1.1.2"
# Testing
junit = "4.13.2"
junitAndroid = "1.2.1"
espresso = "3.6.1"
[libraries]
# AndroidX Core
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
# Compose
compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
compose-ui = { group = "androidx.compose.ui", name = "ui" }
compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
compose-material3 = { group = "androidx.compose.material3", name = "material3" }
compose-material-icons = { group = "androidx.compose.material", name = "material-icons-extended" }
activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleCompose" }
lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleCompose" }
# Networking
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-moshi = { group = "com.squareup.retrofit2", name = "converter-moshi", version.ref = "retrofit" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
moshi = { group = "com.squareup.moshi", name = "moshi-kotlin", version.ref = "moshi" }
moshi-codegen = { group = "com.squareup.moshi", name = "moshi-kotlin-codegen", version.ref = "moshi" }
# Room (offline database)
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
# WorkManager (background sync)
work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workManager" }
# Hilt (dependency injection)
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" }
hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" }
hilt-work = { group = "androidx.hilt", name = "hilt-work", version.ref = "hiltNavigationCompose" }
# CameraX (QR scanning)
camera-core = { group = "androidx.camera", name = "camera-core", version.ref = "cameraX" }
camera-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "cameraX" }
camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "cameraX" }
camera-view = { group = "androidx.camera", name = "camera-view", version.ref = "cameraX" }
mlkit-barcode = { group = "com.google.mlkit", name = "barcode-scanning", version.ref = "mlkitBarcode" }
# DataStore
datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
# Testing
junit = { group = "junit", name = "junit", version.ref = "junit" }
junit-android = { group = "androidx.test.ext", name = "junit", version.ref = "junitAndroid" }
espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }

View File

@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

View File

@@ -0,0 +1,19 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
@Suppress("UnstableApiUsage")
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "RewardFlowTerminal"
include(":app")

View File

@@ -322,6 +322,11 @@ project/
│ ├── unit/
│ └── integration/
├── clients/ # Standalone client applications (API consumers)
│ └── terminal-android/ # Native Android POS terminal (Kotlin/Compose)
├── scripts/ # Seed scripts, deploy scripts
└── docs/ # Documentation
├── architecture/ # System architecture
├── frontend/ # Frontend guides

View File

@@ -0,0 +1,248 @@
# Proposal: CMS Redesign — Alignment with Market Standards
**Date:** 2026-04-14
**Status:** Draft
**Author:** Samir / Claude
---
## Problem Statement
The CMS has good foundations but several design flaws that create confusion and limit usability:
1. **Template vs Theme are disconnected** — Theme (colors/fonts at `/admin/stores/{code}/theme`) and page template (layout structure in `ContentPage.template`) are separate concepts with overlapping names, no UI connection
2. **No template selector in admin** — The `template` field can only be set via seed data or API, not the UI
3. **Content vs Sections duality** — A page has both a freeform `content` field AND a structured `sections` JSON. Which one renders depends on the template. Confusing for admins
4. **Sections editor shows platform-only sections** — Pricing section appears for store pages where it makes no sense
5. **No title/content translation UI** — The `title_translations` and `content_translations` fields exist in the model but have no admin editor. Only seed data populates them. Store overrides lose translations
6. **Fixed section types** — Only 5-8 section types, can't be extended by modules
7. **No section reordering** — Sections render in a fixed order defined by the template
8. **Everything mixed in one list** — Platform marketing pages, store defaults, and store overrides all in `/admin/content-pages`
### Specific bug found
FASHIONHUB has a store override for `/about` with `title_translations=NULL`. The override was created without translations (no UI to add them), so it always shows "About Fashion Hub" regardless of language. The store default it overrides has full translations (`fr="À propos"`, etc.).
---
## Market Standard (Shopify, WordPress, Squarespace)
| Concept | Market Standard | Our Current State |
|---------|----------------|-------------------|
| **Page types** | "Page" (prose) vs "Landing page" (sections) — clearly distinct | Mixed: same model, hidden `template` field decides rendering |
| **Template** | A starting point you choose when creating a page, pre-populates layout | Hidden field, can't be changed in UI |
| **Sections** | Ordered list, drag-and-drop, add/remove any type | Fixed positions, hardcoded in template |
| **Theme** | Global visual styling (colors, fonts) applied to all templates | Separate system, works but disconnected |
| **Translations** | Per-field translations, always visible when editing | Fields exist but no admin UI for page title/content |
| **Content editing** | Rich text for prose pages, section editor for landing pages | Both shown on same edit page |
| **Storefront management** | Dedicated section (Shopify: "Online Store") | Mixed into Content Management |
---
## Proposed Admin Menu Restructure
### Current
```
Content Management (CMS module, order=70)
├── Content Pages → /admin/content-pages (everything mixed)
└── Store Themes → /admin/store-themes
Platform Admin (Tenancy module)
├── Merchants → /admin/merchants
├── Stores → /admin/stores
└── Platforms → /admin/platforms
```
### Proposed
```
Platform Admin (Tenancy module)
├── Merchants → /admin/merchants
├── Stores → /admin/stores
├── Storefronts → /admin/storefronts ← NEW (card grid per store)
└── Platforms → /admin/platforms
Content Management (CMS module)
├── Platform Pages → /admin/platform-pages (renamed, platform marketing only)
└── Media Library → /admin/media
```
### Storefronts page (`/admin/storefronts`)
Card grid layout (like current `/admin/store-themes` but expanded). Each store card shows:
```
┌─────────────────────────────────────┐
│ 🏪 FashionHub │
│ loyalty platform · active │
│ │
│ [Customize Theme] [Edit Homepage] │
│ [Manage Pages] [Preview →] │
└─────────────────────────────────────┘
```
| Action | What it opens |
|--------|--------------|
| **Customize Theme** | `/admin/stores/{code}/theme` (existing, works well) |
| **Edit Homepage** | Landing page section editor for this store's `slug=home` page |
| **Manage Pages** | List of content pages for this store (about, contact, faq — with translations) |
| **Preview** | Opens storefront in new tab |
This replaces the current **Store Themes** menu item — themes become one tab/action within the broader Storefronts management.
### Platform Pages (`/admin/platform-pages`)
Renamed from "Content Pages". Only shows `is_platform_page=True` pages. Used for platform marketing (homepage, pricing, about, terms, privacy). This is what the admin uses to manage the platform marketing site — not individual store content.
---
## Proposed CMS Changes
### Change 1: Page type selector in admin UI
Add a **Page Type** dropdown at the top of the content page edit form:
| Page Type | Template field | Editor shows | Hides |
|-----------|---------------|-------------|-------|
| **Content Page** | `default` | Title (with translations), content editor (with translations), SEO | Sections editor |
| **Landing Page** | `full` | Title (with translations), section editor, SEO | Content field |
When switching types:
- Content → Landing: initialize empty sections if none exist, hide content field
- Landing → Content: show content field, hide sections editor
- Data is preserved in both cases (no deletion)
### Change 2: Title and content translation UI
Add **language tabs** to the title and content fields — same pattern the sections editor already uses:
```
[FR] [EN] [DE] [LB]
┌────────────────────────────┐
│ Title: À propos │
└────────────────────────────┘
┌────────────────────────────┐
│ Content: │
│ Bienvenue chez ... │
└────────────────────────────┘
```
- Default language tab edits `form.title` / `form.content` directly
- Other language tabs edit `form.title_translations[lang]` / `form.content_translations[lang]`
- When creating a store override from a default, pre-populate translations from the default
### Change 3: Template-driven section palette
The **template** (page type) defines which sections are available — not a hardcoded list filtered by context. The admin section editor loads the available section types from a template config.
| Template | Available Sections |
|----------|-------------------|
| `default` (platform homepage) | hero, features, products, pricing, testimonials, gallery, contact_info, cta |
| `full` (store landing page) | hero, features, testimonials, gallery, contact_info, cta |
Implementation: a `TEMPLATE_SECTION_PALETTE` dict mapping template name → list of allowed section types. The route handler passes the palette to the editor JS, which only renders sections in the palette. This keeps the logic in one place and sets up Phase C/D — when sections become an ordered array with add/remove, the template defines the palette of available types, and modules can extend that palette.
### Change 4: Sections as ordered list (future)
**Current:** Dict with fixed keys (`{"hero": {...}, "features": {...}, "cta": {...}}`)
**Proposed:** Ordered array:
```json
[
{"type": "hero", "enabled": true, "data": {...}},
{"type": "features", "enabled": true, "data": {...}},
{"type": "cta", "enabled": true, "data": {...}}
]
```
Benefits:
- Admin can reorder (drag-and-drop)
- Admin can add/remove section types
- Template iterates array generically
- New section types don't require template changes
Migration: if `sections` is a dict → render in legacy order. If array → render in order.
### Change 5: Module-contributed section types (future)
New contract: `StorefrontSectionProviderProtocol`
- Catalog contributes: `product-showcase`, `category-grid`
- Loyalty contributes: `loyalty-signup`, `rewards-overview`
- Section registry aggregates from enabled modules
- Admin section editor shows available types from enabled modules
---
## What Stays the Same
- **3-tier content hierarchy** (platform → store default → store override) — solid
- **TranslatableText pattern** for sections — well-built
- **Section partials** as Jinja2 macros — reusable, themeable
- **Module-driven menus and widgets** — clean contracts
- **Theme system** (colors, fonts, CSS variables) — works well
- **CMS context providers** for header/footer pages — good pattern
- **ContentPage model** — no schema changes needed for Phase A
---
## Implementation Phases
### Phase A: Quick fixes (immediate, no schema changes)
- [ ] Title + content translation UI (language tabs on edit page)
- [ ] Page type selector (Content Page / Landing Page dropdown)
- [ ] Hide content field when Landing Page selected
- [ ] Template-driven section palette (template defines which sections are available)
- [ ] Fix: FASHIONHUB about page — add translations
- [ ] Fix: store theme API bug (done — `get_store_by_code_or_subdomain`)
### Phase B: Menu restructure + Storefronts page
- [ ] Add "Storefronts" menu item under Platform Admin
- [ ] Build card grid page at `/admin/storefronts`
- [ ] Rename "Content Pages" → "Platform Pages" (filter to `is_platform_page=True`)
- [ ] Move Store Themes into Storefronts
- [ ] "Edit Homepage" action on store card → section editor for store's home page
- [ ] "Manage Pages" action → filtered content page list for that store
### Phase C: Section ordering + add/remove
- [ ] Migrate sections from dict to ordered array
- [ ] Drag-and-drop reordering in admin section editor
- [ ] Add/remove sections from available types
- [ ] Template renders sections from ordered array
- [ ] Backward compatibility for dict-format sections
### Phase D: Module-contributed sections
- [ ] `StorefrontSectionProviderProtocol` contract
- [ ] Catalog: product-showcase section
- [ ] Loyalty: loyalty-signup section
- [ ] Section registry in CMS module
- [ ] Admin section editor shows available types from enabled modules
---
## Relation to Storefront Builder Vision
This proposal covers the CMS foundation. The broader [storefront builder vision](storefront-builder-vision.md) builds on top:
| Builder Vision Phase | CMS Redesign Phase |
|---------------------|-------------------|
| Phase 1: Wire sections to store homepages | ✅ Done |
| Phase 2: Module header actions | ✅ Done |
| Phase 3: Module-contributed sections | Phase D |
| Phase 4: Widget slots | Separate (post Phase D) |
| Phase 5: Per-store menus | Phase B sets up the UI |
| Phase 6: Visual editor | Post Phase C (drag-and-drop foundation) |
---
## Key Files
| Component | File |
|-----------|------|
| Content page edit template | `app/modules/cms/templates/cms/admin/content-page-edit.html` |
| Content page edit JS | `app/modules/cms/static/admin/js/content-page-edit.js` |
| ContentPage model | `app/modules/cms/models/content_page.py` |
| Section schemas | `app/modules/cms/schemas/homepage_sections.py` |
| Section partials | `app/modules/cms/templates/cms/platform/sections/_*.html` |
| CMS definition (admin menu) | `app/modules/cms/definition.py` |
| Tenancy definition (admin menu) | `app/modules/tenancy/definition.py` |
| Store theme page | `app/modules/cms/templates/cms/admin/store-theme.html` |
| Store themes list | `app/modules/cms/templates/cms/admin/store-themes.html` |
| Storefront landing templates | `app/modules/cms/templates/cms/storefront/landing-*.html` |
| Seed data | `scripts/seed/create_default_content_pages.py` |

View File

@@ -0,0 +1,174 @@
# Proposal: Transaction Categories (What Was Sold)
**Created:** 2026-04-19
**Status:** Approved — ready to implement
**Priority:** Urgent (client requirement for production launch)
## Context
The client requires sellers to select what was sold (e.g., Men, Women, Accessories, Kids) when entering a loyalty transaction. This enables per-category sales analytics for merchants. Each store can have 4-5 categories, configured by the admin or merchant owner.
## Data Model
### New table: `store_transaction_categories`
| Column | Type | Description |
|---|---|---|
| `id` | Integer PK | Auto-increment |
| `store_id` | FK → stores | Store this category belongs to |
| `name` | String(100) | Display name (e.g., "Men", "Women", "Accessories") |
| `display_order` | Integer | Sort order in the selector (1, 2, 3...) |
| `is_active` | Boolean | Soft-disable without deleting |
| `created_at` | DateTime | Timestamp |
| `updated_at` | DateTime | Timestamp |
**Unique constraint:** `(store_id, name)` — no duplicate names per store.
**Max categories per store:** 10 (enforced at API level, soft limit).
### Modify: `loyalty_transactions`
| Column | Type | Description |
|---|---|---|
| `category_id` | FK → store_transaction_categories (nullable) | What was sold |
Nullable because: existing transactions don't have categories, and enrollment/expiration/void transactions don't involve a sale.
## Migration
`loyalty_007_add_transaction_categories.py`:
1. Create `store_transaction_categories` table
2. Add `category_id` FK column to `loyalty_transactions`
## API Endpoints
### Admin CRUD (on behalf of store)
| Method | Endpoint | Description |
|---|---|---|
| `GET` | `/admin/loyalty/stores/{store_id}/categories` | List categories for a store |
| `POST` | `/admin/loyalty/stores/{store_id}/categories` | Create category |
| `PATCH` | `/admin/loyalty/stores/{store_id}/categories/{id}` | Update category (name, order, active) |
| `DELETE` | `/admin/loyalty/stores/{store_id}/categories/{id}` | Delete category |
### Store/Merchant CRUD (own store)
| Method | Endpoint | Description |
|---|---|---|
| `GET` | `/store/loyalty/categories` | List categories for current store |
| `POST` | `/store/loyalty/categories` | Create category |
| `PATCH` | `/store/loyalty/categories/{id}` | Update category |
| `DELETE` | `/store/loyalty/categories/{id}` | Delete category |
### Modified transaction endpoints
`POST /store/loyalty/stamp`, `POST /store/loyalty/points/earn`, `POST /store/loyalty/points/redeem` — add optional `category_id` field to request body.
`GET /store/loyalty/transactions` and card detail transactions — include `category_name` in response.
## Schemas
### CategoryCreate
```python
class CategoryCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
display_order: int = Field(default=0, ge=0)
```
### CategoryUpdate
```python
class CategoryUpdate(BaseModel):
name: str | None = Field(None, min_length=1, max_length=100)
display_order: int | None = Field(None, ge=0)
is_active: bool | None = None
```
### CategoryResponse
```python
class CategoryResponse(BaseModel):
id: int
store_id: int
name: str
display_order: int
is_active: bool
```
## Service Layer
New `category_service.py` with:
- `list_categories(db, store_id)` — ordered by display_order
- `create_category(db, store_id, data)` — enforce max 10 per store
- `update_category(db, category_id, store_id, data)` — ownership check
- `delete_category(db, category_id, store_id)` — ownership check
- `validate_category_for_store(db, category_id, store_id)` — used by stamp/points services
## UI Changes
### Admin panel
- New "Transaction Categories" section on the merchant store detail page
- Table with name, order, active toggle, edit/delete buttons
- "Add Category" button with modal form
### Store/Merchant settings
- New "Transaction Categories" tab or section on the store settings page
- Same CRUD UI as admin
### Web terminal
- Category selector (radio buttons or button group) shown before the PIN modal
- Only shown when the store has categories configured
- Stored on the transaction
### Android terminal
- Same category selector in the transaction flow
- Categories cached locally (refreshed from API periodically)
- Works offline (category list is static, just an ID reference)
## Card Lookup Response
Add `categories: list[CategoryResponse]` to `CardLookupResponse` so the terminal has the list without a separate API call.
Or: fetch categories once on terminal load and cache them.
**Recommendation:** Fetch once on terminal load — categories are store-level, not card-level. Add `GET /store/loyalty/categories` call to terminal init.
## Transaction History
`TransactionResponse` gets a new `category_name: str | None` field so card detail and terminal transaction tables can show what was sold.
## Analytics
The `analytics_service` can later be extended to group revenue by category — but that's a future enhancement, not in this scope.
## Files to create/modify
| File | Action |
|---|---|
| `app/modules/loyalty/models/transaction_category.py` | NEW — model |
| `app/modules/loyalty/models/loyalty_transaction.py` | Add `category_id` FK |
| `app/modules/loyalty/migrations/versions/loyalty_007_*.py` | NEW — migration |
| `app/modules/loyalty/services/category_service.py` | NEW — CRUD service |
| `app/modules/loyalty/schemas/category.py` | NEW — Pydantic schemas |
| `app/modules/loyalty/schemas/card.py` | Add `category_id` to stamp/points requests |
| `app/modules/loyalty/routes/api/admin.py` | Add admin CRUD endpoints |
| `app/modules/loyalty/routes/api/store.py` | Add store CRUD + modify transaction endpoints |
| `app/modules/loyalty/services/stamp_service.py` | Accept + store `category_id` |
| `app/modules/loyalty/services/points_service.py` | Accept + store `category_id` |
| `app/modules/loyalty/templates/loyalty/store/terminal.html` | Add category selector |
| `app/modules/loyalty/static/store/js/loyalty-terminal.js` | Load categories, pass to transactions |
| `clients/terminal-android/.../data/model/ApiModels.kt` | Add `category_id` to request models |
## Effort
~0.5 day backend (model + migration + service + routes + schemas)
~0.5 day frontend (web terminal category selector + admin CRUD UI)
Android terminal gets it for free via the API models already scaffolded.
## Test plan
- Create 5 categories for a store via admin API
- Verify max 10 limit
- Add stamp with category_id → transaction has category
- Add points with category_id → transaction has category
- Transaction history shows category name
- Terminal loads categories on init
- Category selector appears only when categories exist
- Delete category → existing transactions keep the name (FK nullable, name denormalized or joined)

View File

@@ -2099,47 +2099,319 @@ SHARED_PLATFORM_PAGES = [
STORE_DEFAULT_HOME = {
"slug": "home",
"title": "Welcome to {{store_name}}",
"title": "{{store_name}}",
"title_translations": tt(
"Welcome to {{store_name}}",
"Bienvenue chez {{store_name}}",
"Willkommen bei {{store_name}}",
"Wëllkomm bei {{store_name}}",
"{{store_name}}",
"{{store_name}}",
"{{store_name}}",
"{{store_name}}",
),
"content": """<div class="prose-content">
<h2>Welcome</h2>
<p>{{store_name}} is here to serve you. Browse our offerings and discover what we have for you.</p>
</div>""",
"content_translations": tt(
# English
"""<div class="prose-content">
<h2>Welcome</h2>
<p>{{store_name}} is here to serve you. Browse our offerings and discover what we have for you.</p>
</div>""",
# French
"""<div class="prose-content">
<h2>Bienvenue</h2>
<p>{{store_name}} est là pour vous servir. Découvrez nos offres et ce que nous avons pour vous.</p>
</div>""",
# German
"""<div class="prose-content">
<h2>Willkommen</h2>
<p>{{store_name}} ist für Sie da. Entdecken Sie unser Angebot.</p>
</div>""",
# Luxembourgish
"""<div class="prose-content">
<h2>Wëllkomm</h2>
<p>{{store_name}} ass fir Iech do. Entdeckt eist Angebot.</p>
</div>""",
),
"template": "default",
"meta_description": "Welcome to {{store_name}}",
"content": "",
"template": "full",
"meta_description": "{{store_name}}",
"show_in_header": False,
"show_in_footer": False,
"display_order": 0,
}
def _store_homepage_sections_oms() -> dict:
"""Store homepage sections for OMS platform stores."""
return {
"hero": {
"enabled": True,
"title": t(
"Bienvenue chez {{store_name}}",
"Welcome to {{store_name}}",
"Willkommen bei {{store_name}}",
),
"subtitle": t(
"Découvrez notre sélection de produits et profitez d'une expérience d'achat exceptionnelle.",
"Discover our product selection and enjoy an exceptional shopping experience.",
"Entdecken Sie unsere Produktauswahl und genießen Sie ein außergewöhnliches Einkaufserlebnis.",
),
"background_type": "gradient",
"buttons": [
{
"text": t("Voir nos produits", "Browse Products", "Produkte ansehen"),
"url": "products",
"style": "primary",
},
{
"text": t("À propos", "About Us", "Über uns"),
"url": "about",
"style": "secondary",
},
],
},
"features": {
"enabled": True,
"title": t(
"Pourquoi nous choisir",
"Why Choose Us",
"Warum uns wählen",
),
"subtitle": t(
"Ce qui nous distingue",
"What sets us apart",
"Was uns auszeichnet",
),
"layout": "grid",
"features": [
{
"icon": "check",
"title": t("Qualité premium", "Premium Quality", "Premium-Qualität"),
"description": t(
"Des produits soigneusement sélectionnés pour vous.",
"Carefully selected products just for you.",
"Sorgfältig ausgewählte Produkte für Sie.",
),
},
{
"icon": "truck",
"title": t("Livraison rapide", "Fast Shipping", "Schnelle Lieferung"),
"description": t(
"Livraison rapide directement chez vous.",
"Quick delivery right to your door.",
"Schnelle Lieferung direkt zu Ihnen.",
),
},
{
"icon": "shield-check",
"title": t("Paiement sécurisé", "Secure Payment", "Sichere Zahlung"),
"description": t(
"Vos transactions sont protégées à 100%.",
"Your transactions are 100% protected.",
"Ihre Transaktionen sind 100% geschützt.",
),
},
{
"icon": "chat-bubble-left",
"title": t("Support client", "Customer Support", "Kundensupport"),
"description": t(
"Une équipe à votre écoute pour vous accompagner.",
"A dedicated team ready to assist you.",
"Ein engagiertes Team, das Ihnen zur Seite steht.",
),
},
],
},
"cta": {
"enabled": True,
"title": t(
"Prêt à découvrir nos produits ?",
"Ready to Explore Our Products?",
"Bereit, unsere Produkte zu entdecken?",
),
"subtitle": t(
"Parcourez notre catalogue et trouvez ce qui vous convient.",
"Browse our catalog and find what suits you.",
"Durchstöbern Sie unseren Katalog und finden Sie, was zu Ihnen passt.",
),
"background_type": "gradient",
"buttons": [
{
"text": t("Voir les produits", "View Products", "Produkte ansehen"),
"url": "products",
"style": "primary",
},
],
},
}
def _store_homepage_sections_loyalty() -> dict:
"""Store homepage sections for Loyalty platform stores."""
return {
"hero": {
"enabled": True,
"title": t(
"Bienvenue chez {{store_name}}",
"Welcome to {{store_name}}",
"Willkommen bei {{store_name}}",
),
"subtitle": t(
"Rejoignez notre programme de fidélité et profitez de récompenses exclusives à chaque visite.",
"Join our loyalty program and enjoy exclusive rewards with every visit.",
"Treten Sie unserem Treueprogramm bei und genießen Sie exklusive Prämien bei jedem Besuch.",
),
"background_type": "gradient",
"buttons": [
{
"text": t("Rejoindre le programme", "Join Rewards", "Programm beitreten"),
"url": "account/loyalty",
"style": "primary",
},
{
"text": t("En savoir plus", "Learn More", "Mehr erfahren"),
"url": "about",
"style": "secondary",
},
],
},
"features": {
"enabled": True,
"title": t(
"Votre fidélité récompensée",
"Your Loyalty Rewarded",
"Ihre Treue wird belohnt",
),
"subtitle": t(
"Découvrez les avantages de notre programme",
"Discover the benefits of our program",
"Entdecken Sie die Vorteile unseres Programms",
),
"layout": "grid",
"features": [
{
"icon": "star",
"title": t("Gagnez des points", "Earn Points", "Punkte sammeln"),
"description": t(
"Cumulez des points à chaque achat et échangez-les contre des récompenses.",
"Accumulate points with every purchase and redeem them for rewards.",
"Sammeln Sie bei jedem Einkauf Punkte und lösen Sie sie gegen Prämien ein.",
),
},
{
"icon": "gift",
"title": t("Récompenses exclusives", "Exclusive Rewards", "Exklusive Prämien"),
"description": t(
"Accédez à des offres et récompenses réservées aux membres.",
"Access offers and rewards reserved for members.",
"Zugang zu Angeboten und Prämien nur für Mitglieder.",
),
},
{
"icon": "heart",
"title": t("Avantages membres", "Member Benefits", "Mitgliedervorteile"),
"description": t(
"Profitez d'avantages exclusifs en tant que membre fidèle.",
"Enjoy exclusive benefits as a loyal member.",
"Genießen Sie exklusive Vorteile als treues Mitglied.",
),
},
],
},
"cta": {
"enabled": True,
"title": t(
"Rejoignez-nous aujourd'hui",
"Join Us Today",
"Treten Sie uns noch heute bei",
),
"subtitle": t(
"Inscrivez-vous à notre programme de fidélité et commencez à gagner des récompenses.",
"Sign up for our loyalty program and start earning rewards.",
"Melden Sie sich für unser Treueprogramm an und beginnen Sie, Prämien zu verdienen.",
),
"background_type": "gradient",
"buttons": [
{
"text": t("S'inscrire", "Sign Up", "Anmelden"),
"url": "account/loyalty",
"style": "primary",
},
],
},
}
def _store_homepage_sections_hosting() -> dict:
"""Store homepage sections for Hosting platform stores (client sites)."""
return {
"hero": {
"enabled": True,
"title": t(
"Bienvenue chez {{store_name}}",
"Welcome to {{store_name}}",
"Willkommen bei {{store_name}}",
),
"subtitle": t(
"Votre partenaire de confiance pour des solutions numériques sur mesure.",
"Your trusted partner for tailored digital solutions.",
"Ihr vertrauenswürdiger Partner für maßgeschneiderte digitale Lösungen.",
),
"background_type": "gradient",
"buttons": [
{
"text": t("Nous contacter", "Contact Us", "Kontaktieren Sie uns"),
"url": "contact",
"style": "primary",
},
{
"text": t("À propos", "About Us", "Über uns"),
"url": "about",
"style": "secondary",
},
],
},
"features": {
"enabled": True,
"title": t(
"Nos services",
"Our Services",
"Unsere Dienstleistungen",
),
"subtitle": t(
"Ce que nous pouvons faire pour vous",
"What we can do for you",
"Was wir für Sie tun können",
),
"layout": "grid",
"features": [
{
"icon": "globe-alt",
"title": t("Site web", "Website", "Webseite"),
"description": t(
"Un site web professionnel qui reflète votre activité.",
"A professional website that reflects your business.",
"Eine professionelle Website, die Ihr Unternehmen widerspiegelt.",
),
},
{
"icon": "shield-check",
"title": t("Hébergement sécurisé", "Secure Hosting", "Sicheres Hosting"),
"description": t(
"Hébergement fiable avec certificat SSL inclus.",
"Reliable hosting with SSL certificate included.",
"Zuverlässiges Hosting mit SSL-Zertifikat inklusive.",
),
},
{
"icon": "mail",
"title": t("Email professionnel", "Professional Email", "Professionelle E-Mail"),
"description": t(
"Adresses email personnalisées pour votre entreprise.",
"Custom email addresses for your business.",
"Individuelle E-Mail-Adressen für Ihr Unternehmen.",
),
},
],
},
"cta": {
"enabled": True,
"title": t(
"Besoin d'aide ?",
"Need Help?",
"Brauchen Sie Hilfe?",
),
"subtitle": t(
"Contactez-nous pour discuter de votre projet.",
"Contact us to discuss your project.",
"Kontaktieren Sie uns, um Ihr Projekt zu besprechen.",
),
"background_type": "gradient",
"buttons": [
{
"text": t("Nous contacter", "Contact Us", "Kontaktieren Sie uns"),
"url": "contact",
"style": "primary",
},
],
},
}
STORE_DEFAULTS_COMMON = [
{
"slug": "about",
@@ -2796,8 +3068,18 @@ def create_default_pages(db: Session) -> None:
# Only for platforms that host stores (not wizard.lu main)
# ------------------------------------------------------------------
if platform.code != "main":
# Store homepage (slug="home")
if _create_page(db, platform.id, STORE_DEFAULT_HOME, is_platform_page=False):
# Store homepage (slug="home") with platform-specific sections
store_sections_map = {
"oms": _store_homepage_sections_oms,
"loyalty": _store_homepage_sections_loyalty,
"hosting": _store_homepage_sections_hosting,
}
store_sections_fn = store_sections_map.get(platform.code)
store_sections = store_sections_fn() if store_sections_fn else None
if _create_page(
db, platform.id, STORE_DEFAULT_HOME,
is_platform_page=False, sections=store_sections,
):
created_count += 1
else:
skipped_count += 1