diff --git a/app/modules/dev_tools/static/admin/js/sql-query.js b/app/modules/dev_tools/static/admin/js/sql-query.js index 384df98f..efbabd3a 100644 --- a/app/modules/dev_tools/static/admin/js/sql-query.js +++ b/app/modules/dev_tools/static/admin/js/sql-query.js @@ -84,6 +84,78 @@ function sqlQueryTool() { { name: 'Alembic versions', sql: "SELECT * FROM alembic_version\nORDER BY version_num;" }, ] }, + { + category: 'Loyalty', + items: [ + { name: 'Programs', sql: "SELECT lp.id, lp.merchant_id, lp.loyalty_type,\n lp.stamps_target, lp.points_per_euro, lp.is_active,\n lp.activated_at, lp.created_at\nFROM loyalty_programs lp\nORDER BY lp.id DESC;" }, + { name: 'Cards', sql: "SELECT lc.id, lc.card_number, c.email AS customer_email,\n lc.stamp_count, lc.points_balance, lc.is_active,\n lc.last_activity_at, lc.created_at\nFROM loyalty_cards lc\nJOIN customers c ON c.id = lc.customer_id\nORDER BY lc.id DESC\nLIMIT 100;" }, + { name: 'Transactions', sql: "SELECT lt.id, lt.transaction_type, lc.card_number,\n lt.stamps_delta, lt.points_delta,\n lt.purchase_amount_cents, lt.transaction_at\nFROM loyalty_transactions lt\nJOIN loyalty_cards lc ON lc.id = lt.card_id\nORDER BY lt.transaction_at DESC\nLIMIT 100;" }, + { name: 'Staff PINs', sql: "SELECT sp.id, sp.name, sp.staff_id,\n s.name AS store_name, sp.is_active,\n sp.last_used_at, sp.created_at\nFROM staff_pins sp\nJOIN stores s ON s.id = sp.store_id\nORDER BY sp.id DESC;" }, + ] + }, + { + category: 'Billing', + items: [ + { name: 'Subscription tiers', sql: "SELECT st.id, p.code AS platform_code,\n st.code, st.name, st.price_monthly_cents,\n st.price_annual_cents, st.is_active, st.is_public\nFROM subscription_tiers st\nJOIN platforms p ON p.id = st.platform_id\nORDER BY p.code, st.display_order;" }, + { name: 'Merchant subscriptions', sql: "SELECT ms.id, m.name AS merchant_name,\n p.code AS platform_code, st.name AS tier_name,\n ms.status, ms.is_annual,\n ms.stripe_customer_id, ms.stripe_subscription_id\nFROM merchant_subscriptions ms\nJOIN merchants m ON m.id = ms.merchant_id\nJOIN platforms p ON p.id = ms.platform_id\nLEFT JOIN subscription_tiers st ON st.id = ms.tier_id\nORDER BY ms.id DESC;" }, + { name: 'Billing history', sql: "SELECT bh.id, s.name AS store_name,\n bh.invoice_number, bh.total_cents,\n bh.currency, bh.status, bh.invoice_date\nFROM billing_history bh\nJOIN stores s ON s.id = bh.store_id\nORDER BY bh.invoice_date DESC\nLIMIT 50;" }, + { name: 'Add-on products', sql: "SELECT id, code, name, category,\n price_cents, billing_period, is_active\nFROM addon_products\nORDER BY display_order;" }, + ] + }, + { + category: 'Orders', + items: [ + { name: 'Recent orders', sql: "SELECT o.id, s.name AS store_name,\n o.order_number, o.status, o.total_amount_cents,\n o.customer_email, o.order_date\nFROM orders o\nJOIN stores s ON s.id = o.store_id\nORDER BY o.order_date DESC\nLIMIT 50;" }, + { name: 'Order items', sql: "SELECT oi.id, o.order_number, oi.product_name,\n oi.product_sku, oi.quantity, oi.unit_price_cents,\n oi.item_state\nFROM order_items oi\nJOIN orders o ON o.id = oi.order_id\nORDER BY oi.id DESC\nLIMIT 100;" }, + { name: 'Invoices', sql: "SELECT i.id, s.name AS store_name,\n i.invoice_number, i.status,\n i.total_cents, i.invoice_date\nFROM invoices i\nJOIN stores s ON s.id = i.store_id\nORDER BY i.invoice_date DESC\nLIMIT 50;" }, + ] + }, + { + category: 'Catalog', + items: [ + { name: 'Products', sql: "SELECT p.id, s.name AS store_name,\n p.store_sku, p.gtin, p.price_cents,\n p.availability, p.is_active\nFROM products p\nJOIN stores s ON s.id = p.store_id\nORDER BY p.id DESC\nLIMIT 50;" }, + { name: 'Product translations', sql: "SELECT pt.id, p.store_sku,\n pt.language, pt.title\nFROM product_translations pt\nJOIN products p ON p.id = pt.product_id\nORDER BY pt.id DESC\nLIMIT 50;" }, + { name: 'Product media', sql: "SELECT pm.id, p.store_sku,\n pm.usage_type, pm.display_order\nFROM product_media pm\nJOIN products p ON p.id = pm.product_id\nORDER BY pm.id DESC\nLIMIT 50;" }, + ] + }, + { + category: 'Customers', + items: [ + { name: 'Customers', sql: "SELECT c.id, s.name AS store_name,\n c.email, c.first_name, c.last_name,\n c.customer_number, c.is_active\nFROM customers c\nJOIN stores s ON s.id = c.store_id\nORDER BY c.id DESC\nLIMIT 50;" }, + { name: 'Customer addresses', sql: "SELECT ca.id, c.email AS customer_email,\n ca.address_type, ca.city, ca.country_iso,\n ca.is_default\nFROM customer_addresses ca\nJOIN customers c ON c.id = ca.customer_id\nORDER BY ca.id DESC\nLIMIT 50;" }, + ] + }, + { + category: 'Inventory', + items: [ + { name: 'Stock levels', sql: "SELECT inv.id, s.name AS store_name,\n p.store_sku, inv.quantity,\n inv.reserved_quantity, inv.warehouse\nFROM inventory inv\nJOIN products p ON p.id = inv.product_id\nJOIN stores s ON s.id = inv.store_id\nORDER BY inv.id DESC;" }, + { name: 'Recent transactions', sql: "SELECT it.id, p.store_sku,\n it.transaction_type, it.quantity_change,\n it.order_number, it.created_at\nFROM inventory_transactions it\nJOIN products p ON p.id = it.product_id\nORDER BY it.created_at DESC\nLIMIT 100;" }, + ] + }, + { + category: 'CMS', + items: [ + { name: 'Content pages', sql: "SELECT cp.id, cp.slug, cp.title,\n cp.is_published, cp.is_platform_page,\n s.name AS store_name, p.code AS platform_code\nFROM content_pages cp\nLEFT JOIN stores s ON s.id = cp.store_id\nLEFT JOIN platforms p ON p.id = cp.platform_id\nORDER BY cp.id DESC\nLIMIT 50;" }, + { name: 'Media files', sql: "SELECT mf.id, s.name AS store_name,\n mf.filename, mf.media_type,\n mf.file_size, mf.usage_count\nFROM media_files mf\nJOIN stores s ON s.id = mf.store_id\nORDER BY mf.id DESC\nLIMIT 50;" }, + { name: 'Store themes', sql: "SELECT st.id, s.name AS store_name,\n st.theme_name, st.is_active, st.layout_style\nFROM store_themes st\nJOIN stores s ON s.id = st.store_id\nORDER BY st.id DESC;" }, + ] + }, + { + category: 'Messaging', + items: [ + { name: 'Email templates', sql: "SELECT id, code, language, name,\n category, is_active\nFROM email_templates\nORDER BY category, code, language;" }, + { name: 'Email logs', sql: "SELECT id, recipient_email, subject,\n status, sent_at, provider\nFROM email_logs\nORDER BY sent_at DESC\nLIMIT 50;" }, + { name: 'Admin notifications', sql: "SELECT id, type, priority, title,\n is_read, action_required, created_at\nFROM admin_notifications\nORDER BY created_at DESC\nLIMIT 50;" }, + ] + }, + { + category: 'Marketplace', + items: [ + { name: 'Import jobs', sql: "SELECT mij.id, s.name AS store_name,\n mij.marketplace, mij.status,\n mij.imported_count, mij.error_count,\n mij.created_at\nFROM marketplace_import_jobs mij\nJOIN stores s ON s.id = mij.store_id\nORDER BY mij.created_at DESC\nLIMIT 50;" }, + { name: 'Marketplace products', sql: "SELECT id, marketplace, brand, gtin,\n price_cents, availability, is_active\nFROM marketplace_products\nORDER BY id DESC\nLIMIT 50;" }, + { name: 'Fulfillment queue', sql: "SELECT fq.id, s.name AS store_name,\n fq.operation, fq.status, fq.attempts,\n fq.error_message, fq.created_at\nFROM letzshop_fulfillment_queue fq\nJOIN stores s ON s.id = fq.store_id\nORDER BY fq.created_at DESC\nLIMIT 50;" }, + ] + }, ], toggleCategory(category) { diff --git a/app/modules/loyalty/routes/api/store.py b/app/modules/loyalty/routes/api/store.py index e82a072f..42a20210 100644 --- a/app/modules/loyalty/routes/api/store.py +++ b/app/modules/loyalty/routes/api/store.py @@ -153,6 +153,22 @@ def update_program( return response +@router.delete("/program", status_code=204) +def delete_program( + current_user: User = Depends(get_current_store_api), + db: Session = Depends(get_db), +): + """Delete the merchant's loyalty program (merchant_owner only).""" + if current_user.role != "merchant_owner": + raise AuthorizationException("Only merchant owners can delete programs") + + store_id = current_user.token_store_id + + program = program_service.require_program_by_store(db, store_id) + program_service.delete_program(db, program.id) + logger.info(f"Store user deleted loyalty program {program.id}") + + @router.get("/stats", response_model=ProgramStatsResponse) def get_stats( current_user: User = Depends(get_current_store_api), diff --git a/app/modules/loyalty/static/admin/js/loyalty-program-edit.js b/app/modules/loyalty/static/admin/js/loyalty-program-edit.js index f417c140..fe0f7a9b 100644 --- a/app/modules/loyalty/static/admin/js/loyalty-program-edit.js +++ b/app/modules/loyalty/static/admin/js/loyalty-program-edit.js @@ -6,32 +6,15 @@ const loyaltyProgramEditLog = window.LogConfig.loggers.loyaltyProgramEdit || win function adminLoyaltyProgramEdit() { return { ...data(), + ...createProgramFormMixin(), currentPage: 'loyalty-programs', merchantId: null, merchant: null, programId: null, - settings: { - loyalty_type: 'points', - points_per_euro: 1, - welcome_bonus_points: 0, - minimum_redemption_points: 100, - points_expiration_days: null, - points_rewards: [], - stamps_target: 10, - stamps_reward_description: '', - card_name: '', - card_color: '#4F46E5', - is_active: true - }, - loading: false, - saving: false, - deleting: false, error: null, - isNewProgram: false, - showDeleteModal: false, get backUrl() { return `/admin/loyalty/merchants/${this.merchantId}`; @@ -88,39 +71,22 @@ function adminLoyaltyProgramEdit() { async loadProgram() { try { - // Get program via merchant stats endpoint (includes program data) const response = await apiClient.get(`/admin/loyalty/merchants/${this.merchantId}/stats`); if (response && response.program) { const program = response.program; this.programId = program.id; this.isNewProgram = false; - - this.settings = { - loyalty_type: program.loyalty_type || 'points', - points_per_euro: program.points_per_euro || 1, - welcome_bonus_points: program.welcome_bonus_points || 0, - minimum_redemption_points: program.minimum_redemption_points || 100, - points_expiration_days: program.points_expiration_days || null, - points_rewards: program.points_rewards || [], - stamps_target: program.stamps_target || 10, - stamps_reward_description: program.stamps_reward_description || '', - card_name: program.card_name || '', - card_color: program.card_color || '#4F46E5', - is_active: program.is_active !== false - }; - + this.populateSettings(program); loyaltyProgramEditLog.info('Program loaded, ID:', this.programId); } else { this.isNewProgram = true; - // Set default card name from merchant if (this.merchant) { this.settings.card_name = this.merchant.name + ' Loyalty'; } loyaltyProgramEditLog.info('No program found, create mode'); } } catch (error) { - // If stats fail (e.g., no program), we're in create mode this.isNewProgram = true; if (this.merchant) { this.settings.card_name = this.merchant.name + ' Loyalty'; @@ -133,17 +99,12 @@ function adminLoyaltyProgramEdit() { this.saving = true; try { - // Ensure rewards have IDs - this.settings.points_rewards = this.settings.points_rewards.map((r, i) => ({ - ...r, - id: r.id || `reward_${i + 1}`, - is_active: r.is_active !== false - })); + const payload = this.buildPayload(); if (this.isNewProgram) { const response = await apiClient.post( `/admin/loyalty/merchants/${this.merchantId}/program`, - this.settings + payload ); this.programId = response.id; this.isNewProgram = false; @@ -151,13 +112,12 @@ function adminLoyaltyProgramEdit() { } else { await apiClient.patch( `/admin/loyalty/programs/${this.programId}`, - this.settings + payload ); Utils.showToast('Program updated successfully', 'success'); } loyaltyProgramEditLog.info('Program saved'); - // Navigate back to merchant detail window.location.href = this.backUrl; } catch (error) { Utils.showToast(`Failed to save: ${error.message}`, 'error'); @@ -167,10 +127,6 @@ function adminLoyaltyProgramEdit() { } }, - confirmDelete() { - this.showDeleteModal = true; - }, - async deleteProgram() { if (!this.programId) return; this.deleting = true; @@ -188,20 +144,6 @@ function adminLoyaltyProgramEdit() { this.showDeleteModal = false; } }, - - addReward() { - this.settings.points_rewards.push({ - id: `reward_${Date.now()}`, - name: '', - points_required: 100, - description: '', - is_active: true - }); - }, - - removeReward(index) { - this.settings.points_rewards.splice(index, 1); - } }; } diff --git a/app/modules/loyalty/static/shared/js/loyalty-program-form.js b/app/modules/loyalty/static/shared/js/loyalty-program-form.js new file mode 100644 index 00000000..f9cda4be --- /dev/null +++ b/app/modules/loyalty/static/shared/js/loyalty-program-form.js @@ -0,0 +1,127 @@ +// app/modules/loyalty/static/shared/js/loyalty-program-form.js +// Shared mixin for loyalty program settings forms (admin, merchant, store). + +/** + * Factory that returns the shared Alpine.js properties and methods + * for the loyalty program settings form. + * + * Each page spreads this into its own component and provides: + * - init(), loadData(), saveSettings(), deleteProgram() + * with the correct API paths and navigation. + */ +function createProgramFormMixin() { + return { + // ---- state ---- + settings: { + loyalty_type: 'points', + stamps_target: 10, + stamps_reward_description: '', + stamps_reward_value_cents: null, + points_per_euro: 1, + welcome_bonus_points: 0, + minimum_redemption_points: 100, + minimum_purchase_cents: 0, + points_expiration_days: null, + points_rewards: [], + cooldown_minutes: 15, + max_daily_stamps: 5, + require_staff_pin: true, + card_name: '', + card_color: '#4F46E5', + card_secondary_color: '', + logo_url: '', + hero_image_url: '', + terms_text: '', + privacy_url: '', + is_active: true, + }, + + isNewProgram: false, + saving: false, + deleting: false, + showDeleteModal: false, + + // ---- helpers ---- + + /** + * Populate settings from an API program response object. + */ + populateSettings(program) { + this.settings = { + loyalty_type: program.loyalty_type || 'points', + stamps_target: program.stamps_target || 10, + stamps_reward_description: program.stamps_reward_description || '', + stamps_reward_value_cents: program.stamps_reward_value_cents || null, + points_per_euro: program.points_per_euro || 1, + welcome_bonus_points: program.welcome_bonus_points || 0, + minimum_redemption_points: program.minimum_redemption_points || 100, + minimum_purchase_cents: program.minimum_purchase_cents || 0, + points_expiration_days: program.points_expiration_days || null, + points_rewards: (program.points_rewards || []).map(r => ({ + id: r.id, + name: r.name, + points_required: r.points_required, + description: r.description || '', + is_active: r.is_active !== false, + })), + cooldown_minutes: program.cooldown_minutes ?? 15, + max_daily_stamps: program.max_daily_stamps || 5, + require_staff_pin: program.require_staff_pin !== false, + card_name: program.card_name || '', + card_color: program.card_color || '#4F46E5', + card_secondary_color: program.card_secondary_color || '', + logo_url: program.logo_url || '', + hero_image_url: program.hero_image_url || '', + terms_text: program.terms_text || '', + privacy_url: program.privacy_url || '', + is_active: program.is_active !== false, + }; + }, + + /** + * Build a clean payload from settings for POST/PATCH/PUT. + * Ensures rewards have IDs and cleans empty optional fields. + */ + buildPayload() { + const payload = { ...this.settings }; + + // Ensure rewards have IDs + payload.points_rewards = (payload.points_rewards || []).map((r, i) => ({ + ...r, + id: r.id || `reward_${i + 1}`, + is_active: r.is_active !== false, + })); + + // Clean empty optional fields + if (!payload.stamps_reward_value_cents) payload.stamps_reward_value_cents = null; + if (!payload.points_expiration_days) payload.points_expiration_days = null; + if (!payload.minimum_purchase_cents) payload.minimum_purchase_cents = null; + if (!payload.card_name) payload.card_name = null; + if (!payload.card_secondary_color) payload.card_secondary_color = null; + if (!payload.logo_url) payload.logo_url = null; + if (!payload.hero_image_url) payload.hero_image_url = null; + if (!payload.terms_text) payload.terms_text = null; + if (!payload.privacy_url) payload.privacy_url = null; + + return payload; + }, + + addReward() { + this.settings.points_rewards.push({ + id: `reward_${Date.now()}`, + name: '', + points_required: 100, + description: '', + is_active: true, + }); + }, + + removeReward(index) { + this.settings.points_rewards.splice(index, 1); + }, + + confirmDelete() { + this.showDeleteModal = true; + }, + }; +} diff --git a/app/modules/loyalty/templates/loyalty/admin/program-edit.html b/app/modules/loyalty/templates/loyalty/admin/program-edit.html index 3c7ec9e8..e09e20f1 100644 --- a/app/modules/loyalty/templates/loyalty/admin/program-edit.html +++ b/app/modules/loyalty/templates/loyalty/admin/program-edit.html @@ -3,7 +3,6 @@ {% from 'shared/macros/headers.html' import detail_page_header %} {% from 'shared/macros/alerts.html' import loading_state, error_state %} {% from 'shared/macros/modals.html' import confirm_modal %} -{% from 'shared/macros/inputs.html' import number_stepper %} {% block title %}Program Configuration{% endblock %} @@ -19,209 +18,10 @@
- -
-

- - Program Type -

-
- - - -
-
- - -
-

- - Points Configuration -

-
-
- - {{ number_stepper(model='settings.points_per_euro', min=1, max=100, label='Points per EUR spent') }} -

1 EUR = point(s)

-
-
- - -

Bonus points awarded on enrollment

-
-
- - -
-
- - -

Days of inactivity before points expire (0 = never)

-
-
-
- - -
-

- - Stamps Configuration -

-
-
- - -

Number of stamps needed for reward

-
-
- - -
-
-
- - -
-
-

- - Redemption Rewards -

- -
-
- - -
-
- - -
-

- - Branding -

-
-
- - -
-
- -
- - -
-
-
-
- - -
-

- - Program Status -

- -
- - -
-
- -
-
- - Cancel - - -
-
+ {% set show_delete = true %} + {% set show_status = true %} + {% set cancel_url = '/admin/loyalty/merchants/' ~ merchant_id %} + {% include "loyalty/shared/program-form.html" %}
@@ -239,5 +39,6 @@ {% endblock %} {% block extra_scripts %} + {% endblock %} diff --git a/app/modules/loyalty/templates/loyalty/shared/program-form.html b/app/modules/loyalty/templates/loyalty/shared/program-form.html new file mode 100644 index 00000000..db561671 --- /dev/null +++ b/app/modules/loyalty/templates/loyalty/shared/program-form.html @@ -0,0 +1,310 @@ +{# app/modules/loyalty/templates/loyalty/shared/program-form.html #} +{# + Canonical loyalty program form partial. + Include with: + {% include "loyalty/shared/program-form.html" %} + + Expected Jinja2 variables (set before include): + - show_delete (bool) — show Delete Program button + - show_status (bool) — show is_active toggle + - cancel_url (str) — Cancel link href (Alpine expression or literal) + + Expected Alpine.js state on the parent component: + - settings.* — full program settings object + - isNewProgram — boolean + - saving — boolean + - showDeleteModal — boolean + - addReward() + - removeReward(index) + - confirmDelete() +#} + + +
+

+ + Program Type +

+
+ + + +
+
+ + +
+

+ + Stamps Configuration +

+
+
+ + +

Number of stamps needed for reward

+
+
+ + +
+
+ + +
+
+
+ + +
+

+ + Points Configuration +

+
+
+ + +

1 EUR = point(s)

+
+
+ + +

Bonus points awarded on enrollment

+
+
+ + +
+
+ + +

Minimum purchase amount to earn points (0 = no minimum)

+
+
+ + +

Days of inactivity before points expire (0 = never)

+
+
+
+ + +
+
+

+ + Redemption Rewards +

+ +
+
+ + +
+
+ + +
+

+ + Anti-Fraud Settings +

+
+
+ + +

Time between stamps from the same card

+
+
+ + +

Maximum stamps per card per day

+
+
+ +
+
+
+ + +
+

+ + Branding +

+
+
+ + +
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+

+ + Terms & Privacy +

+
+
+ + +
+
+ + +
+
+
+ + +{% if show_status %} +
+

+ + Program Status +

+ +
+{% endif %} + + +
+
+ {% if show_delete %} + + {% endif %} +
+
+ + Cancel + + +
+