From eafa086c73500bab447db0ee92e2ed7367a70c28 Mon Sep 17 00:00:00 2001 From: Samir Boulahtit Date: Sun, 19 Apr 2026 14:12:55 +0200 Subject: [PATCH] feat(loyalty): translatable categories + mandatory on earn points - Add name_translations JSON column to StoreTransactionCategory (migration loyalty_008). Stores {"en": "Men", "fr": "Hommes", ...}. Model has get_translated_name(lang) helper. - Admin CRUD form now has FR/DE/LB translation inputs alongside the default name. - Points earn: category_id is now mandatory when the store has active categories configured. Returns CATEGORY_REQUIRED error. - Stamps: category remains optional (quick tap workflow). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../loyalty_008_add_category_translations.py | 33 +++++++++++++++++ .../loyalty/models/transaction_category.py | 12 +++++++ app/modules/loyalty/schemas/category.py | 6 ++++ .../loyalty/services/points_service.py | 14 ++++++++ .../admin/js/loyalty-merchant-detail.js | 10 ++++++ .../loyalty/admin/merchant-detail.html | 35 ++++++++++++++----- 6 files changed, 101 insertions(+), 9 deletions(-) create mode 100644 app/modules/loyalty/migrations/versions/loyalty_008_add_category_translations.py diff --git a/app/modules/loyalty/migrations/versions/loyalty_008_add_category_translations.py b/app/modules/loyalty/migrations/versions/loyalty_008_add_category_translations.py new file mode 100644 index 00000000..99ddbeed --- /dev/null +++ b/app/modules/loyalty/migrations/versions/loyalty_008_add_category_translations.py @@ -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") diff --git a/app/modules/loyalty/models/transaction_category.py b/app/modules/loyalty/models/transaction_category.py index 9d046a62..d4b4cb98 100644 --- a/app/modules/loyalty/models/transaction_category.py +++ b/app/modules/loyalty/models/transaction_category.py @@ -15,6 +15,7 @@ from sqlalchemy import ( String, UniqueConstraint, ) +from sqlalchemy.dialects.sqlite import JSON from sqlalchemy.orm import relationship from app.core.database import Base @@ -29,6 +30,11 @@ class StoreTransactionCategory(Base, TimestampMixin): 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) @@ -42,3 +48,9 @@ class StoreTransactionCategory(Base, TimestampMixin): def __repr__(self): return f"" + + 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 diff --git a/app/modules/loyalty/schemas/category.py b/app/modules/loyalty/schemas/category.py index 795e4cb8..284a8f2d 100644 --- a/app/modules/loyalty/schemas/category.py +++ b/app/modules/loyalty/schemas/category.py @@ -10,6 +10,10 @@ 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) @@ -17,6 +21,7 @@ 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 @@ -29,6 +34,7 @@ class CategoryResponse(BaseModel): id: int store_id: int name: str + name_translations: dict[str, str] | None = None display_order: int is_active: bool created_at: datetime diff --git a/app/modules/loyalty/services/points_service.py b/app/modules/loyalty/services/points_service.py index 4d0f5cb3..756d0c5a 100644 --- a/app/modules/loyalty/services/points_service.py +++ b/app/modules/loyalty/services/points_service.py @@ -23,6 +23,7 @@ from app.modules.loyalty.exceptions import ( InsufficientPointsException, InvalidRewardException, LoyaltyCardInactiveException, + LoyaltyException, LoyaltyProgramInactiveException, OrderReferenceRequiredException, StaffPinRequiredException, @@ -102,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_id: + 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 = ( diff --git a/app/modules/loyalty/static/admin/js/loyalty-merchant-detail.js b/app/modules/loyalty/static/admin/js/loyalty-merchant-detail.js index 879f4c09..45713dc3 100644 --- a/app/modules/loyalty/static/admin/js/loyalty-merchant-detail.js +++ b/app/modules/loyalty/static/admin/js/loyalty-merchant-detail.js @@ -38,6 +38,7 @@ function adminLoyaltyMerchantDetail() { storeCategories: [], showAddCategory: false, newCategoryName: '', + newCategoryTranslations: { fr: '', de: '', lb: '' }, // State loading: false, @@ -284,11 +285,20 @@ function adminLoyaltyMerchantDetail() { 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'); diff --git a/app/modules/loyalty/templates/loyalty/admin/merchant-detail.html b/app/modules/loyalty/templates/loyalty/admin/merchant-detail.html index 7b1400b8..b04833bd 100644 --- a/app/modules/loyalty/templates/loyalty/admin/merchant-detail.html +++ b/app/modules/loyalty/templates/loyalty/admin/merchant-detail.html @@ -231,21 +231,38 @@ -
-
-
- - +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+
+ -