feat(loyalty): translatable categories + mandatory on earn points
Some checks failed
Some checks failed
- 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:
@@ -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")
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user