feat(loyalty): translatable categories + mandatory on earn points
Some checks failed
CI / pytest (push) Failing after 2h47m45s
CI / validate (push) Successful in 39s
CI / dependency-scanning (push) Successful in 47s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped
CI / ruff (push) Successful in 21s

- Add name_translations JSON column to StoreTransactionCategory
  (migration loyalty_008). Stores {"en": "Men", "fr": "Hommes", ...}.
  Model has get_translated_name(lang) helper.
- Admin CRUD form now has FR/DE/LB translation inputs alongside the
  default name.
- Points earn: category_id is now mandatory when the store has
  active categories configured. Returns CATEGORY_REQUIRED error.
- Stamps: category remains optional (quick tap workflow).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-19 14:12:55 +02:00
parent ab2daf99bd
commit eafa086c73
6 changed files with 101 additions and 9 deletions

View File

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

View File

@@ -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"<StoreTransactionCategory(id={self.id}, store={self.store_id}, name='{self.name}')>"
def get_translated_name(self, lang: str) -> str:
"""Get name in the given language, falling back to self.name."""
if self.name_translations and isinstance(self.name_translations, dict):
return self.name_translations.get(lang) or self.name
return self.name

View File

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

View File

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

View File

@@ -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');

View File

@@ -231,21 +231,38 @@
</div>
<!-- Add category inline form -->
<div x-show="showAddCategory" class="mb-4 p-3 border border-purple-200 dark:border-purple-800 rounded-lg bg-purple-50 dark:bg-purple-900/20">
<div class="flex items-end gap-3">
<div class="flex-1">
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">{{ _('loyalty.common.name') }}</label>
<input type="text" x-model="newCategoryName" maxlength="100"
<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>
<button @click="showAddCategory = false; newCategoryName = ''" 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>
</div>
</div>