diff --git a/app/modules/loyalty/definition.py b/app/modules/loyalty/definition.py index 60c23fe0..d1ef845f 100644 --- a/app/modules/loyalty/definition.py +++ b/app/modules/loyalty/definition.py @@ -23,6 +23,13 @@ def _get_admin_router(): return admin_router +def _get_merchant_router(): + """Lazy import of merchant router to avoid circular imports.""" + from app.modules.loyalty.routes.api.merchant import router as merchant_router + + return merchant_router + + def _get_store_router(): """Lazy import of store router to avoid circular imports.""" from app.modules.loyalty.routes.api.store import store_router @@ -119,6 +126,7 @@ loyalty_module = ModuleDefinition( ], FrontendType.MERCHANT: [ "loyalty-overview", # Merchant loyalty overview + "loyalty-settings", # Merchant loyalty settings ], }, # New module-driven menu definitions @@ -192,6 +200,13 @@ loyalty_module = ModuleDefinition( route="/merchants/loyalty/overview", order=10, ), + MenuItemDefinition( + id="loyalty-settings", + label_key="loyalty.menu.settings", + icon="cog", + route="/merchants/loyalty/settings", + order=20, + ), ], ), ], @@ -253,6 +268,7 @@ def get_loyalty_module_with_routers() -> ModuleDefinition: during module initialization. """ loyalty_module.admin_router = _get_admin_router() + loyalty_module.merchant_router = _get_merchant_router() loyalty_module.store_router = _get_store_router() loyalty_module.platform_router = _get_platform_router() loyalty_module.storefront_router = _get_storefront_router() diff --git a/app/modules/loyalty/locales/de.json b/app/modules/loyalty/locales/de.json index b5463020..0d6c10d2 100644 --- a/app/modules/loyalty/locales/de.json +++ b/app/modules/loyalty/locales/de.json @@ -77,6 +77,8 @@ "dashboard": "Dashboard", "terminal": "Terminal", "customer_cards": "Kundenkarten", - "statistics": "Statistiken" + "statistics": "Statistiken", + "overview": "Übersicht", + "settings": "Einstellungen" } } diff --git a/app/modules/loyalty/locales/en.json b/app/modules/loyalty/locales/en.json index a0b40756..b288b40b 100644 --- a/app/modules/loyalty/locales/en.json +++ b/app/modules/loyalty/locales/en.json @@ -77,6 +77,8 @@ "dashboard": "Dashboard", "terminal": "Terminal", "customer_cards": "Customer Cards", - "statistics": "Statistics" + "statistics": "Statistics", + "overview": "Overview", + "settings": "Settings" } } diff --git a/app/modules/loyalty/locales/fr.json b/app/modules/loyalty/locales/fr.json index df018e27..0f7de3f3 100644 --- a/app/modules/loyalty/locales/fr.json +++ b/app/modules/loyalty/locales/fr.json @@ -77,6 +77,8 @@ "dashboard": "Tableau de bord", "terminal": "Terminal", "customer_cards": "Cartes clients", - "statistics": "Statistiques" + "statistics": "Statistiques", + "overview": "Aperçu", + "settings": "Paramètres" } } diff --git a/app/modules/loyalty/locales/lb.json b/app/modules/loyalty/locales/lb.json index 954c69f5..cf3d56a9 100644 --- a/app/modules/loyalty/locales/lb.json +++ b/app/modules/loyalty/locales/lb.json @@ -77,6 +77,8 @@ "dashboard": "Dashboard", "terminal": "Terminal", "customer_cards": "Clientekaarten", - "statistics": "Statistiken" + "statistics": "Statistiken", + "overview": "Iwwersiicht", + "settings": "Astellungen" } } diff --git a/app/modules/loyalty/routes/api/admin.py b/app/modules/loyalty/routes/api/admin.py index 9cd4425e..d256a222 100644 --- a/app/modules/loyalty/routes/api/admin.py +++ b/app/modules/loyalty/routes/api/admin.py @@ -20,9 +20,11 @@ from app.modules.loyalty.schemas import ( MerchantSettingsResponse, MerchantSettingsUpdate, MerchantStatsResponse, + ProgramCreate, ProgramListResponse, ProgramResponse, ProgramStatsResponse, + ProgramUpdate, ) from app.modules.loyalty.services import program_service from app.modules.tenancy.models import User # API-007 @@ -107,6 +109,93 @@ def get_program_stats( return ProgramStatsResponse(**stats) +@admin_router.post( + "/merchants/{merchant_id}/program", response_model=ProgramResponse, status_code=201 +) +def create_program_for_merchant( + data: ProgramCreate, + merchant_id: int = Path(..., gt=0), + current_user: User = Depends(get_current_admin_api), + db: Session = Depends(get_db), +): + """Create a loyalty program for a merchant (admin override).""" + program = program_service.create_program(db, merchant_id, data) + + response = ProgramResponse.model_validate(program) + response.is_stamps_enabled = program.is_stamps_enabled + response.is_points_enabled = program.is_points_enabled + response.display_name = program.display_name + + logger.info(f"Admin created loyalty program for merchant {merchant_id}") + return response + + +@admin_router.patch("/programs/{program_id}", response_model=ProgramResponse) +def update_program( + data: ProgramUpdate, + program_id: int = Path(..., gt=0), + current_user: User = Depends(get_current_admin_api), + db: Session = Depends(get_db), +): + """Update a loyalty program (admin override).""" + program = program_service.update_program(db, program_id, data) + + response = ProgramResponse.model_validate(program) + response.is_stamps_enabled = program.is_stamps_enabled + response.is_points_enabled = program.is_points_enabled + response.display_name = program.display_name + + logger.info(f"Admin updated loyalty program {program_id}") + return response + + +@admin_router.delete("/programs/{program_id}", status_code=204) +def delete_program( + program_id: int = Path(..., gt=0), + current_user: User = Depends(get_current_admin_api), + db: Session = Depends(get_db), +): + """Delete a loyalty program (admin override).""" + program_service.delete_program(db, program_id) + logger.info(f"Admin deleted loyalty program {program_id}") + + +@admin_router.post("/programs/{program_id}/activate", response_model=ProgramResponse) +def activate_program( + program_id: int = Path(..., gt=0), + current_user: User = Depends(get_current_admin_api), + db: Session = Depends(get_db), +): + """Activate a loyalty program.""" + program = program_service.activate_program(db, program_id) + + response = ProgramResponse.model_validate(program) + response.is_stamps_enabled = program.is_stamps_enabled + response.is_points_enabled = program.is_points_enabled + response.display_name = program.display_name + + logger.info(f"Admin activated loyalty program {program_id}") + return response + + +@admin_router.post("/programs/{program_id}/deactivate", response_model=ProgramResponse) +def deactivate_program( + program_id: int = Path(..., gt=0), + current_user: User = Depends(get_current_admin_api), + db: Session = Depends(get_db), +): + """Deactivate a loyalty program.""" + program = program_service.deactivate_program(db, program_id) + + response = ProgramResponse.model_validate(program) + response.is_stamps_enabled = program.is_stamps_enabled + response.is_points_enabled = program.is_points_enabled + response.display_name = program.display_name + + logger.info(f"Admin deactivated loyalty program {program_id}") + return response + + # ============================================================================= # Merchant Management # ============================================================================= diff --git a/app/modules/loyalty/routes/api/merchant.py b/app/modules/loyalty/routes/api/merchant.py new file mode 100644 index 00000000..5030d2c0 --- /dev/null +++ b/app/modules/loyalty/routes/api/merchant.py @@ -0,0 +1,98 @@ +# app/modules/loyalty/routes/api/merchant.py +""" +Loyalty module merchant routes. + +Merchant portal endpoints for full program CRUD: +- Get merchant's loyalty program +- Create a loyalty program +- Update the loyalty program +- Delete the loyalty program + +Authentication: Authorization header (API-only, no cookies for CSRF safety). +The user must own at least one active merchant (validated by +get_merchant_for_current_user). + +Auto-discovered by the route system (merchant.py in routes/api/ triggers +registration under /api/v1/merchants/loyalty/*). +""" + +import logging + +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from app.api.deps import get_merchant_for_current_user +from app.core.database import get_db +from app.modules.loyalty.schemas import ( + ProgramCreate, + ProgramResponse, + ProgramUpdate, +) +from app.modules.loyalty.services import program_service +from app.modules.tenancy.models import Merchant + +logger = logging.getLogger(__name__) + +ROUTE_CONFIG = { + "prefix": "/loyalty", +} + +router = APIRouter() + + +def _build_program_response(program) -> ProgramResponse: + """Build a ProgramResponse from a program ORM object.""" + response = ProgramResponse.model_validate(program) + response.is_stamps_enabled = program.is_stamps_enabled + response.is_points_enabled = program.is_points_enabled + response.display_name = program.display_name + return response + + +# ============================================================================= +# Program CRUD +# ============================================================================= + + +@router.get("/program", response_model=ProgramResponse) +def get_program( + merchant: Merchant = Depends(get_merchant_for_current_user), + db: Session = Depends(get_db), +): + """Get the merchant's loyalty program.""" + program = program_service.require_program_by_merchant(db, merchant.id) + return _build_program_response(program) + + +@router.post("/program", response_model=ProgramResponse, status_code=201) +def create_program( + data: ProgramCreate, + merchant: Merchant = Depends(get_merchant_for_current_user), + db: Session = Depends(get_db), +): + """Create a loyalty program for the merchant.""" + program = program_service.create_program(db, merchant.id, data) + return _build_program_response(program) + + +@router.patch("/program", response_model=ProgramResponse) +def update_program( + data: ProgramUpdate, + merchant: Merchant = Depends(get_merchant_for_current_user), + db: Session = Depends(get_db), +): + """Update the merchant's loyalty program.""" + program = program_service.require_program_by_merchant(db, merchant.id) + program = program_service.update_program(db, program.id, data) + return _build_program_response(program) + + +@router.delete("/program", status_code=204) +def delete_program( + merchant: Merchant = Depends(get_merchant_for_current_user), + db: Session = Depends(get_db), +): + """Delete the merchant's loyalty program.""" + program = program_service.require_program_by_merchant(db, merchant.id) + program_service.delete_program(db, program.id) + logger.info(f"Merchant {merchant.id} ({merchant.name}) deleted loyalty program") diff --git a/app/modules/loyalty/routes/api/store.py b/app/modules/loyalty/routes/api/store.py index 127ca205..f5ebced2 100644 --- a/app/modules/loyalty/routes/api/store.py +++ b/app/modules/loyalty/routes/api/store.py @@ -40,10 +40,8 @@ from app.modules.loyalty.schemas import ( PointsRedeemResponse, PointsVoidRequest, PointsVoidResponse, - ProgramCreate, ProgramResponse, ProgramStatsResponse, - ProgramUpdate, StampRedeemRequest, StampRedeemResponse, StampRequest, @@ -106,46 +104,6 @@ def get_program( return response -@store_router.post("/program", response_model=ProgramResponse, status_code=201) -def create_program( - data: ProgramCreate, - current_user: User = Depends(get_current_store_api), - db: Session = Depends(get_db), -): - """Create a loyalty program for the merchant.""" - store_id = current_user.token_store_id - merchant_id = get_store_merchant_id(db, store_id) - - program = program_service.create_program(db, merchant_id, data) - - response = ProgramResponse.model_validate(program) - response.is_stamps_enabled = program.is_stamps_enabled - response.is_points_enabled = program.is_points_enabled - response.display_name = program.display_name - - return response - - -@store_router.patch("/program", response_model=ProgramResponse) -def update_program( - data: ProgramUpdate, - current_user: User = Depends(get_current_store_api), - db: Session = Depends(get_db), -): - """Update the merchant's loyalty program.""" - store_id = current_user.token_store_id - - program = program_service.require_program_by_store(db, store_id) - program = program_service.update_program(db, program.id, data) - - response = ProgramResponse.model_validate(program) - response.is_stamps_enabled = program.is_stamps_enabled - response.is_points_enabled = program.is_points_enabled - response.display_name = program.display_name - - return response - - @store_router.get("/stats", response_model=ProgramStatsResponse) def get_stats( current_user: User = Depends(get_current_store_api), diff --git a/app/modules/loyalty/routes/pages/merchant.py b/app/modules/loyalty/routes/pages/merchant.py index 7207e1c3..416ad4b9 100644 --- a/app/modules/loyalty/routes/pages/merchant.py +++ b/app/modules/loyalty/routes/pages/merchant.py @@ -98,3 +98,32 @@ async def merchant_loyalty_overview( "loyalty/merchant/overview.html", context, ) + + +# ============================================================================ +# LOYALTY SETTINGS +# ============================================================================ + + +@router.get("/settings", response_class=HTMLResponse, include_in_schema=False) +async def merchant_loyalty_settings( + request: Request, + current_user: UserContext = Depends(get_current_merchant_from_cookie_or_header), + merchant: Merchant = Depends(get_merchant_for_current_user_page), + db: Session = Depends(get_db), +): + """ + Render merchant loyalty settings page. + + Allows merchant to create, configure, and manage their loyalty program. + """ + context = _get_merchant_context( + request, + db, + current_user, + merchant_id=merchant.id, + ) + return templates.TemplateResponse( + "loyalty/merchant/settings.html", + context, + ) diff --git a/app/modules/loyalty/routes/pages/store.py b/app/modules/loyalty/routes/pages/store.py index be280c2e..af0fec08 100644 --- a/app/modules/loyalty/routes/pages/store.py +++ b/app/modules/loyalty/routes/pages/store.py @@ -178,32 +178,6 @@ async def store_loyalty_card_detail( ) -# ============================================================================ -# PROGRAM SETTINGS -# ============================================================================ - - -@router.get( - "/{store_code}/loyalty/settings", - response_class=HTMLResponse, - include_in_schema=False, -) -async def store_loyalty_settings( - request: Request, - store_code: str = Path(..., description="Store code"), - current_user: User = Depends(get_current_store_from_cookie_or_header), - db: Session = Depends(get_db), -): - """ - Render loyalty program settings page. - Allows store to configure points rate, rewards, branding, etc. - """ - return templates.TemplateResponse( - "loyalty/store/settings.html", - get_store_context(request, db, current_user, store_code), - ) - - # ============================================================================ # STATS DASHBOARD # ============================================================================ 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 f9fff665..e785b580 100644 --- a/app/modules/loyalty/static/admin/js/loyalty-merchant-detail.js +++ b/app/modules/loyalty/static/admin/js/loyalty-merchant-detail.js @@ -36,6 +36,7 @@ function adminLoyaltyMerchantDetail() { // State loading: false, error: null, + showDeleteModal: false, // Initialize async init() { @@ -176,6 +177,67 @@ function adminLoyaltyMerchantDetail() { } }, + // Create a default program for this merchant + async createProgram() { + try { + const data = { + loyalty_type: 'points', + points_per_euro: 1, + welcome_bonus_points: 0, + minimum_redemption_points: 100, + card_name: this.merchant?.name ? this.merchant.name + ' Loyalty' : 'Loyalty Program', + is_active: true + }; + const response = await apiClient.post(`/admin/loyalty/merchants/${this.merchantId}/program`, data); + this.program = response; + Utils.showToast('Loyalty program created', 'success'); + loyaltyMerchantDetailLog.info('Program created for merchant', this.merchantId); + // Reload stats + await this.loadStats(); + } catch (error) { + Utils.showToast(`Failed to create program: ${error.message}`, 'error'); + loyaltyMerchantDetailLog.error('Failed to create program:', error); + } + }, + + // Toggle program active/inactive + async toggleActive() { + if (!this.program) return; + const action = this.program.is_active ? 'deactivate' : 'activate'; + try { + const response = await apiClient.post(`/admin/loyalty/programs/${this.program.id}/${action}`); + this.program.is_active = response.is_active; + Utils.showToast(`Program ${action}d successfully`, 'success'); + loyaltyMerchantDetailLog.info(`Program ${action}d`); + } catch (error) { + Utils.showToast(`Failed to ${action} program: ${error.message}`, 'error'); + loyaltyMerchantDetailLog.error(`Failed to ${action} program:`, error); + } + }, + + // Show delete confirmation + confirmDeleteProgram() { + this.showDeleteModal = true; + }, + + // Delete the program + async deleteProgram() { + if (!this.program) return; + try { + await apiClient.delete(`/admin/loyalty/programs/${this.program.id}`); + this.program = null; + this.showDeleteModal = false; + Utils.showToast('Loyalty program deleted', 'success'); + loyaltyMerchantDetailLog.info('Program deleted'); + // Reload stats + await this.loadStats(); + } catch (error) { + Utils.showToast(`Failed to delete program: ${error.message}`, 'error'); + loyaltyMerchantDetailLog.error('Failed to delete program:', error); + this.showDeleteModal = false; + } + }, + // Format date for display formatDate(dateString) { if (!dateString) return 'N/A'; diff --git a/app/modules/loyalty/static/admin/js/loyalty-programs.js b/app/modules/loyalty/static/admin/js/loyalty-programs.js index dc7feca0..d13e0f3c 100644 --- a/app/modules/loyalty/static/admin/js/loyalty-programs.js +++ b/app/modules/loyalty/static/admin/js/loyalty-programs.js @@ -232,6 +232,20 @@ function adminLoyaltyPrograms() { } }, + // Toggle program active/inactive + async toggleProgramActive(program) { + const action = program.is_active ? 'deactivate' : 'activate'; + try { + const response = await apiClient.post(`/admin/loyalty/programs/${program.id}/${action}`); + program.is_active = response.is_active; + Utils.showToast(`Program ${action}d successfully`, 'success'); + loyaltyProgramsLog.info(`Program ${program.id} ${action}d`); + } catch (error) { + Utils.showToast(`Failed to ${action} program: ${error.message}`, 'error'); + loyaltyProgramsLog.error(`Failed to ${action} program:`, error); + } + }, + // Format date for display formatDate(dateString) { if (!dateString) return 'N/A'; diff --git a/app/modules/loyalty/static/store/js/loyalty-settings.js b/app/modules/loyalty/static/merchant/js/loyalty-settings.js similarity index 69% rename from app/modules/loyalty/static/store/js/loyalty-settings.js rename to app/modules/loyalty/static/merchant/js/loyalty-settings.js index 46e86382..4d600f02 100644 --- a/app/modules/loyalty/static/store/js/loyalty-settings.js +++ b/app/modules/loyalty/static/merchant/js/loyalty-settings.js @@ -1,9 +1,9 @@ -// app/modules/loyalty/static/store/js/loyalty-settings.js +// app/modules/loyalty/static/merchant/js/loyalty-settings.js // noqa: js-006 - async init pattern is safe, loadData has try/catch const loyaltySettingsLog = window.LogConfig.loggers.loyaltySettings || window.LogConfig.createLogger('loyaltySettings'); -function storeLoyaltySettings() { +function merchantLoyaltySettings() { return { ...data(), currentPage: 'loyalty-settings', @@ -22,22 +22,18 @@ function storeLoyaltySettings() { loading: false, saving: false, + deleting: false, error: null, isNewProgram: false, + showDeleteModal: false, async init() { - loyaltySettingsLog.info('=== LOYALTY SETTINGS PAGE INITIALIZING ==='); - if (window._loyaltySettingsInitialized) return; - window._loyaltySettingsInitialized = true; - - // IMPORTANT: Call parent init first to set storeCode from URL - const parentInit = data().init; - if (parentInit) { - await parentInit.call(this); - } + loyaltySettingsLog.info('=== MERCHANT LOYALTY SETTINGS PAGE INITIALIZING ==='); + if (window._merchantLoyaltySettingsInitialized) return; + window._merchantLoyaltySettingsInitialized = true; await this.loadSettings(); - loyaltySettingsLog.info('=== LOYALTY SETTINGS PAGE INITIALIZATION COMPLETE ==='); + loyaltySettingsLog.info('=== MERCHANT LOYALTY SETTINGS PAGE INITIALIZATION COMPLETE ==='); }, async loadSettings() { @@ -45,7 +41,7 @@ function storeLoyaltySettings() { this.error = null; try { - const response = await apiClient.get('/store/loyalty/program'); + const response = await apiClient.get('/merchants/loyalty/program'); if (response) { this.settings = { loyalty_type: response.loyalty_type || 'points', @@ -86,10 +82,10 @@ function storeLoyaltySettings() { let response; if (this.isNewProgram) { - response = await apiClient.post('/store/loyalty/program', this.settings); + response = await apiClient.post('/merchants/loyalty/program', this.settings); this.isNewProgram = false; } else { - response = await apiClient.patch('/store/loyalty/program', this.settings); + response = await apiClient.patch('/merchants/loyalty/program', this.settings); } Utils.showToast('Settings saved successfully', 'success'); @@ -102,6 +98,28 @@ function storeLoyaltySettings() { } }, + confirmDelete() { + this.showDeleteModal = true; + }, + + async deleteProgram() { + this.deleting = true; + + try { + await apiClient.delete('/merchants/loyalty/program'); + Utils.showToast('Loyalty program deleted', 'success'); + loyaltySettingsLog.info('Program deleted'); + // Redirect to overview + window.location.href = '/merchants/loyalty/overview'; + } catch (error) { + Utils.showToast(`Failed to delete: ${error.message}`, 'error'); + loyaltySettingsLog.error('Delete failed:', error); + } finally { + this.deleting = false; + this.showDeleteModal = false; + } + }, + addReward() { this.settings.points_rewards.push({ id: `reward_${Date.now()}`, @@ -121,4 +139,4 @@ function storeLoyaltySettings() { if (!window.LogConfig.loggers.loyaltySettings) { window.LogConfig.loggers.loyaltySettings = window.LogConfig.createLogger('loyaltySettings'); } -loyaltySettingsLog.info('Loyalty settings module loaded'); +loyaltySettingsLog.info('Merchant loyalty settings module loaded'); diff --git a/app/modules/loyalty/templates/loyalty/admin/merchant-detail.html b/app/modules/loyalty/templates/loyalty/admin/merchant-detail.html index 8773168a..026b5e2d 100644 --- a/app/modules/loyalty/templates/loyalty/admin/merchant-detail.html +++ b/app/modules/loyalty/templates/loyalty/admin/merchant-detail.html @@ -105,10 +105,27 @@
Program Name
@@ -142,11 +159,39 @@This merchant has not set up a loyalty program yet. Stores can set up the program from their dashboard.
+This merchant has not set up a loyalty program yet.
++ This will permanently delete the loyalty program and all associated data. This action cannot be undone. +
+Loyalty program statistics across all your stores.
+Loyalty program statistics across all your stores.
+- Your loyalty program hasn't been set up yet. Contact the platform administrator or set it up from your store dashboard. + Your loyalty program hasn't been set up yet. Create one to start rewarding your customers.
+ + + Create Program +1 EUR = point(s)
+ This will permanently delete your loyalty program and all associated data (cards, transactions, rewards). + This action cannot be undone. +
+