Compare commits
23 Commits
adc36246b8
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 3883927be0 | |||
| 39e02f0d9b | |||
| 29593f4c61 | |||
| 220f7e3a08 | |||
| 258aa6a34b | |||
| 51bcc9f874 | |||
| eafa086c73 | |||
| ab2daf99bd | |||
| 1cf9fea40a | |||
| cd4f83f2cb | |||
| 457350908a | |||
| e759282116 | |||
| 1df1b2bfca | |||
| 51a2114e02 | |||
| 21e4ac5124 | |||
| 3ade1b9354 | |||
| b5bb9415f6 | |||
| bb3d6f0012 | |||
| c92fe1261b | |||
| ca152cd544 | |||
| 914967edcc | |||
| 64fe58c171 | |||
| 3044490a3e |
@@ -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
|
||||
|
||||
@@ -72,6 +72,7 @@ cart_module = ModuleDefinition(
|
||||
icon="shopping-cart",
|
||||
route="cart",
|
||||
order=20,
|
||||
header_template="cart/storefront/partials/header-cart.html",
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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>
|
||||
@@ -152,6 +152,7 @@ catalog_module = ModuleDefinition(
|
||||
icon="search",
|
||||
route="",
|
||||
order=10,
|
||||
header_template="catalog/storefront/partials/header-search.html",
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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>
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
# =========================================================================
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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(
|
||||
|
||||
56
app/modules/loyalty/models/transaction_category.py
Normal file
56
app/modules/loyalty/models/transaction_category.py
Normal 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
|
||||
@@ -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
|
||||
# =============================================================================
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
48
app/modules/loyalty/schemas/category.py
Normal file
48
app/modules/loyalty/schemas/category.py
Normal 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
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
160
app/modules/loyalty/services/category_service.py
Normal file
160
app/modules/loyalty/services/category_service.py
Normal 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()
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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)
|
||||
# ============================================================================
|
||||
|
||||
@@ -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') }}">
|
||||
|
||||
@@ -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
14
clients/terminal-android/.gitignore
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
*.iml
|
||||
.gradle
|
||||
/local.properties
|
||||
/.idea
|
||||
.DS_Store
|
||||
/build
|
||||
/captures
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
local.properties
|
||||
/app/build
|
||||
/app/release
|
||||
*.apk
|
||||
*.aab
|
||||
100
clients/terminal-android/SETUP.md
Normal file
100
clients/terminal-android/SETUP.md
Normal 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
|
||||
```
|
||||
108
clients/terminal-android/app/build.gradle.kts
Normal file
108
clients/terminal-android/app/build.gradle.kts
Normal 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)
|
||||
}
|
||||
7
clients/terminal-android/app/proguard-rules.pro
vendored
Normal file
7
clients/terminal-android/app/proguard-rules.pro
vendored
Normal 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*
|
||||
38
clients/terminal-android/app/src/main/AndroidManifest.xml
Normal file
38
clients/terminal-android/app/src/main/AndroidManifest.xml
Normal 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>
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package lu.rewardflow.terminal
|
||||
|
||||
import android.app.Application
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
|
||||
@HiltAndroidApp
|
||||
class RewardFlowApp : Application()
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">RewardFlow Terminal</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,5 @@
|
||||
<resources>
|
||||
<style name="Theme.RewardFlowTerminal" parent="Theme.Material3.DayNight.NoActionBar">
|
||||
<item name="android:windowFullscreen">true</item>
|
||||
</style>
|
||||
</resources>
|
||||
8
clients/terminal-android/build.gradle.kts
Normal file
8
clients/terminal-android/build.gradle.kts
Normal 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
|
||||
}
|
||||
4
clients/terminal-android/gradle.properties
Normal file
4
clients/terminal-android/gradle.properties
Normal file
@@ -0,0 +1,4 @@
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
android.nonTransitiveRClass=true
|
||||
113
clients/terminal-android/gradle/libs.versions.toml
Normal file
113
clients/terminal-android/gradle/libs.versions.toml
Normal 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" }
|
||||
7
clients/terminal-android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
clients/terminal-android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
|
||||
19
clients/terminal-android/settings.gradle.kts
Normal file
19
clients/terminal-android/settings.gradle.kts
Normal 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")
|
||||
@@ -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
|
||||
|
||||
248
docs/proposals/cms-redesign-alignment.md
Normal file
248
docs/proposals/cms-redesign-alignment.md
Normal 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` |
|
||||
174
docs/proposals/transaction-categories.md
Normal file
174
docs/proposals/transaction-categories.md
Normal 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)
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user