diff --git a/app/modules/loyalty/definition.py b/app/modules/loyalty/definition.py index defcd529..358ae888 100644 --- a/app/modules/loyalty/definition.py +++ b/app/modules/loyalty/definition.py @@ -137,7 +137,11 @@ loyalty_module = ModuleDefinition( ], FrontendType.MERCHANT: [ "loyalty-program", # Merchant loyalty program + "loyalty-cards", # Customer cards "loyalty-analytics", # Merchant loyalty analytics + "loyalty-transactions", # Transaction feed + "loyalty-pins", # Staff PINs + "loyalty-settings", # Settings (read-only) ], }, # New module-driven menu definitions @@ -197,6 +201,14 @@ loyalty_module = ModuleDefinition( order=20, requires_permission="loyalty.view_programs", ), + MenuItemDefinition( + id="pins", + label_key="loyalty.menu.staff_pins", + icon="key", + route="/store/{store_code}/loyalty/pins", + order=22, + requires_permission="loyalty.view_programs", + ), MenuItemDefinition( id="loyalty-program", label_key="loyalty.menu.program", @@ -230,6 +242,13 @@ loyalty_module = ModuleDefinition( route="/merchants/loyalty/program", order=10, ), + MenuItemDefinition( + id="loyalty-cards", + label_key="loyalty.menu.customer_cards", + icon="identification", + route="/merchants/loyalty/cards", + order=15, + ), MenuItemDefinition( id="loyalty-analytics", label_key="loyalty.menu.analytics", @@ -237,6 +256,27 @@ loyalty_module = ModuleDefinition( route="/merchants/loyalty/analytics", order=20, ), + MenuItemDefinition( + id="loyalty-transactions", + label_key="loyalty.menu.transactions", + icon="clock", + route="/merchants/loyalty/transactions", + order=25, + ), + MenuItemDefinition( + id="loyalty-pins", + label_key="loyalty.menu.staff_pins", + icon="key", + route="/merchants/loyalty/pins", + order=30, + ), + MenuItemDefinition( + id="loyalty-settings", + label_key="loyalty.menu.settings", + icon="cog", + route="/merchants/loyalty/settings", + order=35, + ), ], ), ], diff --git a/app/modules/loyalty/docs/index.md b/app/modules/loyalty/docs/index.md index fb1ab80c..6835c6ec 100644 --- a/app/modules/loyalty/docs/index.md +++ b/app/modules/loyalty/docs/index.md @@ -45,6 +45,22 @@ See [Data Model](data-model.md) for full entity relationships. - **MerchantLoyaltySettings** — Admin-controlled merchant settings - **AppleDeviceRegistration** — Apple Wallet push notification tokens +## Pages & Navigation + +See [Pages & Navigation](pages-and-navigation.md) for the full cross-persona page matrix, URL patterns, sidebar navigation, and shared component architecture. + +All personas share the same page set with scope-appropriate access: + +| Page | Admin | Merchant | Store | Storefront | +|---|:---:|:---:|:---:|:---:| +| Program | Any merchant | Own | Own | Public | +| Analytics | Any merchant | Multi-store | Store-scoped | -- | +| Cards/Members | Any merchant | All stores | Store-scoped | Own card | +| Transactions | Any merchant | Store filter | Store-scoped | Own history | +| Staff PINs | Read-only | Full CRUD | Full CRUD | -- | +| Settings | Edit | Read-only | -- | -- | +| Terminal | -- | -- | POS | -- | + ## API Endpoints ### Store Endpoints (`/api/v1/store/loyalty/`) @@ -53,15 +69,34 @@ See [Data Model](data-model.md) for full entity relationships. |--------|----------|-------------| | `GET` | `/program` | Get store's loyalty program | | `POST` | `/program` | Create loyalty program | -| `PATCH` | `/program` | Update loyalty program | +| `PUT` | `/program` | Update loyalty program | +| `DELETE` | `/program` | Delete loyalty program | | `GET` | `/stats` | Get program statistics | +| `GET` | `/stats/merchant` | Get merchant-wide statistics | | `GET` | `/cards` | List customer cards | | `POST` | `/cards/enroll` | Enroll customer in program | | `POST` | `/stamp` | Add stamp to card | | `POST` | `/stamp/redeem` | Redeem stamps for reward | -| `POST` | `/points` | Earn points from purchase | +| `POST` | `/stamp/void` | Void stamps | +| `POST` | `/points/earn` | Earn points from purchase | | `POST` | `/points/redeem` | Redeem points for reward | -| `*` | `/pins/*` | Staff PIN management | +| `POST` | `/points/void` | Void points | +| `POST` | `/cards/{id}/points/adjust` | Manual point adjustment (owner) | +| `*` | `/pins/*` | Staff PIN management (list, create, update, delete, unlock) | + +### Merchant Endpoints (`/api/v1/merchants/loyalty/`) + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `GET/POST/PATCH/DELETE` | `/program` | Program CRUD | +| `GET` | `/stats` | Merchant-wide statistics | +| `GET` | `/cards` | List cards across all stores | +| `GET` | `/cards/{id}` | Card detail | +| `GET` | `/cards/{id}/transactions` | Card transaction history | +| `GET` | `/transactions` | Merchant-wide transactions | +| `*` | `/pins/*` | Staff PIN management across stores | +| `GET` | `/settings` | Loyalty settings (read-only) | +| `GET` | `/locations` | Store list for filter dropdowns | ### Admin Endpoints (`/api/v1/admin/loyalty/`) @@ -70,6 +105,15 @@ See [Data Model](data-model.md) for full entity relationships. | `GET` | `/programs` | List all loyalty programs | | `GET` | `/programs/{id}` | Get specific program | | `GET` | `/stats` | Platform-wide statistics | +| `GET` | `/merchants/{mid}/cards` | Merchant's cards (on behalf) | +| `GET` | `/merchants/{mid}/cards/{cid}` | Card detail (on behalf) | +| `GET` | `/merchants/{mid}/transactions` | Merchant's transactions | +| `GET` | `/merchants/{mid}/pins` | Merchant's PINs (read-only) | +| `GET` | `/merchants/{mid}/locations` | Merchant's stores | +| `GET` | `/merchants/{mid}/stats` | Merchant statistics | +| `GET/PATCH` | `/merchants/{mid}/settings` | Merchant settings | +| `GET` | `/wallet-status` | Wallet integration status | +| `*` | `/debug/*` | Wallet debug endpoints (super admin) | ### Storefront Endpoints (`/api/v1/storefront/loyalty/`) @@ -78,6 +122,7 @@ See [Data Model](data-model.md) for full entity relationships. | `GET` | `/card` | Get customer's loyalty card | | `GET` | `/transactions` | Transaction history | | `POST` | `/enroll` | Self-enrollment | +| `GET` | `/program` | Public program info | ## Scheduled Tasks @@ -105,6 +150,7 @@ Environment variables (prefix: `LOYALTY_`): - [Data Model](data-model.md) — Entity relationships and database schema - [Business Logic](business-logic.md) — Anti-fraud system, wallet integration, enrollment flow +- [Pages & Navigation](pages-and-navigation.md) — Cross-persona page matrix, URL patterns, shared components - [User Journeys](user-journeys.md) — Detailed user journey flows with dev/prod URLs - [Program Analysis](program-analysis.md) — Business analysis and platform vision - [UI Design](ui-design.md) — Admin and store interface mockups and implementation roadmap diff --git a/app/modules/loyalty/docs/pages-and-navigation.md b/app/modules/loyalty/docs/pages-and-navigation.md new file mode 100644 index 00000000..98e2aea4 --- /dev/null +++ b/app/modules/loyalty/docs/pages-and-navigation.md @@ -0,0 +1,191 @@ +# Pages & Navigation + +Cross-persona page architecture for the loyalty module. Every persona sees the same set of pages but scoped to their access level. + +## Page Matrix + +| Page | Admin (on behalf) | Merchant | Store | Storefront | +|---|:---:|:---:|:---:|:---:| +| Program | Any merchant | Own | Own (owner edits) | Public info | +| Analytics | Any merchant | Own (multi-store) | Store-scoped | -- | +| Cards/Members | Any merchant | Own (all stores) | Store-scoped | Own card | +| Card Detail | Any card | Own merchant | Store-scoped | Own card | +| Transactions | Any merchant | Own (store filter) | Store-scoped | Own history | +| Staff PINs | Read-only | Full CRUD | Full CRUD | -- | +| Settings | Edit | Read-only | -- | -- | +| Terminal | -- | -- | POS interface | -- | +| Wallet Debug | Super admin only | -- | -- | -- | +| Enrollment | -- | -- | Staff-initiated | Self-service | + +## URL Patterns + +### Store Pages + +``` +/store/{store_code}/loyalty/terminal # POS terminal +/store/{store_code}/loyalty/cards # Customer cards list +/store/{store_code}/loyalty/cards/{id} # Card detail +/store/{store_code}/loyalty/pins # Staff PIN management +/store/{store_code}/loyalty/program # Program view (owner: edit) +/store/{store_code}/loyalty/analytics # Store analytics +/store/{store_code}/loyalty/enroll # Staff-initiated enrollment +``` + +### Merchant Pages + +``` +/merchants/loyalty/program # Program view/edit +/merchants/loyalty/cards # Cards across all stores +/merchants/loyalty/cards/{id} # Card detail +/merchants/loyalty/analytics # Merchant-wide analytics +/merchants/loyalty/transactions # Transactions (store filter) +/merchants/loyalty/pins # PINs across all stores (CRUD) +/merchants/loyalty/settings # Loyalty settings (read-only) +``` + +### Admin Pages (On Behalf) + +``` +/admin/loyalty/programs # All programs list +/admin/loyalty/analytics # Platform-wide analytics +/admin/loyalty/wallet-debug # Wallet diagnostics (super admin) +/admin/loyalty/merchants/{mid} # Merchant detail hub +/admin/loyalty/merchants/{mid}/program # Program edit +/admin/loyalty/merchants/{mid}/settings # Merchant settings (edit) +/admin/loyalty/merchants/{mid}/cards # Merchant's cards +/admin/loyalty/merchants/{mid}/cards/{cid} # Card detail +/admin/loyalty/merchants/{mid}/transactions # Merchant's transactions +/admin/loyalty/merchants/{mid}/pins # Merchant's PINs (read-only) +``` + +### Storefront Pages + +``` +/platforms/loyalty/storefront/{store_code}/loyalty/join # Self-enrollment +/platforms/loyalty/storefront/{store_code}/loyalty/join/success # Enrollment success +/platforms/loyalty/storefront/{store_code}/account/loyalty # Dashboard +/platforms/loyalty/storefront/{store_code}/account/loyalty/history # History +``` + +## Sidebar Navigation + +### Store Menu + +| Order | Item | Icon | Route | +|---|---|---|---| +| 10 | Terminal | `gift` | `/store/{code}/loyalty/terminal` | +| 20 | Customer Cards | `identification` | `/store/{code}/loyalty/cards` | +| 22 | Staff PINs | `key` | `/store/{code}/loyalty/pins` | +| 25 | Program | `cog` | `/store/{code}/loyalty/program` | +| 30 | Analytics | `chart-bar` | `/store/{code}/loyalty/analytics` | + +All require `loyalty.view_programs` permission. + +### Merchant Menu + +| Order | Item | Icon | Route | +|---|---|---|---| +| 10 | Program | `gift` | `/merchants/loyalty/program` | +| 15 | Customer Cards | `identification` | `/merchants/loyalty/cards` | +| 20 | Analytics | `chart-bar` | `/merchants/loyalty/analytics` | +| 25 | Transactions | `clock` | `/merchants/loyalty/transactions` | +| 30 | Staff PINs | `key` | `/merchants/loyalty/pins` | +| 35 | Settings | `cog` | `/merchants/loyalty/settings` | + +### Admin Menu + +| Order | Item | Icon | Route | +|---|---|---|---| +| 10 | Loyalty Programs | `gift` | `/admin/loyalty/programs` | +| 20 | Loyalty Analytics | `chart-bar` | `/admin/loyalty/analytics` | +| 30 | Wallet Debug | `beaker` | `/admin/loyalty/wallet-debug` (super admin) | + +Admin sub-pages (cards, transactions, PINs) are accessed via the merchant detail hub — no separate menu items. + +## API Endpoints by Persona + +### Store API (`/api/v1/store/loyalty/`) + +| Group | Endpoints | +|---|---| +| Program | `GET/POST/PUT/DELETE /program`, `GET /stats`, `GET /stats/merchant` | +| Cards | `GET /cards`, `GET/POST /cards/lookup`, `GET /cards/{id}`, `POST /cards/enroll`, `GET /cards/{id}/transactions`, `GET /transactions` | +| Stamps | `POST /stamp`, `POST /stamp/redeem`, `POST /stamp/void` | +| Points | `POST /points/earn`, `POST /points/redeem`, `POST /points/void`, `POST /cards/{id}/points/adjust` | +| PINs | `GET /pins`, `POST /pins`, `PATCH /pins/{id}`, `DELETE /pins/{id}`, `POST /pins/{id}/unlock` | + +### Merchant API (`/api/v1/merchants/loyalty/`) + +| Group | Endpoints | +|---|---| +| Program | `GET/POST/PATCH/DELETE /program`, `GET /stats` | +| Cards | `GET /cards`, `GET /cards/{id}`, `GET /cards/{id}/transactions` | +| Transactions | `GET /transactions` | +| PINs | `GET /pins`, `POST /pins`, `PATCH /pins/{id}`, `DELETE /pins/{id}`, `POST /pins/{id}/unlock` | +| Settings | `GET /settings` | +| Locations | `GET /locations` | + +### Admin API (`/api/v1/admin/loyalty/`) + +| Group | Endpoints | +|---|---| +| Programs | `GET /programs`, `GET /programs/{id}`, `GET /programs/{id}/stats`, `POST /merchants/{mid}/program`, `PATCH /programs/{id}`, `DELETE /programs/{id}`, `POST /programs/{id}/activate`, `POST /programs/{id}/deactivate` | +| Merchant Mgmt | `GET /merchants/{mid}/stats`, `GET /merchants/{mid}/settings`, `PATCH /merchants/{mid}/settings` | +| On-Behalf | `GET /merchants/{mid}/cards`, `GET /merchants/{mid}/cards/{cid}`, `GET /merchants/{mid}/cards/{cid}/transactions`, `GET /merchants/{mid}/transactions`, `GET /merchants/{mid}/pins` (read-only), `GET /merchants/{mid}/locations` | +| Platform | `GET /stats`, `GET /wallet-status` | +| Wallet Debug | `GET /debug/config`, `GET /debug/classes`, `POST /debug/classes/{pid}/create`, `GET /debug/cards/{cid}`, `POST /debug/cards/{cid}/generate-url`, `GET /debug/recent-enrollments` (super admin) | + +## Architecture: Shared Components + +All persona pages reuse the same shared partials and JS modules to ensure consistent behavior: + +### Shared Templates (`templates/loyalty/shared/`) + +| Partial | Variables | Used By | +|---|---|---| +| `cards-list.html` | `cards_api_prefix`, `cards_base_url`, `show_store_filter`, `show_enroll_button` | Store, Merchant, Admin | +| `card-detail-view.html` | `card_detail_api_prefix`, `card_detail_back_url` | Store, Merchant, Admin | +| `transactions-list.html` | `transactions_api_prefix`, `show_store_filter` | Merchant, Admin | +| `pins-list.html` | `pins_api_prefix`, `show_store_filter`, `show_crud` | Store, Merchant, Admin | +| `analytics-stats.html` | `show_programs_card`, `show_locations`, `show_merchants_metric` | Store, Merchant, Admin | +| `program-form.html` | `show_delete`, `show_status` | Store, Merchant, Admin | +| `program-view.html` | -- | Store, Merchant, Admin | + +### Shared JS Modules (`static/shared/js/`) + +| Module | Factory Function | Config | +|---|---|---| +| `loyalty-cards-list.js` | `loyaltyCardsList(config)` | `apiPrefix`, `baseUrl`, `showStoreFilter`, `currentPage` | +| `loyalty-card-detail-view.js` | `loyaltyCardDetailView(config)` | `apiPrefix`, `backUrl`, `currentPage` | +| `loyalty-transactions-list.js` | `loyaltyTransactionsList(config)` | `apiPrefix`, `showStoreFilter`, `currentPage` | +| `loyalty-pins-list.js` | `loyaltyPinsList(config)` | `apiPrefix`, `showStoreFilter`, `showCrud`, `currentPage` | + +Each persona has thin wrapper JS files that call the shared factory with persona-specific config (API prefix, base URL, currentPage). + +## Scoping Rules + +| Concern | Admin | Merchant | Store | +|---|---|---|---| +| **Data scope** | Any merchant (via `merchant_id` path param) | Own merchant (from auth token) | Own store (from auth token), cards visible merchant-wide | +| **PINs** | Read-only across merchant | CRUD across all stores | CRUD for own store only | +| **Settings** | Full edit | Read-only view | Not visible | +| **Card operations** | View only | View only | Stamp/points/enroll/void | +| **Program edit** | Any merchant | Own program | Owner role only | + +## Schemas + +### PinDetailResponse (new) + +Extends `PinResponse` with store context for cross-store listings: + +```python +class PinDetailResponse(PinResponse): + store_id: int | None + store_name: str | None + +class PinDetailListResponse(BaseModel): + pins: list[PinDetailResponse] + total: int +``` + +Used by merchant `GET /pins` and admin `GET /merchants/{mid}/pins` endpoints. Store endpoints continue using `PinResponse` / `PinListResponse` (single-store context). diff --git a/app/modules/loyalty/locales/de.json b/app/modules/loyalty/locales/de.json index 515d9164..1ce2d592 100644 --- a/app/modules/loyalty/locales/de.json +++ b/app/modules/loyalty/locales/de.json @@ -81,7 +81,9 @@ "program": "Programm", "overview": "Übersicht", "settings": "Einstellungen", - "wallet_debug": "Wallet Debug" + "wallet_debug": "Wallet Debug", + "staff_pins": "Mitarbeiter-PINs", + "transactions": "Transaktionen" }, "permissions": { "view_programs": "Programme anzeigen", @@ -242,6 +244,105 @@ "terms_conditions": "Allgemeine Geschäftsbedingungen", "privacy_policy_url": "Datenschutzrichtlinien-URL" }, + "cards": { + "total_members": "Mitglieder gesamt", + "active_30d": "Aktiv (30T)", + "new_this_month": "Neu diesen Monat", + "total_points_balance": "Gesamtpunktestand", + "search_placeholder": "Nach Name, E-Mail, Telefon oder Kartennummer suchen...", + "all_status": "Alle Status", + "all_stores": "Alle Filialen", + "col_member": "Mitglied", + "col_card_number": "Kartennummer", + "col_points_balance": "Punktestand", + "col_last_activity": "Letzte Aktivität", + "col_status": "Status", + "col_actions": "Aktionen", + "no_members": "Keine Mitglieder gefunden", + "adjust_search": "Versuchen Sie, Ihre Suchkriterien anzupassen" + }, + "card_detail": { + "title": "Kartendetail", + "loading": "Kartendetails werden geladen...", + "error_loading": "Fehler beim Laden", + "points_balance": "Punktestand", + "total_earned": "Gesamt verdient", + "total_redeemed": "Gesamt eingelöst", + "member_since": "Mitglied seit", + "customer_information": "Kundeninformationen", + "name": "Name", + "email": "E-Mail", + "phone": "Telefon", + "birthday": "Geburtstag", + "card_details": "Kartendetails", + "card_number": "Kartennummer", + "status": "Status", + "last_activity": "Letzte Aktivität", + "enrolled_at": "Angemeldet bei", + "transaction_history": "Transaktionshistorie", + "col_date": "Datum", + "col_type": "Typ", + "col_points": "Punkte", + "col_location": "Standort", + "col_notes": "Notizen", + "no_transactions": "Keine Transaktionen gefunden" + }, + "transactions": { + "title": "Transaktionen", + "subtitle": "Alle Treue-Transaktionen anzeigen", + "loading": "Transaktionen werden geladen...", + "error_loading": "Fehler beim Laden", + "search_placeholder": "Transaktionen suchen...", + "all_types": "Alle Typen", + "all_stores": "Alle Filialen", + "col_date": "Datum", + "col_customer": "Kunde", + "col_type": "Typ", + "col_points": "Punkte", + "col_location": "Standort", + "col_notes": "Notizen", + "no_transactions": "Keine Transaktionen gefunden" + }, + "pins": { + "title": "Mitarbeiter-PINs", + "subtitle": "Mitarbeiter-Authentifizierungs-PINs verwalten", + "loading": "PINs werden geladen...", + "error_loading": "Fehler beim Laden", + "total_pins": "PINs gesamt", + "active_pins": "Aktiv", + "locked_pins": "Gesperrt", + "all_stores": "Alle Filialen", + "all_status": "Alle Status", + "status_active": "Aktiv", + "status_inactive": "Inaktiv", + "status_locked": "Gesperrt", + "col_name": "Name", + "col_staff_id": "Mitarbeiter-ID", + "col_store": "Filiale", + "col_status": "Status", + "col_locked": "Gesperrt", + "col_last_used": "Zuletzt verwendet", + "col_actions": "Aktionen", + "no_pins": "Keine PINs gefunden", + "create_pin": "PIN erstellen", + "edit_pin": "PIN bearbeiten", + "delete_pin": "PIN löschen", + "unlock_pin": "Entsperren", + "confirm_delete": "Sind Sie sicher, dass Sie diesen PIN löschen möchten?", + "pin_name": "Name des Mitarbeiters", + "pin_staff_id": "Mitarbeiter-ID (optional)", + "pin_code": "PIN-Code", + "pin_code_hint": "4-6-stelliger PIN", + "pin_store": "Filiale", + "select_store": "Filiale auswählen", + "pin_created": "PIN erfolgreich erstellt", + "pin_updated": "PIN erfolgreich aktualisiert", + "pin_deleted": "PIN erfolgreich gelöscht", + "pin_unlocked": "PIN erfolgreich entsperrt", + "save": "Speichern", + "cancel": "Abbrechen", + "read_only_notice": "PINs sind in der Admin-Ansicht schreibgeschützt" + }, "program_form": { "program_type": "Programmtyp", "points_type_desc": "Punkte pro ausgegebenem EUR verdienen", @@ -360,7 +461,31 @@ "cross_location_redemption": "Standortübergreifende Einlösung", "allowed": "Erlaubt", "disabled": "Deaktiviert", - "modify_policy": "Admin-Richtlinie ändern" + "modify_policy": "Admin-Richtlinie ändern", + "view_cards": "Karten anzeigen", + "view_transactions": "Transaktionen anzeigen", + "view_pins": "PINs anzeigen" + }, + "merchant_cards": { + "title": "Händlerkarten", + "subtitle": "Treuekarten für diesen Händler anzeigen", + "loading": "Karten werden geladen...", + "error_loading": "Fehler beim Laden" + }, + "merchant_card_detail": { + "title": "Kartendetail" + }, + "merchant_transactions": { + "title": "Händler-Transaktionen", + "subtitle": "Alle Transaktionen für diesen Händler", + "loading": "Transaktionen werden geladen...", + "error_loading": "Fehler beim Laden" + }, + "merchant_pins": { + "title": "Händler-PINs", + "subtitle": "PINs für diesen Händler anzeigen (schreibgeschützt)", + "loading": "PINs werden geladen...", + "error_loading": "Fehler beim Laden" }, "merchant_settings": { "title": "Händler-Treueeinstellungen", @@ -445,6 +570,34 @@ "delete_message": "Ihr Treueprogramm und alle zugehörigen Daten (Karten, Transaktionen, Prämien) werden dauerhaft gelöscht. Dies kann nicht rückgängig gemacht werden.", "delete_confirm": "Programm löschen" }, + "cards": { + "title": "Kundenkarten", + "subtitle": "Treuemitglieder über alle Standorte verwalten" + }, + "card_detail": { + "title": "Kartendetail" + }, + "transactions": { + "title": "Transaktionen", + "subtitle": "Alle Transaktionen über alle Standorte anzeigen" + }, + "pins": { + "title": "Mitarbeiter-PINs", + "subtitle": "PINs für alle Standorte verwalten" + }, + "settings": { + "title": "Treue-Einstellungen", + "subtitle": "Treueprogramm-Einstellungen anzeigen", + "admin_controlled": "Diese Einstellungen werden vom Plattformadministrator verwaltet", + "staff_pin_policy": "Mitarbeiter-PIN-Richtlinie", + "self_enrollment": "Selbstanmeldung", + "cross_location": "Standortübergreifende Einlösung", + "void_transactions": "Stornierungen erlauben", + "enabled": "Aktiviert", + "disabled": "Deaktiviert", + "required": "Erforderlich", + "optional": "Optional" + }, "analytics": { "title": "Treue-Analytik", "subtitle": "Treueprogramm-Statistiken für alle Ihre Geschäfte", @@ -609,6 +762,12 @@ "create_program": "Programm erstellen", "contact_admin": "Kontaktieren Sie Ihren Administrator, um ein Treueprogramm einzurichten." }, + "pins": { + "title": "Mitarbeiter-PINs", + "subtitle": "Mitarbeiter-PINs für diesen Standort verwalten", + "loading": "PINs werden geladen...", + "error_loading": "Fehler beim Laden der PINs" + }, "settings": { "title": "Treue-Einstellungen", "page_title": "Treueprogramm-Einstellungen", diff --git a/app/modules/loyalty/locales/en.json b/app/modules/loyalty/locales/en.json index dc10ff58..f1c2a613 100644 --- a/app/modules/loyalty/locales/en.json +++ b/app/modules/loyalty/locales/en.json @@ -91,7 +91,9 @@ "program": "Program", "overview": "Overview", "settings": "Settings", - "wallet_debug": "Wallet Debug" + "wallet_debug": "Wallet Debug", + "staff_pins": "Staff PINs", + "transactions": "Transactions" }, "onboarding": { "create_program": { @@ -242,6 +244,105 @@ "terms_conditions": "Terms & Conditions", "privacy_policy_url": "Privacy Policy URL" }, + "cards": { + "total_members": "Total Members", + "active_30d": "Active (30d)", + "new_this_month": "New This Month", + "total_points_balance": "Total Points Balance", + "search_placeholder": "Search by name, email, phone, or card number...", + "all_status": "All Status", + "all_stores": "All Stores", + "col_member": "Member", + "col_card_number": "Card Number", + "col_points_balance": "Points Balance", + "col_last_activity": "Last Activity", + "col_status": "Status", + "col_actions": "Actions", + "no_members": "No members found", + "adjust_search": "Try adjusting your search criteria" + }, + "card_detail": { + "title": "Card Detail", + "loading": "Loading card details...", + "error_loading": "Error loading card details", + "points_balance": "Points Balance", + "total_earned": "Total Earned", + "total_redeemed": "Total Redeemed", + "member_since": "Member Since", + "customer_information": "Customer Information", + "name": "Name", + "email": "Email", + "phone": "Phone", + "birthday": "Birthday", + "card_details": "Card Details", + "card_number": "Card Number", + "status": "Status", + "last_activity": "Last Activity", + "enrolled_at": "Enrolled At", + "transaction_history": "Transaction History", + "col_date": "Date", + "col_type": "Type", + "col_points": "Points", + "col_location": "Location", + "col_notes": "Notes", + "no_transactions": "No transactions found" + }, + "transactions": { + "title": "Transactions", + "subtitle": "View all loyalty transactions", + "loading": "Loading transactions...", + "error_loading": "Error loading transactions", + "search_placeholder": "Search transactions...", + "all_types": "All Types", + "all_stores": "All Stores", + "col_date": "Date", + "col_customer": "Customer", + "col_type": "Type", + "col_points": "Points", + "col_location": "Location", + "col_notes": "Notes", + "no_transactions": "No transactions found" + }, + "pins": { + "title": "Staff PINs", + "subtitle": "Manage staff authentication PINs", + "loading": "Loading PINs...", + "error_loading": "Error loading PINs", + "total_pins": "Total PINs", + "active_pins": "Active", + "locked_pins": "Locked", + "all_stores": "All Stores", + "all_status": "All Status", + "status_active": "Active", + "status_inactive": "Inactive", + "status_locked": "Locked", + "col_name": "Name", + "col_staff_id": "Staff ID", + "col_store": "Store", + "col_status": "Status", + "col_locked": "Locked", + "col_last_used": "Last Used", + "col_actions": "Actions", + "no_pins": "No staff PINs found", + "create_pin": "Create PIN", + "edit_pin": "Edit PIN", + "delete_pin": "Delete PIN", + "unlock_pin": "Unlock", + "confirm_delete": "Are you sure you want to delete this PIN?", + "pin_name": "Staff member name", + "pin_staff_id": "Employee ID (optional)", + "pin_code": "PIN Code", + "pin_code_hint": "4-6 digit PIN", + "pin_store": "Store", + "select_store": "Select store", + "pin_created": "PIN created successfully", + "pin_updated": "PIN updated successfully", + "pin_deleted": "PIN deleted successfully", + "pin_unlocked": "PIN unlocked successfully", + "save": "Save", + "cancel": "Cancel", + "read_only_notice": "PINs are read-only in admin view" + }, "program_form": { "program_type": "Program Type", "points_type_desc": "Earn points per EUR spent", @@ -360,7 +461,31 @@ "cross_location_redemption": "Cross-Location Redemption", "allowed": "Allowed", "disabled": "Disabled", - "modify_policy": "Modify admin policy" + "modify_policy": "Modify admin policy", + "view_cards": "View Cards", + "view_transactions": "View Transactions", + "view_pins": "View PINs" + }, + "merchant_cards": { + "title": "Merchant Cards", + "subtitle": "View loyalty cards for this merchant", + "loading": "Loading cards...", + "error_loading": "Error loading cards" + }, + "merchant_card_detail": { + "title": "Card Detail" + }, + "merchant_transactions": { + "title": "Merchant Transactions", + "subtitle": "View all transactions for this merchant", + "loading": "Loading transactions...", + "error_loading": "Error loading transactions" + }, + "merchant_pins": { + "title": "Merchant Staff PINs", + "subtitle": "View staff PINs for this merchant (read-only)", + "loading": "Loading PINs...", + "error_loading": "Error loading PINs" }, "merchant_settings": { "title": "Merchant Loyalty Settings", @@ -445,6 +570,34 @@ "delete_message": "This will permanently delete your loyalty program and all associated data (cards, transactions, rewards). This action cannot be undone.", "delete_confirm": "Delete Program" }, + "cards": { + "title": "Customer Cards", + "subtitle": "View and manage loyalty members across all locations" + }, + "card_detail": { + "title": "Card Detail" + }, + "transactions": { + "title": "Transactions", + "subtitle": "View all loyalty transactions across locations" + }, + "pins": { + "title": "Staff PINs", + "subtitle": "Manage staff PINs across all locations" + }, + "settings": { + "title": "Loyalty Settings", + "subtitle": "View loyalty program settings", + "admin_controlled": "These settings are managed by the platform administrator", + "staff_pin_policy": "Staff PIN Policy", + "self_enrollment": "Self Enrollment", + "cross_location": "Cross-Location Redemption", + "void_transactions": "Allow Void Transactions", + "enabled": "Enabled", + "disabled": "Disabled", + "required": "Required", + "optional": "Optional" + }, "analytics": { "title": "Loyalty Analytics", "subtitle": "Loyalty program statistics across all your stores", @@ -609,6 +762,12 @@ "create_program": "Create Program", "contact_admin": "Contact your administrator to set up a loyalty program." }, + "pins": { + "title": "Staff PINs", + "subtitle": "Manage staff authentication PINs for this location", + "loading": "Loading PINs...", + "error_loading": "Error loading PINs" + }, "settings": { "title": "Loyalty Settings", "page_title": "Loyalty Program Settings", diff --git a/app/modules/loyalty/locales/fr.json b/app/modules/loyalty/locales/fr.json index b7de911a..03d7da20 100644 --- a/app/modules/loyalty/locales/fr.json +++ b/app/modules/loyalty/locales/fr.json @@ -81,7 +81,9 @@ "program": "Programme", "overview": "Aperçu", "settings": "Paramètres", - "wallet_debug": "Wallet Debug" + "wallet_debug": "Wallet Debug", + "staff_pins": "PINs du personnel", + "transactions": "Transactions" }, "permissions": { "view_programs": "Voir les programmes", @@ -242,6 +244,105 @@ "terms_conditions": "Conditions Générales", "privacy_policy_url": "URL politique de confidentialité" }, + "cards": { + "total_members": "Membres totaux", + "active_30d": "Actifs (30j)", + "new_this_month": "Nouveaux ce mois", + "total_points_balance": "Solde total des points", + "search_placeholder": "Rechercher par nom, email, téléphone ou numéro de carte...", + "all_status": "Tous les statuts", + "all_stores": "Tous les magasins", + "col_member": "Membre", + "col_card_number": "Numéro de carte", + "col_points_balance": "Solde de points", + "col_last_activity": "Dernière activité", + "col_status": "Statut", + "col_actions": "Actions", + "no_members": "Aucun membre trouvé", + "adjust_search": "Essayez de modifier vos critères de recherche" + }, + "card_detail": { + "title": "Détail de la carte", + "loading": "Chargement des détails...", + "error_loading": "Erreur lors du chargement", + "points_balance": "Solde de points", + "total_earned": "Total gagné", + "total_redeemed": "Total échangé", + "member_since": "Membre depuis", + "customer_information": "Informations client", + "name": "Nom", + "email": "E-mail", + "phone": "Téléphone", + "birthday": "Anniversaire", + "card_details": "Détails de la carte", + "card_number": "Numéro de carte", + "status": "Statut", + "last_activity": "Dernière activité", + "enrolled_at": "Inscrit à", + "transaction_history": "Historique des transactions", + "col_date": "Date", + "col_type": "Type", + "col_points": "Points", + "col_location": "Emplacement", + "col_notes": "Notes", + "no_transactions": "Aucune transaction trouvée" + }, + "transactions": { + "title": "Transactions", + "subtitle": "Voir toutes les transactions", + "loading": "Chargement des transactions...", + "error_loading": "Erreur lors du chargement", + "search_placeholder": "Rechercher des transactions...", + "all_types": "Tous les types", + "all_stores": "Tous les magasins", + "col_date": "Date", + "col_customer": "Client", + "col_type": "Type", + "col_points": "Points", + "col_location": "Emplacement", + "col_notes": "Notes", + "no_transactions": "Aucune transaction trouvée" + }, + "pins": { + "title": "PINs du personnel", + "subtitle": "Gérer les PINs d'authentification", + "loading": "Chargement des PINs...", + "error_loading": "Erreur lors du chargement", + "total_pins": "Total PINs", + "active_pins": "Actifs", + "locked_pins": "Verrouillés", + "all_stores": "Tous les magasins", + "all_status": "Tous les statuts", + "status_active": "Actif", + "status_inactive": "Inactif", + "status_locked": "Verrouillé", + "col_name": "Nom", + "col_staff_id": "ID employé", + "col_store": "Magasin", + "col_status": "Statut", + "col_locked": "Verrouillé", + "col_last_used": "Dernière utilisation", + "col_actions": "Actions", + "no_pins": "Aucun PIN trouvé", + "create_pin": "Créer un PIN", + "edit_pin": "Modifier le PIN", + "delete_pin": "Supprimer le PIN", + "unlock_pin": "Déverrouiller", + "confirm_delete": "Êtes-vous sûr de vouloir supprimer ce PIN ?", + "pin_name": "Nom de l'employé", + "pin_staff_id": "ID employé (optionnel)", + "pin_code": "Code PIN", + "pin_code_hint": "PIN à 4-6 chiffres", + "pin_store": "Magasin", + "select_store": "Sélectionner un magasin", + "pin_created": "PIN créé avec succès", + "pin_updated": "PIN modifié avec succès", + "pin_deleted": "PIN supprimé avec succès", + "pin_unlocked": "PIN déverrouillé avec succès", + "save": "Enregistrer", + "cancel": "Annuler", + "read_only_notice": "Les PINs sont en lecture seule en mode admin" + }, "program_form": { "program_type": "Type de programme", "points_type_desc": "Gagner des points par EUR dépensé", @@ -360,7 +461,31 @@ "cross_location_redemption": "Échange inter-points de vente", "allowed": "Autorisé", "disabled": "Désactivé", - "modify_policy": "Modifier la politique admin" + "modify_policy": "Modifier la politique admin", + "view_cards": "Voir les cartes", + "view_transactions": "Voir les transactions", + "view_pins": "Voir les PINs" + }, + "merchant_cards": { + "title": "Cartes du commerçant", + "subtitle": "Voir les cartes de ce commerçant", + "loading": "Chargement des cartes...", + "error_loading": "Erreur lors du chargement" + }, + "merchant_card_detail": { + "title": "Détail de la carte" + }, + "merchant_transactions": { + "title": "Transactions du commerçant", + "subtitle": "Voir toutes les transactions de ce commerçant", + "loading": "Chargement des transactions...", + "error_loading": "Erreur lors du chargement" + }, + "merchant_pins": { + "title": "PINs du commerçant", + "subtitle": "Voir les PINs de ce commerçant (lecture seule)", + "loading": "Chargement des PINs...", + "error_loading": "Erreur lors du chargement" }, "merchant_settings": { "title": "Paramètres de fidélité du commerçant", @@ -445,6 +570,34 @@ "delete_message": "Cela supprimera définitivement votre programme de fidélité et toutes les données associées (cartes, transactions, récompenses). Cette action est irréversible.", "delete_confirm": "Supprimer le programme" }, + "cards": { + "title": "Cartes clients", + "subtitle": "Voir et gérer les membres fidélité sur tous les sites" + }, + "card_detail": { + "title": "Détail de la carte" + }, + "transactions": { + "title": "Transactions", + "subtitle": "Voir toutes les transactions sur tous les sites" + }, + "pins": { + "title": "PINs du personnel", + "subtitle": "Gérer les PINs sur tous les sites" + }, + "settings": { + "title": "Paramètres fidélité", + "subtitle": "Voir les paramètres du programme", + "admin_controlled": "Ces paramètres sont gérés par l'administrateur", + "staff_pin_policy": "Politique PIN personnel", + "self_enrollment": "Auto-inscription", + "cross_location": "Échange multi-sites", + "void_transactions": "Autoriser les annulations", + "enabled": "Activé", + "disabled": "Désactivé", + "required": "Obligatoire", + "optional": "Optionnel" + }, "analytics": { "title": "Analytique fidélité", "subtitle": "Statistiques de fidélité pour tous vos magasins", @@ -609,6 +762,12 @@ "create_program": "Créer un programme", "contact_admin": "Contactez votre administrateur pour configurer un programme de fidélité." }, + "pins": { + "title": "PINs du personnel", + "subtitle": "Gérer les PINs d'authentification pour ce magasin", + "loading": "Chargement des PINs...", + "error_loading": "Erreur lors du chargement des PINs" + }, "settings": { "title": "Paramètres de fidélité", "page_title": "Paramètres du programme de fidélité", diff --git a/app/modules/loyalty/locales/lb.json b/app/modules/loyalty/locales/lb.json index 5d2092c6..67d8e334 100644 --- a/app/modules/loyalty/locales/lb.json +++ b/app/modules/loyalty/locales/lb.json @@ -81,7 +81,9 @@ "program": "Programm", "overview": "Iwwersiicht", "settings": "Astellungen", - "wallet_debug": "Wallet Debug" + "wallet_debug": "Wallet Debug", + "staff_pins": "Mataarbechter-PINen", + "transactions": "Transaktionen" }, "permissions": { "view_programs": "Programmer kucken", @@ -242,6 +244,105 @@ "terms_conditions": "Allgemeng Geschäftsbedingungen", "privacy_policy_url": "Dateschutzrichtlinn-URL" }, + "cards": { + "total_members": "Memberen total", + "active_30d": "Aktiv (30D)", + "new_this_month": "Nei dëse Mount", + "total_points_balance": "Total Punktestand", + "search_placeholder": "No Numm, E-Mail, Telefon oder Kaartennummer sichen...", + "all_status": "All Statussen", + "all_stores": "All Geschäfter", + "col_member": "Member", + "col_card_number": "Kaartennummer", + "col_points_balance": "Punktestand", + "col_last_activity": "Lescht Aktivitéit", + "col_status": "Status", + "col_actions": "Aktiounen", + "no_members": "Keng Memberen fonnt", + "adjust_search": "Probéiert Är Sichkriterien unzepassen" + }, + "card_detail": { + "title": "Kaartendetail", + "loading": "Kaartendetailer ginn gelueden...", + "error_loading": "Feeler beim Lueden", + "points_balance": "Punktestand", + "total_earned": "Total verdéngt", + "total_redeemed": "Total ageléist", + "member_since": "Member zënter", + "customer_information": "Clientsinformatiounen", + "name": "Numm", + "email": "E-Mail", + "phone": "Telefon", + "birthday": "Gebuertsdag", + "card_details": "Kaartendetailer", + "card_number": "Kaartennummer", + "status": "Status", + "last_activity": "Lescht Aktivitéit", + "enrolled_at": "Ageschriwwen bei", + "transaction_history": "Transaktiouns-Historique", + "col_date": "Datum", + "col_type": "Typ", + "col_points": "Punkten", + "col_location": "Standuert", + "col_notes": "Notizen", + "no_transactions": "Keng Transaktioune fonnt" + }, + "transactions": { + "title": "Transaktionen", + "subtitle": "All Treieprogramm-Transaktioune kucken", + "loading": "Transaktioune ginn gelueden...", + "error_loading": "Feeler beim Lueden", + "search_placeholder": "Transaktioune sichen...", + "all_types": "All Typen", + "all_stores": "All Geschäfter", + "col_date": "Datum", + "col_customer": "Client", + "col_type": "Typ", + "col_points": "Punkten", + "col_location": "Standuert", + "col_notes": "Notizen", + "no_transactions": "Keng Transaktioune fonnt" + }, + "pins": { + "title": "Mataarbechter-PINen", + "subtitle": "Mataarbechter-Authentifizéierungs-PINen verwalten", + "loading": "PINe ginn gelueden...", + "error_loading": "Feeler beim Lueden", + "total_pins": "PINen total", + "active_pins": "Aktiv", + "locked_pins": "Gespaart", + "all_stores": "All Geschäfter", + "all_status": "All Statussen", + "status_active": "Aktiv", + "status_inactive": "Inaktiv", + "status_locked": "Gespaart", + "col_name": "Numm", + "col_staff_id": "Mataarbechter-ID", + "col_store": "Geschäft", + "col_status": "Status", + "col_locked": "Gespaart", + "col_last_used": "Lescht benotzt", + "col_actions": "Aktiounen", + "no_pins": "Keng PINen fonnt", + "create_pin": "PIN erstellen", + "edit_pin": "PIN änneren", + "delete_pin": "PIN läschen", + "unlock_pin": "Entspären", + "confirm_delete": "Sidd Dir sécher, datt Dir dësen PIN läsche wëllt?", + "pin_name": "Numm vum Mataarbechter", + "pin_staff_id": "Mataarbechter-ID (optional)", + "pin_code": "PIN-Code", + "pin_code_hint": "4-6-stellegen PIN", + "pin_store": "Geschäft", + "select_store": "Geschäft wielen", + "pin_created": "PIN erfollegräich erstallt", + "pin_updated": "PIN erfollegräich aktualiséiert", + "pin_deleted": "PIN erfollegräich geläscht", + "pin_unlocked": "PIN erfollegräich entspäert", + "save": "Späicheren", + "cancel": "Ofbriechen", + "read_only_notice": "PINen sinn an der Admin-Usiicht nëmmen ze liesen" + }, "program_form": { "program_type": "Programmtyp", "points_type_desc": "Punkten pro ausgegoenen EUR verdéngen", @@ -360,7 +461,31 @@ "cross_location_redemption": "Standuertiwergreifend Aléisung", "allowed": "Erlaabt", "disabled": "Deaktivéiert", - "modify_policy": "Admin-Richtlinn änneren" + "modify_policy": "Admin-Richtlinn änneren", + "view_cards": "Kaarten kucken", + "view_transactions": "Transaktioune kucken", + "view_pins": "PINen kucken" + }, + "merchant_cards": { + "title": "Händlerkaarten", + "subtitle": "Treiekaarten fir dësen Händler kucken", + "loading": "Kaarte ginn gelueden...", + "error_loading": "Feeler beim Lueden" + }, + "merchant_card_detail": { + "title": "Kaartendetail" + }, + "merchant_transactions": { + "title": "Händler-Transaktionen", + "subtitle": "All Transaktioune fir dësen Händler", + "loading": "Transaktioune ginn gelueden...", + "error_loading": "Feeler beim Lueden" + }, + "merchant_pins": { + "title": "Händler-PINen", + "subtitle": "PINen fir dësen Händler kucken (nëmmen liesen)", + "loading": "PINe ginn gelueden...", + "error_loading": "Feeler beim Lueden" }, "merchant_settings": { "title": "Händler-Treieastelllungen", @@ -445,6 +570,34 @@ "delete_message": "Ärt Treieprogramm an all verbonnen Daten (Kaarten, Transaktiounen, Belounungen) ginn dauerhaft geläscht. Dëst kann net réckgängeg gemaach ginn.", "delete_confirm": "Programm läschen" }, + "cards": { + "title": "Clientskaarten", + "subtitle": "Treieprogramm-Memberen iwwer all Standuerter verwalten" + }, + "card_detail": { + "title": "Kaartendetail" + }, + "transactions": { + "title": "Transaktionen", + "subtitle": "All Transaktioune vun all Standuerter kucken" + }, + "pins": { + "title": "Mataarbechter-PINen", + "subtitle": "PINen fir all Standuerter verwalten" + }, + "settings": { + "title": "Treieprogramm-Astellungen", + "subtitle": "Treieprogramm-Astellungen kucken", + "admin_controlled": "Dës Astellungen ginn vum Plattformadministrateur geréiert", + "staff_pin_policy": "Mataarbechter-PIN-Richtlinn", + "self_enrollment": "Selwer-Umeldung", + "cross_location": "Standuert-iwwergräifend Aléisung", + "void_transactions": "Stornéierungen erlaben", + "enabled": "Aktivéiert", + "disabled": "Deaktivéiert", + "required": "Obligatoresch", + "optional": "Optional" + }, "analytics": { "title": "Treie-Analytik", "subtitle": "Treieprogramm-Statistiken fir all Är Geschäfter", @@ -609,6 +762,12 @@ "create_program": "Programm erstellen", "contact_admin": "Kontaktéiert Ären Administrateur fir en Treieprogramm anzerichten." }, + "pins": { + "title": "Mataarbechter-PINen", + "subtitle": "Mataarbechter-PINen fir dëse Standuert verwalten", + "loading": "PINe ginn gelueden...", + "error_loading": "Feeler beim Lueden vun den PINen" + }, "settings": { "title": "Treie-Astellungen", "page_title": "Treieprogramm-Astellungen", diff --git a/app/modules/loyalty/routes/api/admin.py b/app/modules/loyalty/routes/api/admin.py index 1c688186..da013cee 100644 --- a/app/modules/loyalty/routes/api/admin.py +++ b/app/modules/loyalty/routes/api/admin.py @@ -21,16 +21,23 @@ from app.api.deps import ( from app.core.database import get_db from app.modules.enums import FrontendType from app.modules.loyalty.schemas import ( + CardDetailResponse, + CardListResponse, + CardResponse, MerchantSettingsResponse, MerchantSettingsUpdate, MerchantStatsResponse, + PinDetailListResponse, + PinDetailResponse, ProgramCreate, ProgramListResponse, ProgramResponse, ProgramStatsResponse, ProgramUpdate, + TransactionListResponse, + TransactionResponse, ) -from app.modules.loyalty.services import program_service +from app.modules.loyalty.services import card_service, pin_service, program_service from app.modules.tenancy.models import User # API-007 logger = logging.getLogger(__name__) @@ -260,6 +267,202 @@ def update_merchant_settings( return MerchantSettingsResponse.model_validate(settings) +# ============================================================================= +# On-Behalf Card & Transaction Endpoints +# ============================================================================= + + +@router.get("/merchants/{merchant_id}/cards", response_model=CardListResponse) +def list_merchant_cards( + merchant_id: int = Path(..., gt=0), + skip: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=100), + is_active: bool | None = Query(None), + search: str | None = Query(None, max_length=100), + store_id: int | None = Query(None, description="Filter by enrolled store"), + current_user: User = Depends(get_current_admin_api), + db: Session = Depends(get_db), +): + """List loyalty cards for a merchant (admin on-behalf).""" + cards, total = card_service.list_cards( + db, + merchant_id, + store_id=store_id, + skip=skip, + limit=limit, + is_active=is_active, + search=search, + ) + + card_responses = [] + for card in cards: + program = card.program + response = CardResponse( + id=card.id, + card_number=card.card_number, + customer_id=card.customer_id, + merchant_id=card.merchant_id, + program_id=card.program_id, + enrolled_at_store_id=card.enrolled_at_store_id, + customer_name=card.customer.full_name if card.customer else None, + customer_email=card.customer.email if card.customer else None, + stamp_count=card.stamp_count, + stamps_target=program.stamps_target if program else 0, + stamps_until_reward=max(0, (program.stamps_target if program else 0) - card.stamp_count), + total_stamps_earned=card.total_stamps_earned, + stamps_redeemed=card.stamps_redeemed, + points_balance=card.points_balance, + total_points_earned=card.total_points_earned, + points_redeemed=card.points_redeemed, + is_active=card.is_active, + created_at=card.created_at, + has_google_wallet=bool(card.google_object_id), + has_apple_wallet=bool(card.apple_serial_number), + ) + card_responses.append(response) + + return CardListResponse(cards=card_responses, total=total) + + +@router.get("/merchants/{merchant_id}/cards/{card_id}", response_model=CardDetailResponse) +def get_merchant_card( + merchant_id: int = Path(..., gt=0), + card_id: int = Path(..., gt=0), + current_user: User = Depends(get_current_admin_api), + db: Session = Depends(get_db), +): + """Get detailed loyalty card info (admin on-behalf).""" + card = card_service.get_card(db, card_id) + if not card or card.merchant_id != merchant_id: + from app.modules.loyalty.exceptions import LoyaltyCardNotFoundException + + raise LoyaltyCardNotFoundException(str(card_id)) + + program = card.program + customer = card.customer + + return CardDetailResponse( + id=card.id, + card_number=card.card_number, + customer_id=card.customer_id, + merchant_id=card.merchant_id, + program_id=card.program_id, + enrolled_at_store_id=card.enrolled_at_store_id, + customer_name=customer.full_name if customer else None, + customer_email=customer.email if customer else None, + merchant_name=card.merchant.name if card.merchant else None, + qr_code_data=card.qr_code_data or card.card_number, + program_name=program.display_name, + program_type=program.loyalty_type, + reward_description=program.stamps_reward_description, + stamp_count=card.stamp_count, + stamps_target=program.stamps_target, + stamps_until_reward=max(0, program.stamps_target - card.stamp_count), + total_stamps_earned=card.total_stamps_earned, + stamps_redeemed=card.stamps_redeemed, + points_balance=card.points_balance, + total_points_earned=card.total_points_earned, + points_redeemed=card.points_redeemed, + is_active=card.is_active, + created_at=card.created_at, + last_stamp_at=card.last_stamp_at, + last_points_at=card.last_points_at, + last_redemption_at=card.last_redemption_at, + last_activity_at=card.last_activity_at, + has_google_wallet=bool(card.google_object_id), + has_apple_wallet=bool(card.apple_serial_number), + ) + + +@router.get( + "/merchants/{merchant_id}/cards/{card_id}/transactions", + response_model=TransactionListResponse, +) +def get_merchant_card_transactions( + merchant_id: int = Path(..., gt=0), + card_id: int = Path(..., gt=0), + skip: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=100), + current_user: User = Depends(get_current_admin_api), + db: Session = Depends(get_db), +): + """Get transaction history for a card (admin on-behalf).""" + card = card_service.get_card(db, card_id) + if not card or card.merchant_id != merchant_id: + from app.modules.loyalty.exceptions import LoyaltyCardNotFoundException + + raise LoyaltyCardNotFoundException(str(card_id)) + + transactions, total = card_service.get_card_transactions( + db, card_id, skip=skip, limit=limit + ) + + return TransactionListResponse( + transactions=[TransactionResponse.model_validate(t) for t in transactions], + total=total, + ) + + +@router.get("/merchants/{merchant_id}/transactions", response_model=TransactionListResponse) +def list_merchant_transactions( + merchant_id: int = Path(..., gt=0), + skip: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=100), + store_id: int | None = Query(None, description="Filter by store location"), + current_user: User = Depends(get_current_admin_api), + db: Session = Depends(get_db), +): + """List recent transactions for a merchant (admin on-behalf).""" + transactions, total = card_service.get_store_transactions( + db, merchant_id, store_id=store_id, skip=skip, limit=limit + ) + + tx_responses = [] + for t in transactions: + tx = TransactionResponse.model_validate(t) + if t.card and t.card.customer: + tx.customer_name = t.card.customer.full_name + tx_responses.append(tx) + + return TransactionListResponse(transactions=tx_responses, total=total) + + +@router.get("/merchants/{merchant_id}/pins", response_model=PinDetailListResponse) +def list_merchant_pins( + merchant_id: int = Path(..., gt=0), + store_id: int | None = Query(None, description="Filter by store location"), + is_active: bool | None = Query(None), + current_user: User = Depends(get_current_admin_api), + db: Session = Depends(get_db), +): + """List staff PINs for a merchant (admin read-only).""" + pins = pin_service.list_pins_for_merchant( + db, merchant_id, store_id=store_id, is_active=is_active + ) + + pin_responses = [PinDetailResponse.model_validate(pin) for pin in pins] + + return PinDetailListResponse(pins=pin_responses, total=len(pin_responses)) + + +@router.get("/merchants/{merchant_id}/locations") +def list_merchant_locations( + merchant_id: int = Path(..., gt=0), + current_user: User = Depends(get_current_admin_api), + db: Session = Depends(get_db), +): + """Get active store locations for a merchant.""" + locations = program_service.get_merchant_locations(db, merchant_id) + return [ + { + "id": store.id, + "name": store.name, + "code": store.store_code, + } + for store in locations + ] + + # ============================================================================= # Platform Stats # ============================================================================= diff --git a/app/modules/loyalty/routes/api/merchant.py b/app/modules/loyalty/routes/api/merchant.py index 21669798..5684f569 100644 --- a/app/modules/loyalty/routes/api/merchant.py +++ b/app/modules/loyalty/routes/api/merchant.py @@ -7,6 +7,11 @@ Merchant portal endpoints for full program CRUD: - Create a loyalty program - Update the loyalty program - Delete the loyalty program +- List/view loyalty cards +- Transaction history +- Staff PIN management +- Merchant settings (read-only) +- Store locations Authentication: Authorization header (API-only, no cookies for CSRF safety). The user must own at least one active merchant (validated by @@ -18,18 +23,33 @@ registration under /api/v1/merchants/loyalty/*). import logging -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Path, Query 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.exceptions import ( + LoyaltyCardNotFoundException, + StaffPinNotFoundException, +) from app.modules.loyalty.schemas import ( + CardDetailResponse, + CardListResponse, + CardResponse, + MerchantSettingsResponse, + PinCreate, + PinDetailListResponse, + PinDetailResponse, + PinResponse, + PinUpdate, ProgramCreate, ProgramResponse, ProgramUpdate, + TransactionListResponse, + TransactionResponse, ) from app.modules.loyalty.schemas.program import MerchantStatsResponse -from app.modules.loyalty.services import program_service +from app.modules.loyalty.services import card_service, pin_service, program_service from app.modules.tenancy.models import Merchant logger = logging.getLogger(__name__) @@ -112,3 +132,284 @@ def delete_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") + + +# ============================================================================= +# Cards +# ============================================================================= + + +@router.get("/cards", response_model=CardListResponse) +def list_cards( + skip: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=100), + is_active: bool | None = Query(None), + search: str | None = Query(None, max_length=100), + store_id: int | None = Query(None), + merchant: Merchant = Depends(get_merchant_for_current_user), + db: Session = Depends(get_db), +): + """List loyalty cards for the merchant.""" + program = program_service.require_program_by_merchant(db, merchant.id) + + cards, total = card_service.list_cards( + db, + merchant.id, + store_id=store_id, + skip=skip, + limit=limit, + is_active=is_active, + search=search, + ) + + card_responses = [] + for card in cards: + customer = card.customer + response = CardResponse( + id=card.id, + card_number=card.card_number, + customer_id=card.customer_id, + merchant_id=card.merchant_id, + program_id=card.program_id, + enrolled_at_store_id=card.enrolled_at_store_id, + customer_name=customer.full_name if customer else None, + customer_email=customer.email if customer else None, + stamp_count=card.stamp_count, + stamps_target=program.stamps_target, + stamps_until_reward=max(0, program.stamps_target - card.stamp_count), + total_stamps_earned=card.total_stamps_earned, + stamps_redeemed=card.stamps_redeemed, + points_balance=card.points_balance, + total_points_earned=card.total_points_earned, + points_redeemed=card.points_redeemed, + is_active=card.is_active, + created_at=card.created_at, + has_google_wallet=bool(card.google_object_id), + has_apple_wallet=bool(card.apple_serial_number), + ) + card_responses.append(response) + + return CardListResponse(cards=card_responses, total=total) + + +@router.get("/cards/{card_id}", response_model=CardDetailResponse) +def get_card_detail( + card_id: int = Path(..., gt=0), + merchant: Merchant = Depends(get_merchant_for_current_user), + db: Session = Depends(get_db), +): + """Get detailed loyalty card info by ID.""" + card = card_service.get_card(db, card_id) + if not card or card.merchant_id != merchant.id: + raise LoyaltyCardNotFoundException(str(card_id)) + + program = card.program + customer = card.customer + + return CardDetailResponse( + id=card.id, + card_number=card.card_number, + customer_id=card.customer_id, + merchant_id=card.merchant_id, + program_id=card.program_id, + enrolled_at_store_id=card.enrolled_at_store_id, + customer_name=customer.full_name if customer else None, + customer_email=customer.email if customer else None, + merchant_name=card.merchant.name if card.merchant else None, + qr_code_data=card.qr_code_data or card.card_number, + program_name=program.display_name, + program_type=program.loyalty_type, + reward_description=program.stamps_reward_description, + stamp_count=card.stamp_count, + stamps_target=program.stamps_target, + stamps_until_reward=max(0, program.stamps_target - card.stamp_count), + total_stamps_earned=card.total_stamps_earned, + stamps_redeemed=card.stamps_redeemed, + points_balance=card.points_balance, + total_points_earned=card.total_points_earned, + points_redeemed=card.points_redeemed, + is_active=card.is_active, + created_at=card.created_at, + last_stamp_at=card.last_stamp_at, + last_points_at=card.last_points_at, + last_redemption_at=card.last_redemption_at, + last_activity_at=card.last_activity_at, + has_google_wallet=bool(card.google_object_id), + has_apple_wallet=bool(card.apple_serial_number), + ) + + +@router.get("/cards/{card_id}/transactions", response_model=TransactionListResponse) +def get_card_transactions( + card_id: int = Path(..., gt=0), + skip: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=100), + merchant: Merchant = Depends(get_merchant_for_current_user), + db: Session = Depends(get_db), +): + """Get transaction history for a card.""" + card = card_service.get_card(db, card_id) + if not card or card.merchant_id != merchant.id: + raise LoyaltyCardNotFoundException(str(card_id)) + + transactions, total = card_service.get_card_transactions( + db, card_id, skip=skip, limit=limit + ) + + return TransactionListResponse( + transactions=[TransactionResponse.model_validate(t) for t in transactions], + total=total, + ) + + +# ============================================================================= +# Transactions +# ============================================================================= + + +@router.get("/transactions", response_model=TransactionListResponse) +def list_transactions( + skip: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=100), + store_id: int | None = Query(None), + merchant: Merchant = Depends(get_merchant_for_current_user), + db: Session = Depends(get_db), +): + """Merchant-wide transaction feed.""" + transactions, total = card_service.get_store_transactions( + db, merchant.id, store_id=store_id, skip=skip, limit=limit + ) + + tx_responses = [] + for t in transactions: + tx = TransactionResponse.model_validate(t) + if t.card and t.card.customer: + tx.customer_name = t.card.customer.full_name + tx_responses.append(tx) + + return TransactionListResponse(transactions=tx_responses, total=total) + + +# ============================================================================= +# Staff PINs +# ============================================================================= + + +def _validate_pin_ownership(db: Session, pin_id: int, merchant_id: int): + """Validate that a PIN belongs to the merchant. Returns the pin.""" + pin = pin_service.require_pin(db, pin_id) + if pin.merchant_id != merchant_id: + raise StaffPinNotFoundException(str(pin_id)) + return pin + + +@router.get("/pins", response_model=PinDetailListResponse) +def list_pins( + store_id: int | None = Query(None), + is_active: bool | None = Query(None), + merchant: Merchant = Depends(get_merchant_for_current_user), + db: Session = Depends(get_db), +): + """List PINs across all stores for the merchant.""" + pins = pin_service.list_pins_for_merchant( + db, merchant.id, store_id=store_id, is_active=is_active + ) + + pin_responses = [] + for pin in pins: + resp = PinDetailResponse.model_validate(pin) + resp.store_id = pin.store_id + resp.store_name = pin.store.name if pin.store else None + pin_responses.append(resp) + + return PinDetailListResponse(pins=pin_responses, total=len(pin_responses)) + + +@router.post("/pins", response_model=PinResponse, status_code=201) +def create_pin( + data: PinCreate, + store_id: int = Query(..., gt=0), + merchant: Merchant = Depends(get_merchant_for_current_user), + db: Session = Depends(get_db), +): + """Create a new staff PIN.""" + # Validate store belongs to merchant + locations = program_service.get_merchant_locations(db, merchant.id) + store_ids = [loc.id for loc in locations] + if store_id not in store_ids: + from app.modules.tenancy.exceptions import StoreNotFoundException + + raise StoreNotFoundException(str(store_id), identifier_type="id") + + program = program_service.require_program_by_merchant(db, merchant.id) + pin = pin_service.create_pin(db, program.id, store_id, data) + return PinResponse.model_validate(pin) + + +@router.patch("/pins/{pin_id}", response_model=PinResponse) +def update_pin( + data: PinUpdate, + pin_id: int = Path(..., gt=0), + merchant: Merchant = Depends(get_merchant_for_current_user), + db: Session = Depends(get_db), +): + """Update a staff PIN.""" + _validate_pin_ownership(db, pin_id, merchant.id) + pin = pin_service.update_pin(db, pin_id, data) + return PinResponse.model_validate(pin) + + +@router.delete("/pins/{pin_id}", status_code=204) +def delete_pin( + pin_id: int = Path(..., gt=0), + merchant: Merchant = Depends(get_merchant_for_current_user), + db: Session = Depends(get_db), +): + """Delete a staff PIN.""" + _validate_pin_ownership(db, pin_id, merchant.id) + pin_service.delete_pin(db, pin_id) + + +@router.post("/pins/{pin_id}/unlock", response_model=PinResponse) +def unlock_pin( + pin_id: int = Path(..., gt=0), + merchant: Merchant = Depends(get_merchant_for_current_user), + db: Session = Depends(get_db), +): + """Unlock a locked staff PIN.""" + _validate_pin_ownership(db, pin_id, merchant.id) + pin = pin_service.unlock_pin(db, pin_id) + return PinResponse.model_validate(pin) + + +# ============================================================================= +# Settings +# ============================================================================= + + +@router.get("/settings", response_model=MerchantSettingsResponse) +def get_settings( + merchant: Merchant = Depends(get_merchant_for_current_user), + db: Session = Depends(get_db), +): + """Get read-only merchant loyalty settings.""" + settings = program_service.get_or_create_merchant_settings(db, merchant.id) + return MerchantSettingsResponse.model_validate(settings) + + +# ============================================================================= +# Locations +# ============================================================================= + + +@router.get("/locations") +def list_locations( + merchant: Merchant = Depends(get_merchant_for_current_user), + db: Session = Depends(get_db), +): + """List merchant stores (for filter dropdowns).""" + locations = program_service.get_merchant_locations(db, merchant.id) + return [ + {"id": loc.id, "name": loc.name, "code": loc.store_code} + for loc in locations + ] diff --git a/app/modules/loyalty/routes/pages/admin.py b/app/modules/loyalty/routes/pages/admin.py index e16f2b7c..4844e36d 100644 --- a/app/modules/loyalty/routes/pages/admin.py +++ b/app/modules/loyalty/routes/pages/admin.py @@ -124,6 +124,91 @@ async def admin_loyalty_program_edit( ) +@router.get( + "/merchants/{merchant_id}/cards", + response_class=HTMLResponse, + include_in_schema=False, +) +async def admin_loyalty_merchant_cards( + request: Request, + merchant_id: int = Path(..., description="Merchant ID"), + current_user: User = Depends(require_menu_access("loyalty-programs", FrontendType.ADMIN)), + db: Session = Depends(get_db), +): + """ + Render merchant loyalty cards list page. + Shows all loyalty cards for a specific merchant. + """ + return templates.TemplateResponse( + "loyalty/admin/merchant-cards.html", + get_admin_context(request, db, current_user, merchant_id=merchant_id), + ) + + +@router.get( + "/merchants/{merchant_id}/cards/{card_id}", + response_class=HTMLResponse, + include_in_schema=False, +) +async def admin_loyalty_merchant_card_detail( + request: Request, + merchant_id: int = Path(..., description="Merchant ID"), + card_id: int = Path(..., description="Card ID"), + current_user: User = Depends(require_menu_access("loyalty-programs", FrontendType.ADMIN)), + db: Session = Depends(get_db), +): + """ + Render merchant loyalty card detail page. + Shows detailed info for a specific loyalty card. + """ + return templates.TemplateResponse( + "loyalty/admin/merchant-card-detail.html", + get_admin_context(request, db, current_user, merchant_id=merchant_id, card_id=card_id), + ) + + +@router.get( + "/merchants/{merchant_id}/transactions", + response_class=HTMLResponse, + include_in_schema=False, +) +async def admin_loyalty_merchant_transactions( + request: Request, + merchant_id: int = Path(..., description="Merchant ID"), + current_user: User = Depends(require_menu_access("loyalty-programs", FrontendType.ADMIN)), + db: Session = Depends(get_db), +): + """ + Render merchant loyalty transactions list page. + Shows all loyalty transactions for a specific merchant. + """ + return templates.TemplateResponse( + "loyalty/admin/merchant-transactions.html", + get_admin_context(request, db, current_user, merchant_id=merchant_id), + ) + + +@router.get( + "/merchants/{merchant_id}/pins", + response_class=HTMLResponse, + include_in_schema=False, +) +async def admin_loyalty_merchant_pins( + request: Request, + merchant_id: int = Path(..., description="Merchant ID"), + current_user: User = Depends(require_menu_access("loyalty-programs", FrontendType.ADMIN)), + db: Session = Depends(get_db), +): + """ + Render merchant staff PINs page (read-only). + Shows all staff PINs for a specific merchant. + """ + return templates.TemplateResponse( + "loyalty/admin/merchant-pins.html", + get_admin_context(request, db, current_user, merchant_id=merchant_id), + ) + + @router.get( "/merchants/{merchant_id}/settings", response_class=HTMLResponse, diff --git a/app/modules/loyalty/routes/pages/merchant.py b/app/modules/loyalty/routes/pages/merchant.py index 25a59267..f02493a7 100644 --- a/app/modules/loyalty/routes/pages/merchant.py +++ b/app/modules/loyalty/routes/pages/merchant.py @@ -157,3 +157,125 @@ async def merchant_loyalty_analytics( "loyalty/merchant/analytics.html", context, ) + + +# ============================================================================ +# LOYALTY CARDS +# ============================================================================ + + +@router.get("/cards", response_class=HTMLResponse, include_in_schema=False) +async def merchant_loyalty_cards( + 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 cards list page.""" + context = _get_merchant_context( + request, + db, + current_user, + merchant_id=merchant.id, + ) + return templates.TemplateResponse( + "loyalty/merchant/cards.html", + context, + ) + + +@router.get("/cards/{card_id}", response_class=HTMLResponse, include_in_schema=False) +async def merchant_loyalty_card_detail( + request: Request, + card_id: int, + 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 card detail page.""" + context = _get_merchant_context( + request, + db, + current_user, + merchant_id=merchant.id, + card_id=card_id, + ) + return templates.TemplateResponse( + "loyalty/merchant/card-detail.html", + context, + ) + + +# ============================================================================ +# LOYALTY TRANSACTIONS +# ============================================================================ + + +@router.get("/transactions", response_class=HTMLResponse, include_in_schema=False) +async def merchant_loyalty_transactions( + 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 transactions page.""" + context = _get_merchant_context( + request, + db, + current_user, + merchant_id=merchant.id, + ) + return templates.TemplateResponse( + "loyalty/merchant/transactions.html", + context, + ) + + +# ============================================================================ +# LOYALTY STAFF PINS +# ============================================================================ + + +@router.get("/pins", response_class=HTMLResponse, include_in_schema=False) +async def merchant_loyalty_pins( + 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 staff PINs page.""" + context = _get_merchant_context( + request, + db, + current_user, + merchant_id=merchant.id, + ) + return templates.TemplateResponse( + "loyalty/merchant/pins.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 (read-only).""" + 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 0010e7b2..c5a0c99a 100644 --- a/app/modules/loyalty/routes/pages/store.py +++ b/app/modules/loyalty/routes/pages/store.py @@ -216,6 +216,32 @@ async def store_loyalty_analytics( ) +# ============================================================================ +# STAFF PINS +# ============================================================================ + + +@router.get( + "/loyalty/pins", + response_class=HTMLResponse, + include_in_schema=False, +) +async def store_loyalty_pins( + request: Request, + store_code: str = Depends(get_resolved_store_code), + current_user: User = Depends(get_current_store_from_cookie_or_header), + db: Session = Depends(get_db), +): + """ + Render staff PINs management page. + Allows store staff to manage staff PINs for loyalty transactions. + """ + return templates.TemplateResponse( + "loyalty/store/pins.html", + get_store_context(request, db, current_user, store_code), + ) + + # ============================================================================ # ENROLLMENT # ============================================================================ diff --git a/app/modules/loyalty/schemas/__init__.py b/app/modules/loyalty/schemas/__init__.py index 71f8a7d5..193a0192 100644 --- a/app/modules/loyalty/schemas/__init__.py +++ b/app/modules/loyalty/schemas/__init__.py @@ -39,6 +39,8 @@ from app.modules.loyalty.schemas.card import ( from app.modules.loyalty.schemas.pin import ( # Staff PIN PinCreate, + PinDetailListResponse, + PinDetailResponse, PinListResponse, PinResponse, PinUpdate, @@ -122,7 +124,9 @@ __all__ = [ "PinCreate", "PinUpdate", "PinResponse", + "PinDetailResponse", "PinListResponse", + "PinDetailListResponse", "PinVerifyRequest", "PinVerifyResponse", ] diff --git a/app/modules/loyalty/schemas/pin.py b/app/modules/loyalty/schemas/pin.py index 6b612e09..87a8b2e7 100644 --- a/app/modules/loyalty/schemas/pin.py +++ b/app/modules/loyalty/schemas/pin.py @@ -77,6 +77,20 @@ class PinListResponse(BaseModel): total: int +class PinDetailResponse(PinResponse): + """Extended PIN response including store context for cross-store listings.""" + + store_id: int | None = None + store_name: str | None = None + + +class PinDetailListResponse(BaseModel): + """Schema for listing staff PINs with store context.""" + + pins: list[PinDetailResponse] + total: int + + class PinVerifyRequest(BaseModel): """Schema for verifying a staff PIN.""" diff --git a/app/modules/loyalty/static/admin/js/loyalty-merchant-card-detail.js b/app/modules/loyalty/static/admin/js/loyalty-merchant-card-detail.js new file mode 100644 index 00000000..70f9c1b5 --- /dev/null +++ b/app/modules/loyalty/static/admin/js/loyalty-merchant-card-detail.js @@ -0,0 +1,22 @@ +// app/modules/loyalty/static/admin/js/loyalty-merchant-card-detail.js +// Admin wrapper for shared loyalty card detail view (on-behalf view). + +const adminMerchantCardDetailLog = window.LogConfig.loggers.adminMerchantCardDetail || window.LogConfig.createLogger('adminMerchantCardDetail'); + +function adminMerchantCardDetail() { + // Extract merchant_id and card_id from URL: /admin/loyalty/merchants/{merchant_id}/cards/{card_id} + const pathParts = window.location.pathname.split('/'); + const merchantsIndex = pathParts.indexOf('merchants'); + const merchantId = merchantsIndex !== -1 ? pathParts[merchantsIndex + 1] : null; + + return loyaltyCardDetailView({ + apiPrefix: '/admin/loyalty/merchants/' + merchantId, + backUrl: '/admin/loyalty/merchants/' + merchantId + '/cards', + currentPage: 'loyalty-programs', + }); +} + +if (!window.LogConfig.loggers.adminMerchantCardDetail) { + window.LogConfig.loggers.adminMerchantCardDetail = window.LogConfig.createLogger('adminMerchantCardDetail'); +} +adminMerchantCardDetailLog.info('Admin merchant card detail module loaded'); diff --git a/app/modules/loyalty/static/admin/js/loyalty-merchant-cards.js b/app/modules/loyalty/static/admin/js/loyalty-merchant-cards.js new file mode 100644 index 00000000..bde43332 --- /dev/null +++ b/app/modules/loyalty/static/admin/js/loyalty-merchant-cards.js @@ -0,0 +1,23 @@ +// app/modules/loyalty/static/admin/js/loyalty-merchant-cards.js +// Admin wrapper for shared loyalty cards list (on-behalf view). + +const adminMerchantCardsLog = window.LogConfig.loggers.adminMerchantCards || window.LogConfig.createLogger('adminMerchantCards'); + +function adminMerchantCards() { + // Extract merchant_id from URL: /admin/loyalty/merchants/{merchant_id}/cards + const pathParts = window.location.pathname.split('/'); + const merchantsIndex = pathParts.indexOf('merchants'); + const merchantId = merchantsIndex !== -1 ? pathParts[merchantsIndex + 1] : null; + + return loyaltyCardsList({ + apiPrefix: '/admin/loyalty/merchants/' + merchantId, + baseUrl: '/admin/loyalty/merchants/' + merchantId + '/cards', + showStoreFilter: true, + currentPage: 'loyalty-programs', + }); +} + +if (!window.LogConfig.loggers.adminMerchantCards) { + window.LogConfig.loggers.adminMerchantCards = window.LogConfig.createLogger('adminMerchantCards'); +} +adminMerchantCardsLog.info('Admin merchant cards module loaded'); diff --git a/app/modules/loyalty/static/admin/js/loyalty-merchant-pins.js b/app/modules/loyalty/static/admin/js/loyalty-merchant-pins.js new file mode 100644 index 00000000..8acd0d2c --- /dev/null +++ b/app/modules/loyalty/static/admin/js/loyalty-merchant-pins.js @@ -0,0 +1,23 @@ +// app/modules/loyalty/static/admin/js/loyalty-merchant-pins.js +// Admin wrapper for shared loyalty PINs list (on-behalf read-only view). + +const adminMerchantPinsLog = window.LogConfig.loggers.adminMerchantPins || window.LogConfig.createLogger('adminMerchantPins'); + +function adminMerchantPins() { + // Extract merchant_id from URL: /admin/loyalty/merchants/{merchant_id}/pins + const pathParts = window.location.pathname.split('/'); + const merchantsIndex = pathParts.indexOf('merchants'); + const merchantId = merchantsIndex !== -1 ? pathParts[merchantsIndex + 1] : null; + + return loyaltyPinsList({ + apiPrefix: '/admin/loyalty/merchants/' + merchantId, + showStoreFilter: true, + showCrud: false, + currentPage: 'loyalty-programs', + }); +} + +if (!window.LogConfig.loggers.adminMerchantPins) { + window.LogConfig.loggers.adminMerchantPins = window.LogConfig.createLogger('adminMerchantPins'); +} +adminMerchantPinsLog.info('Admin merchant pins module loaded'); diff --git a/app/modules/loyalty/static/admin/js/loyalty-merchant-transactions.js b/app/modules/loyalty/static/admin/js/loyalty-merchant-transactions.js new file mode 100644 index 00000000..3aaf4a30 --- /dev/null +++ b/app/modules/loyalty/static/admin/js/loyalty-merchant-transactions.js @@ -0,0 +1,22 @@ +// app/modules/loyalty/static/admin/js/loyalty-merchant-transactions.js +// Admin wrapper for shared loyalty transactions list (on-behalf view). + +const adminMerchantTransactionsLog = window.LogConfig.loggers.adminMerchantTransactions || window.LogConfig.createLogger('adminMerchantTransactions'); + +function adminMerchantTransactions() { + // Extract merchant_id from URL: /admin/loyalty/merchants/{merchant_id}/transactions + const pathParts = window.location.pathname.split('/'); + const merchantsIndex = pathParts.indexOf('merchants'); + const merchantId = merchantsIndex !== -1 ? pathParts[merchantsIndex + 1] : null; + + return loyaltyTransactionsList({ + apiPrefix: '/admin/loyalty/merchants/' + merchantId, + showStoreFilter: true, + currentPage: 'loyalty-programs', + }); +} + +if (!window.LogConfig.loggers.adminMerchantTransactions) { + window.LogConfig.loggers.adminMerchantTransactions = window.LogConfig.createLogger('adminMerchantTransactions'); +} +adminMerchantTransactionsLog.info('Admin merchant transactions module loaded'); diff --git a/app/modules/loyalty/static/merchant/js/loyalty-card-detail.js b/app/modules/loyalty/static/merchant/js/loyalty-card-detail.js new file mode 100644 index 00000000..9a33940a --- /dev/null +++ b/app/modules/loyalty/static/merchant/js/loyalty-card-detail.js @@ -0,0 +1,17 @@ +// app/modules/loyalty/static/merchant/js/loyalty-card-detail.js +// noqa: js-006 - async init pattern is safe, loadData has try/catch + +const merchantCardDetailLog = window.LogConfig.loggers.merchantCardDetail || window.LogConfig.createLogger('merchantCardDetail'); + +function merchantLoyaltyCardDetail() { + return loyaltyCardDetailView({ + apiPrefix: '/merchants/loyalty', + backUrl: '/merchants/loyalty/cards', + currentPage: 'loyalty-cards', + }); +} + +if (!window.LogConfig.loggers.merchantCardDetail) { + window.LogConfig.loggers.merchantCardDetail = window.LogConfig.createLogger('merchantCardDetail'); +} +merchantCardDetailLog.info('Merchant loyalty card detail module loaded'); diff --git a/app/modules/loyalty/static/merchant/js/loyalty-cards.js b/app/modules/loyalty/static/merchant/js/loyalty-cards.js new file mode 100644 index 00000000..88dff441 --- /dev/null +++ b/app/modules/loyalty/static/merchant/js/loyalty-cards.js @@ -0,0 +1,18 @@ +// app/modules/loyalty/static/merchant/js/loyalty-cards.js +// noqa: js-006 - async init pattern is safe, loadData has try/catch + +const merchantCardsLog = window.LogConfig.loggers.merchantCards || window.LogConfig.createLogger('merchantCards'); + +function merchantLoyaltyCards() { + return loyaltyCardsList({ + apiPrefix: '/merchants/loyalty', + baseUrl: '/merchants/loyalty/cards', + showStoreFilter: true, + currentPage: 'loyalty-cards', + }); +} + +if (!window.LogConfig.loggers.merchantCards) { + window.LogConfig.loggers.merchantCards = window.LogConfig.createLogger('merchantCards'); +} +merchantCardsLog.info('Merchant loyalty cards module loaded'); diff --git a/app/modules/loyalty/static/merchant/js/loyalty-merchant-settings.js b/app/modules/loyalty/static/merchant/js/loyalty-merchant-settings.js new file mode 100644 index 00000000..adb7b4db --- /dev/null +++ b/app/modules/loyalty/static/merchant/js/loyalty-merchant-settings.js @@ -0,0 +1,53 @@ +// app/modules/loyalty/static/merchant/js/loyalty-merchant-settings.js +// noqa: js-006 - async init pattern is safe, loadData has try/catch + +const merchantSettingsViewLog = window.LogConfig.loggers.merchantSettingsView || window.LogConfig.createLogger('merchantSettingsView'); + +function merchantLoyaltyMerchantSettings() { + return { + ...data(), + currentPage: 'loyalty-settings', + + settings: null, + loading: false, + error: null, + + async init() { + merchantSettingsViewLog.info('=== MERCHANT LOYALTY SETTINGS VIEW PAGE INITIALIZING ==='); + if (window._merchantLoyaltyMerchantSettingsInitialized) return; + window._merchantLoyaltyMerchantSettingsInitialized = true; + + this.loadMenuConfig(); + await this.loadSettings(); + merchantSettingsViewLog.info('=== MERCHANT LOYALTY SETTINGS VIEW PAGE INITIALIZATION COMPLETE ==='); + }, + + async loadSettings() { + this.loading = true; + this.error = null; + + try { + const response = await apiClient.get('/merchants/loyalty/settings'); + if (response) { + this.settings = response; + merchantSettingsViewLog.info('Settings loaded'); + } + } catch (error) { + if (error.status === 404) { + merchantSettingsViewLog.info('No settings found'); + this.settings = null; + } else { + merchantSettingsViewLog.error('Failed to load settings:', error); + this.error = error.message || 'Failed to load settings'; + } + } finally { + this.loading = false; + } + }, + }; +} + +if (!window.LogConfig.loggers.merchantSettingsView) { + window.LogConfig.loggers.merchantSettingsView = window.LogConfig.createLogger('merchantSettingsView'); +} +merchantSettingsViewLog.info('Merchant loyalty settings view module loaded'); diff --git a/app/modules/loyalty/static/merchant/js/loyalty-pins.js b/app/modules/loyalty/static/merchant/js/loyalty-pins.js new file mode 100644 index 00000000..2765ec7b --- /dev/null +++ b/app/modules/loyalty/static/merchant/js/loyalty-pins.js @@ -0,0 +1,18 @@ +// app/modules/loyalty/static/merchant/js/loyalty-pins.js +// noqa: js-006 - async init pattern is safe, loadData has try/catch + +const merchantPinsLog = window.LogConfig.loggers.merchantPins || window.LogConfig.createLogger('merchantPins'); + +function merchantLoyaltyPins() { + return loyaltyPinsList({ + apiPrefix: '/merchants/loyalty', + showStoreFilter: true, + showCrud: true, + currentPage: 'loyalty-pins', + }); +} + +if (!window.LogConfig.loggers.merchantPins) { + window.LogConfig.loggers.merchantPins = window.LogConfig.createLogger('merchantPins'); +} +merchantPinsLog.info('Merchant loyalty pins module loaded'); diff --git a/app/modules/loyalty/static/merchant/js/loyalty-transactions.js b/app/modules/loyalty/static/merchant/js/loyalty-transactions.js new file mode 100644 index 00000000..79df0f03 --- /dev/null +++ b/app/modules/loyalty/static/merchant/js/loyalty-transactions.js @@ -0,0 +1,17 @@ +// app/modules/loyalty/static/merchant/js/loyalty-transactions.js +// noqa: js-006 - async init pattern is safe, loadData has try/catch + +const merchantTransactionsLog = window.LogConfig.loggers.merchantTransactions || window.LogConfig.createLogger('merchantTransactions'); + +function merchantLoyaltyTransactions() { + return loyaltyTransactionsList({ + apiPrefix: '/merchants/loyalty', + showStoreFilter: true, + currentPage: 'loyalty-transactions', + }); +} + +if (!window.LogConfig.loggers.merchantTransactions) { + window.LogConfig.loggers.merchantTransactions = window.LogConfig.createLogger('merchantTransactions'); +} +merchantTransactionsLog.info('Merchant loyalty transactions module loaded'); diff --git a/app/modules/loyalty/static/shared/js/loyalty-card-detail-view.js b/app/modules/loyalty/static/shared/js/loyalty-card-detail-view.js new file mode 100644 index 00000000..92c72fe5 --- /dev/null +++ b/app/modules/loyalty/static/shared/js/loyalty-card-detail-view.js @@ -0,0 +1,156 @@ +// app/modules/loyalty/static/shared/js/loyalty-card-detail-view.js +// Shared Alpine.js data factory for loyalty card detail pages. +// Used by store, merchant, and admin frontends. + +const loyaltyCardDetailViewLog = window.LogConfig.loggers.loyaltyCardDetailView || window.LogConfig.createLogger('loyaltyCardDetailView'); + +/** + * Factory that returns an Alpine.js data object for a single loyalty card detail view. + * + * @param {Object} config + * @param {string} config.apiPrefix - API path prefix, e.g. '/store/loyalty' + * @param {string} config.backUrl - URL for the "back to list" navigation + * @param {string} config.currentPage - Alpine currentPage identifier + */ +function loyaltyCardDetailView(config) { + const guardKey = '_loyaltyCardDetailView_' + config.currentPage + '_initialized'; + + return { + ...data(), + currentPage: config.currentPage, + + // Data + cardId: null, + card: null, + transactions: [], + + // State + loading: false, + error: null, + + // Config (exposed for templates) + _config: config, + + async init() { + loyaltyCardDetailViewLog.info('=== LOYALTY CARD DETAIL VIEW INITIALIZING ===', config.currentPage); + if (window[guardKey]) return; + window[guardKey] = true; + + const parentInit = data().init; + if (parentInit) { + await parentInit.call(this); + } + + // Extract cardId from URL: find 'cards' segment and take the next segment + this.cardId = this._extractCardIdFromUrl(); + if (!this.cardId) { + this.error = I18n.t('loyalty.errors.card_not_found'); + loyaltyCardDetailViewLog.error('Could not extract card ID from URL'); + return; + } + + loyaltyCardDetailViewLog.info('Card ID extracted:', this.cardId); + await this.loadData(); + loyaltyCardDetailViewLog.info('=== LOYALTY CARD DETAIL VIEW INITIALIZATION COMPLETE ==='); + }, + + /** + * Extract the card ID from the current URL path. + * Looks for the 'cards' segment and returns the segment immediately after it. + * e.g. /store/ORION/loyalty/cards/abc123 -> 'abc123' + */ + _extractCardIdFromUrl() { + try { + const segments = window.location.pathname.split('/').filter(Boolean); + const cardsIndex = segments.indexOf('cards'); + if (cardsIndex !== -1 && cardsIndex + 1 < segments.length) { + return segments[cardsIndex + 1]; + } + } catch (error) { + loyaltyCardDetailViewLog.error('Error parsing URL for card ID:', error); + } + return null; + }, + + async loadData() { + this.loading = true; + this.error = null; + try { + await Promise.all([ + this.loadCard(), + this.loadTransactions() + ]); + } catch (error) { + loyaltyCardDetailViewLog.error('Failed to load card data:', error); + this.error = error.message; + } finally { + this.loading = false; + } + }, + + async loadCard() { + try { + const response = await apiClient.get(config.apiPrefix + '/cards/' + this.cardId); + if (response) { + this.card = response; + } + } catch (error) { + loyaltyCardDetailViewLog.error('Failed to load card:', error); + throw error; + } + }, + + async loadTransactions() { + try { + const response = await apiClient.get( + config.apiPrefix + '/cards/' + this.cardId + '/transactions?limit=50' + ); + if (response) { + this.transactions = Array.isArray(response) + ? response + : (response.transactions || []); + } + } catch (error) { + loyaltyCardDetailViewLog.warn('Failed to load transactions:', error.message); + } + }, + + // Formatting helpers + formatNumber(num) { + return num == null ? '0' : new Intl.NumberFormat('en-US').format(num); + }, + + formatDate(dateString) { + if (!dateString) return 'Never'; + try { + return new Date(dateString).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric' + }); + } catch (e) { + return dateString; + } + }, + + formatDateTime(dateString) { + if (!dateString) return 'Never'; + try { + return new Date(dateString).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + } catch (e) { + return dateString; + } + } + }; +} + +if (!window.LogConfig.loggers.loyaltyCardDetailView) { + window.LogConfig.loggers.loyaltyCardDetailView = window.LogConfig.createLogger('loyaltyCardDetailView'); +} +loyaltyCardDetailViewLog.info('Loyalty card detail view shared module loaded'); diff --git a/app/modules/loyalty/static/shared/js/loyalty-cards-list.js b/app/modules/loyalty/static/shared/js/loyalty-cards-list.js new file mode 100644 index 00000000..8454a9c2 --- /dev/null +++ b/app/modules/loyalty/static/shared/js/loyalty-cards-list.js @@ -0,0 +1,247 @@ +// app/modules/loyalty/static/shared/js/loyalty-cards-list.js +// Shared Alpine.js data factory for loyalty cards listing pages. +// Used by store, merchant, and admin frontends. + +const loyaltyCardsListLog = window.LogConfig.loggers.loyaltyCardsList || window.LogConfig.createLogger('loyaltyCardsList'); + +/** + * Factory that returns an Alpine.js data object for loyalty card listing. + * + * @param {Object} config + * @param {string} config.apiPrefix - API path prefix, e.g. '/store/loyalty' + * @param {string} config.baseUrl - Base URL for card detail links, e.g. '/store/ORION/loyalty/cards' + * @param {boolean} config.showStoreFilter - Whether to show the store/location dropdown filter + * @param {string} config.currentPage - Alpine currentPage identifier + */ +function loyaltyCardsList(config) { + const guardKey = '_loyaltyCardsList_' + config.currentPage + '_initialized'; + + return { + ...data(), + currentPage: config.currentPage, + + // Data + cards: [], + program: null, + stats: { + total_cards: 0, + active_cards: 0, + new_this_month: 0, + total_points_balance: 0 + }, + locations: [], + + // Filters + filters: { + search: '', + status: '', + store_id: '' + }, + + // Pagination + pagination: { + page: 1, + per_page: 20, + total: 0, + pages: 0 + }, + + // State + loading: false, + error: null, + + // Config (exposed for templates) + _config: config, + + async init() { + loyaltyCardsListLog.info('=== LOYALTY CARDS LIST INITIALIZING ===', config.currentPage); + if (window[guardKey]) return; + window[guardKey] = true; + + const parentInit = data().init; + if (parentInit) { + await parentInit.call(this); + } + + if (window.PlatformSettings) { + try { + this.pagination.per_page = await window.PlatformSettings.getRowsPerPage(); + } catch (error) { + loyaltyCardsListLog.warn('Failed to get rows per page setting:', error.message); + } + } + + await this.loadData(); + loyaltyCardsListLog.info('=== LOYALTY CARDS LIST INITIALIZATION COMPLETE ==='); + }, + + async loadData() { + this.loading = true; + this.error = null; + try { + await this.loadProgram(); + if (this.program) { + const parallel = [this.loadCards(), this.loadStats()]; + if (config.showStoreFilter) { + parallel.push(this.loadLocations()); + } + await Promise.all(parallel); + } + } catch (error) { + loyaltyCardsListLog.error('Failed to load data:', error); + this.error = error.message; + } finally { + this.loading = false; + } + }, + + async loadProgram() { + try { + const response = await apiClient.get(config.apiPrefix + '/program'); + if (response) this.program = response; + } catch (error) { + if (error.status !== 404) throw error; + } + }, + + async loadCards() { + try { + const params = new URLSearchParams(); + params.append('skip', (this.pagination.page - 1) * this.pagination.per_page); + params.append('limit', this.pagination.per_page); + if (this.filters.search) params.append('search', this.filters.search); + if (this.filters.status) params.append('is_active', this.filters.status === 'active'); + if (this.filters.store_id) params.append('store_id', this.filters.store_id); + + const response = await apiClient.get(config.apiPrefix + '/cards?' + params); + if (response) { + this.cards = response.cards || []; + this.pagination.total = response.total || 0; + this.pagination.pages = Math.ceil(this.pagination.total / this.pagination.per_page); + } + } catch (error) { + loyaltyCardsListLog.error('Failed to load cards:', error); + throw error; + } + }, + + async loadStats() { + try { + const response = await apiClient.get(config.apiPrefix + '/stats'); + if (response) { + this.stats = { + total_cards: response.total_cards || 0, + active_cards: response.active_cards || 0, + new_this_month: response.new_this_month || 0, + total_points_balance: response.total_points_balance || 0 + }; + } + } catch (error) { + loyaltyCardsListLog.warn('Failed to load stats:', error.message); + } + }, + + async loadLocations() { + try { + const response = await apiClient.get(config.apiPrefix + '/locations'); + if (response) { + this.locations = Array.isArray(response) ? response : (response.locations || []); + } + } catch (error) { + loyaltyCardsListLog.warn('Failed to load locations:', error.message); + } + }, + + debouncedSearch() { + if (this._searchTimeout) clearTimeout(this._searchTimeout); + this._searchTimeout = setTimeout(() => { + this.pagination.page = 1; + this.loadCards(); + }, 300); + }, + + applyFilter() { + this.pagination.page = 1; + this.loadCards(); + }, + + // Pagination helpers + get totalPages() { + return this.pagination.pages; + }, + + get startIndex() { + return this.pagination.total === 0 + ? 0 + : (this.pagination.page - 1) * this.pagination.per_page + 1; + }, + + get endIndex() { + const end = this.pagination.page * this.pagination.per_page; + return end > this.pagination.total ? this.pagination.total : end; + }, + + get pageNumbers() { + const pages = []; + const total = this.totalPages; + const current = this.pagination.page; + + if (total <= 7) { + for (let i = 1; i <= total; i++) pages.push(i); + } else { + pages.push(1); + if (current > 3) pages.push('...'); + const start = Math.max(2, current - 1); + const end = Math.min(total - 1, current + 1); + for (let i = start; i <= end; i++) pages.push(i); + if (current < total - 2) pages.push('...'); + pages.push(total); + } + return pages; + }, + + previousPage() { + if (this.pagination.page > 1) { + this.pagination.page--; + this.loadCards(); + } + }, + + nextPage() { + if (this.pagination.page < this.totalPages) { + this.pagination.page++; + this.loadCards(); + } + }, + + goToPage(num) { + if (num !== '...' && num >= 1 && num <= this.totalPages) { + this.pagination.page = num; + this.loadCards(); + } + }, + + // Formatting helpers + formatNumber(num) { + return num == null ? '0' : new Intl.NumberFormat('en-US').format(num); + }, + + formatDate(dateString) { + if (!dateString) return 'Never'; + try { + return new Date(dateString).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric' + }); + } catch (e) { + return dateString; + } + } + }; +} + +if (!window.LogConfig.loggers.loyaltyCardsList) { + window.LogConfig.loggers.loyaltyCardsList = window.LogConfig.createLogger('loyaltyCardsList'); +} +loyaltyCardsListLog.info('Loyalty cards list shared module loaded'); diff --git a/app/modules/loyalty/static/shared/js/loyalty-pins-list.js b/app/modules/loyalty/static/shared/js/loyalty-pins-list.js new file mode 100644 index 00000000..69aec4c5 --- /dev/null +++ b/app/modules/loyalty/static/shared/js/loyalty-pins-list.js @@ -0,0 +1,283 @@ +// app/modules/loyalty/static/shared/js/loyalty-pins-list.js +// Shared Alpine.js data factory for loyalty staff PIN listing pages. +// Used by store, merchant, and admin frontends. + +const loyaltyPinsListLog = window.LogConfig.loggers.loyaltyPinsList || window.LogConfig.createLogger('loyaltyPinsList'); + +/** + * Factory that returns an Alpine.js data object for loyalty PIN management. + * + * @param {Object} config + * @param {string} config.apiPrefix - API path prefix, e.g. '/store/loyalty' + * @param {boolean} config.showStoreFilter - Whether to show the store/location dropdown filter + * @param {boolean} config.showCrud - Whether to show create/edit/delete UI + * @param {string} config.currentPage - Alpine currentPage identifier + */ +function loyaltyPinsList(config) { + const guardKey = '_loyaltyPinsList_' + config.currentPage + '_initialized'; + + return { + ...data(), + currentPage: config.currentPage, + + // Data + pins: [], + program: null, + locations: [], + + // Filters + filters: { + store_id: '', + status: '' + }, + + // Stats (computed from pins array) + stats: { + total: 0, + active: 0, + locked: 0 + }, + + // State + loading: false, + error: null, + + // Modal state + showCreateModal: false, + showEditModal: false, + showDeleteModal: false, + editingPin: null, + deletingPin: null, + + // Form state + pinForm: { + name: '', + staff_id: '', + pin: '', + store_id: '' + }, + + // Action state + saving: false, + deleting: false, + + // Config (exposed for templates) + _config: config, + + async init() { + loyaltyPinsListLog.info('=== LOYALTY PINS LIST INITIALIZING ===', config.currentPage); + if (window[guardKey]) return; + window[guardKey] = true; + + const parentInit = data().init; + if (parentInit) { + await parentInit.call(this); + } + + await this.loadData(); + loyaltyPinsListLog.info('=== LOYALTY PINS LIST INITIALIZATION COMPLETE ==='); + }, + + async loadData() { + this.loading = true; + this.error = null; + try { + await this.loadProgram(); + if (this.program) { + const parallel = [this.loadPins()]; + if (config.showStoreFilter) { + parallel.push(this.loadLocations()); + } + await Promise.all(parallel); + } + } catch (error) { + loyaltyPinsListLog.error('Failed to load data:', error); + this.error = error.message; + } finally { + this.loading = false; + } + }, + + async loadProgram() { + try { + const response = await apiClient.get(config.apiPrefix + '/program'); + if (response) this.program = response; + } catch (error) { + if (error.status !== 404) throw error; + } + }, + + async loadPins() { + try { + const params = new URLSearchParams(); + if (this.filters.store_id) params.append('store_id', this.filters.store_id); + if (this.filters.status) params.append('is_active', this.filters.status === 'active'); + + const queryString = params.toString(); + const url = config.apiPrefix + '/pins' + (queryString ? '?' + queryString : ''); + const response = await apiClient.get(url); + if (response) { + this.pins = Array.isArray(response) ? response : (response.pins || []); + this.computeStats(); + } + } catch (error) { + loyaltyPinsListLog.error('Failed to load pins:', error); + throw error; + } + }, + + async loadLocations() { + try { + const response = await apiClient.get(config.apiPrefix + '/locations'); + if (response) { + this.locations = Array.isArray(response) ? response : (response.locations || []); + } + } catch (error) { + loyaltyPinsListLog.warn('Failed to load locations:', error.message); + } + }, + + computeStats() { + this.stats.total = this.pins.length; + this.stats.active = this.pins.filter(p => p.is_active && !p.is_locked).length; + this.stats.locked = this.pins.filter(p => p.is_locked).length; + }, + + applyFilter() { + this.loadPins(); + }, + + // ---- CRUD operations (only used when config.showCrud is true) ---- + + openCreateModal() { + this.pinForm = { + name: '', + staff_id: '', + pin: '', + store_id: '' + }; + this.showCreateModal = true; + }, + + openEditModal(pin) { + this.editingPin = pin; + this.pinForm = { + name: pin.name || '', + staff_id: pin.staff_id || '', + pin: '', + store_id: pin.store_id || '' + }; + this.showEditModal = true; + }, + + openDeleteModal(pin) { + this.deletingPin = pin; + this.showDeleteModal = true; + }, + + async createPin() { + this.saving = true; + try { + const payload = { ...this.pinForm }; + if (!payload.store_id) delete payload.store_id; + + await apiClient.post(config.apiPrefix + '/pins', payload); + Utils.showToast(I18n.t('loyalty.toasts.pin_created'), 'success'); + this.showCreateModal = false; + await this.loadPins(); + } catch (error) { + loyaltyPinsListLog.error('Failed to create pin:', error); + Utils.showToast(error.message || I18n.t('loyalty.toasts.pin_create_error'), 'error'); + } finally { + this.saving = false; + } + }, + + async updatePin() { + if (!this.editingPin) return; + this.saving = true; + try { + const payload = { ...this.pinForm }; + // Only include pin field if a new pin was entered + if (!payload.pin) delete payload.pin; + if (!payload.store_id) delete payload.store_id; + + await apiClient.patch( + config.apiPrefix + '/pins/' + this.editingPin.id, + payload + ); + Utils.showToast(I18n.t('loyalty.toasts.pin_updated'), 'success'); + this.showEditModal = false; + this.editingPin = null; + await this.loadPins(); + } catch (error) { + loyaltyPinsListLog.error('Failed to update pin:', error); + Utils.showToast(error.message || I18n.t('loyalty.toasts.pin_update_error'), 'error'); + } finally { + this.saving = false; + } + }, + + async deletePin() { + if (!this.deletingPin) return; + this.deleting = true; + try { + await apiClient.delete(config.apiPrefix + '/pins/' + this.deletingPin.id); + Utils.showToast(I18n.t('loyalty.toasts.pin_deleted'), 'success'); + this.showDeleteModal = false; + this.deletingPin = null; + await this.loadPins(); + } catch (error) { + loyaltyPinsListLog.error('Failed to delete pin:', error); + Utils.showToast(error.message || I18n.t('loyalty.toasts.pin_delete_error'), 'error'); + } finally { + this.deleting = false; + } + }, + + async unlockPin(pin) { + try { + await apiClient.post(config.apiPrefix + '/pins/' + pin.id + '/unlock'); + Utils.showToast(I18n.t('loyalty.toasts.pin_unlocked'), 'success'); + await this.loadPins(); + } catch (error) { + loyaltyPinsListLog.error('Failed to unlock pin:', error); + Utils.showToast(error.message || I18n.t('loyalty.toasts.pin_unlock_error'), 'error'); + } + }, + + // Formatting helpers + formatDate(dateString) { + if (!dateString) return 'Never'; + try { + return new Date(dateString).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric' + }); + } catch (e) { + return dateString; + } + }, + + formatDateTime(dateString) { + if (!dateString) return 'Never'; + try { + return new Date(dateString).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + } catch (e) { + return dateString; + } + } + }; +} + +if (!window.LogConfig.loggers.loyaltyPinsList) { + window.LogConfig.loggers.loyaltyPinsList = window.LogConfig.createLogger('loyaltyPinsList'); +} +loyaltyPinsListLog.info('Loyalty pins list shared module loaded'); diff --git a/app/modules/loyalty/static/shared/js/loyalty-transactions-list.js b/app/modules/loyalty/static/shared/js/loyalty-transactions-list.js new file mode 100644 index 00000000..c3a83d03 --- /dev/null +++ b/app/modules/loyalty/static/shared/js/loyalty-transactions-list.js @@ -0,0 +1,248 @@ +// app/modules/loyalty/static/shared/js/loyalty-transactions-list.js +// Shared Alpine.js data factory for loyalty transactions listing pages. +// Used by store, merchant, and admin frontends. + +const loyaltyTransactionsListLog = window.LogConfig.loggers.loyaltyTransactionsList || window.LogConfig.createLogger('loyaltyTransactionsList'); + +/** + * Factory that returns an Alpine.js data object for loyalty transaction listing. + * + * @param {Object} config + * @param {string} config.apiPrefix - API path prefix, e.g. '/store/loyalty' + * @param {boolean} config.showStoreFilter - Whether to show the store/location dropdown filter + * @param {string} config.currentPage - Alpine currentPage identifier + */ +function loyaltyTransactionsList(config) { + const guardKey = '_loyaltyTransactionsList_' + config.currentPage + '_initialized'; + + return { + ...data(), + currentPage: config.currentPage, + + // Data + transactions: [], + locations: [], + + // Filters + filters: { + search: '', + type: '', + store_id: '' + }, + + // Pagination + pagination: { + page: 1, + per_page: 20, + total: 0, + pages: 0 + }, + + // State + loading: false, + error: null, + + // Config (exposed for templates) + _config: config, + + async init() { + loyaltyTransactionsListLog.info('=== LOYALTY TRANSACTIONS LIST INITIALIZING ===', config.currentPage); + if (window[guardKey]) return; + window[guardKey] = true; + + const parentInit = data().init; + if (parentInit) { + await parentInit.call(this); + } + + if (window.PlatformSettings) { + try { + this.pagination.per_page = await window.PlatformSettings.getRowsPerPage(); + } catch (error) { + loyaltyTransactionsListLog.warn('Failed to get rows per page setting:', error.message); + } + } + + await this.loadData(); + loyaltyTransactionsListLog.info('=== LOYALTY TRANSACTIONS LIST INITIALIZATION COMPLETE ==='); + }, + + async loadData() { + this.loading = true; + this.error = null; + try { + const parallel = [this.loadTransactions()]; + if (config.showStoreFilter) { + parallel.push(this.loadLocations()); + } + await Promise.all(parallel); + } catch (error) { + loyaltyTransactionsListLog.error('Failed to load data:', error); + this.error = error.message; + } finally { + this.loading = false; + } + }, + + async loadTransactions() { + try { + const params = new URLSearchParams(); + params.append('skip', (this.pagination.page - 1) * this.pagination.per_page); + params.append('limit', this.pagination.per_page); + if (this.filters.search) params.append('search', this.filters.search); + if (this.filters.type) params.append('type', this.filters.type); + if (this.filters.store_id) params.append('store_id', this.filters.store_id); + + const response = await apiClient.get(config.apiPrefix + '/transactions?' + params); + if (response) { + this.transactions = response.transactions || []; + this.pagination.total = response.total || 0; + this.pagination.pages = Math.ceil(this.pagination.total / this.pagination.per_page); + } + } catch (error) { + loyaltyTransactionsListLog.error('Failed to load transactions:', error); + throw error; + } + }, + + async loadLocations() { + try { + const response = await apiClient.get(config.apiPrefix + '/locations'); + if (response) { + this.locations = Array.isArray(response) ? response : (response.locations || []); + } + } catch (error) { + loyaltyTransactionsListLog.warn('Failed to load locations:', error.message); + } + }, + + debouncedSearch() { + if (this._searchTimeout) clearTimeout(this._searchTimeout); + this._searchTimeout = setTimeout(() => { + this.pagination.page = 1; + this.loadTransactions(); + }, 300); + }, + + applyFilter() { + this.pagination.page = 1; + this.loadTransactions(); + }, + + // Pagination helpers + get totalPages() { + return this.pagination.pages; + }, + + get startIndex() { + return this.pagination.total === 0 + ? 0 + : (this.pagination.page - 1) * this.pagination.per_page + 1; + }, + + get endIndex() { + const end = this.pagination.page * this.pagination.per_page; + return end > this.pagination.total ? this.pagination.total : end; + }, + + get pageNumbers() { + const pages = []; + const total = this.totalPages; + const current = this.pagination.page; + + if (total <= 7) { + for (let i = 1; i <= total; i++) pages.push(i); + } else { + pages.push(1); + if (current > 3) pages.push('...'); + const start = Math.max(2, current - 1); + const end = Math.min(total - 1, current + 1); + for (let i = start; i <= end; i++) pages.push(i); + if (current < total - 2) pages.push('...'); + pages.push(total); + } + return pages; + }, + + previousPage() { + if (this.pagination.page > 1) { + this.pagination.page--; + this.loadTransactions(); + } + }, + + nextPage() { + if (this.pagination.page < this.totalPages) { + this.pagination.page++; + this.loadTransactions(); + } + }, + + goToPage(num) { + if (num !== '...' && num >= 1 && num <= this.totalPages) { + this.pagination.page = num; + this.loadTransactions(); + } + }, + + /** + * Returns Tailwind CSS classes for a transaction type badge. + * @param {string} type - Transaction type (e.g. 'earn', 'redeem', 'bonus', 'adjustment', 'expire') + * @returns {string} CSS class string + */ + getTransactionBadgeClass(type) { + switch (type) { + case 'earn': + return 'bg-green-100 text-green-800'; + case 'redeem': + return 'bg-blue-100 text-blue-800'; + case 'bonus': + return 'bg-purple-100 text-purple-800'; + case 'adjustment': + return 'bg-yellow-100 text-yellow-800'; + case 'expire': + return 'bg-red-100 text-red-800'; + default: + return 'bg-gray-100 text-gray-800'; + } + }, + + // Formatting helpers + formatNumber(num) { + return num == null ? '0' : new Intl.NumberFormat('en-US').format(num); + }, + + formatDate(dateString) { + if (!dateString) return 'Never'; + try { + return new Date(dateString).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric' + }); + } catch (e) { + return dateString; + } + }, + + formatDateTime(dateString) { + if (!dateString) return 'Never'; + try { + return new Date(dateString).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + } catch (e) { + return dateString; + } + } + }; +} + +if (!window.LogConfig.loggers.loyaltyTransactionsList) { + window.LogConfig.loggers.loyaltyTransactionsList = window.LogConfig.createLogger('loyaltyTransactionsList'); +} +loyaltyTransactionsListLog.info('Loyalty transactions list shared module loaded'); diff --git a/app/modules/loyalty/static/store/js/loyalty-pins.js b/app/modules/loyalty/static/store/js/loyalty-pins.js new file mode 100644 index 00000000..4a35df14 --- /dev/null +++ b/app/modules/loyalty/static/store/js/loyalty-pins.js @@ -0,0 +1,18 @@ +// app/modules/loyalty/static/store/js/loyalty-pins.js +// noqa: js-006 - async init pattern is safe, loadData has try/catch + +const storePinsLog = window.LogConfig.loggers.storePins || window.LogConfig.createLogger('storePins'); + +function storeLoyaltyPins() { + return loyaltyPinsList({ + apiPrefix: '/store/loyalty', + showStoreFilter: false, + showCrud: true, + currentPage: 'pins', + }); +} + +if (!window.LogConfig.loggers.storePins) { + window.LogConfig.loggers.storePins = window.LogConfig.createLogger('storePins'); +} +storePinsLog.info('Store loyalty pins module loaded'); diff --git a/app/modules/loyalty/templates/loyalty/admin/merchant-card-detail.html b/app/modules/loyalty/templates/loyalty/admin/merchant-card-detail.html new file mode 100644 index 00000000..7d3fc62d --- /dev/null +++ b/app/modules/loyalty/templates/loyalty/admin/merchant-card-detail.html @@ -0,0 +1,26 @@ +{# app/modules/loyalty/templates/loyalty/admin/merchant-card-detail.html #} +{% extends "admin/base.html" %} +{% from 'shared/macros/headers.html' import detail_page_header %} +{% from 'shared/macros/alerts.html' import loading_state, error_state %} + +{% block title %}{{ _('loyalty.admin.merchant_card_detail.title') }}{% endblock %} +{% block i18n_modules %}['loyalty']{% endblock %} +{% block alpine_data %}adminMerchantCardDetail(){% endblock %} + +{% block content %} +{% call detail_page_header("card?.card_number || 'Card Detail'", '/admin/loyalty/merchants/' + merchant_id|string + '/cards', subtitle_show='card') %} + +{% endcall %} + +{{ loading_state(_('loyalty.admin.merchant_card_detail.loading')) }} +{{ error_state(_('loyalty.admin.merchant_card_detail.error_loading')) }} + +{% set card_detail_api_prefix = '/admin/loyalty/merchants/' + merchant_id|string %} +{% set card_detail_back_url = '/admin/loyalty/merchants/' + merchant_id|string + '/cards' %} +{% include 'loyalty/shared/card-detail-view.html' %} +{% endblock %} + +{% block extra_scripts %} + + +{% endblock %} diff --git a/app/modules/loyalty/templates/loyalty/admin/merchant-cards.html b/app/modules/loyalty/templates/loyalty/admin/merchant-cards.html new file mode 100644 index 00000000..90852ba3 --- /dev/null +++ b/app/modules/loyalty/templates/loyalty/admin/merchant-cards.html @@ -0,0 +1,35 @@ +{# app/modules/loyalty/templates/loyalty/admin/merchant-cards.html #} +{% extends "admin/base.html" %} +{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %} +{% from 'shared/macros/alerts.html' import loading_state, error_state %} + +{% block title %}{{ _('loyalty.admin.merchant_cards.title') }}{% endblock %} +{% block i18n_modules %}['loyalty']{% endblock %} +{% block alpine_data %}adminMerchantCards(){% endblock %} + +{% block content %} +{% call page_header_flex(title=_('loyalty.admin.merchant_cards.title'), subtitle=_('loyalty.admin.merchant_cards.subtitle')) %} +
+ {{ refresh_button(loading_var='loading', onclick='loadCards()', variant='secondary') }} + + + {{ _('loyalty.common.back') }} + +
+{% endcall %} + +{{ loading_state(_('loyalty.admin.merchant_cards.loading')) }} +{{ error_state(_('loyalty.admin.merchant_cards.error_loading')) }} + +{% set cards_api_prefix = '/admin/loyalty/merchants/' + merchant_id|string %} +{% set cards_base_url = '/admin/loyalty/merchants/' + merchant_id|string + '/cards' %} +{% set show_store_filter = true %} +{% set show_enroll_button = false %} +{% include 'loyalty/shared/cards-list.html' %} +{% endblock %} + +{% block extra_scripts %} + + +{% endblock %} diff --git a/app/modules/loyalty/templates/loyalty/admin/merchant-detail.html b/app/modules/loyalty/templates/loyalty/admin/merchant-detail.html index 82c979c3..a1cbafab 100644 --- a/app/modules/loyalty/templates/loyalty/admin/merchant-detail.html +++ b/app/modules/loyalty/templates/loyalty/admin/merchant-detail.html @@ -46,6 +46,24 @@ {{ _('loyalty.admin.merchant_detail.view_merchant') }} + + + {{ _('loyalty.admin.merchant_detail.view_cards') }} + + + + {{ _('loyalty.admin.merchant_detail.view_transactions') }} + + + + {{ _('loyalty.admin.merchant_detail.view_pins') }} + diff --git a/app/modules/loyalty/templates/loyalty/admin/merchant-pins.html b/app/modules/loyalty/templates/loyalty/admin/merchant-pins.html new file mode 100644 index 00000000..4317cf4f --- /dev/null +++ b/app/modules/loyalty/templates/loyalty/admin/merchant-pins.html @@ -0,0 +1,34 @@ +{# app/modules/loyalty/templates/loyalty/admin/merchant-pins.html #} +{% extends "admin/base.html" %} +{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %} +{% from 'shared/macros/alerts.html' import loading_state, error_state %} + +{% block title %}{{ _('loyalty.admin.merchant_pins.title') }}{% endblock %} +{% block i18n_modules %}['loyalty']{% endblock %} +{% block alpine_data %}adminMerchantPins(){% endblock %} + +{% block content %} +{% call page_header_flex(title=_('loyalty.admin.merchant_pins.title'), subtitle=_('loyalty.admin.merchant_pins.subtitle')) %} +
+ {{ refresh_button(loading_var='loading', onclick='loadPins()', variant='secondary') }} + + + {{ _('loyalty.common.back') }} + +
+{% endcall %} + +{{ loading_state(_('loyalty.admin.merchant_pins.loading')) }} +{{ error_state(_('loyalty.admin.merchant_pins.error_loading')) }} + +{% set pins_api_prefix = '/admin/loyalty/merchants/' + merchant_id|string %} +{% set show_store_filter = true %} +{% set show_crud = false %} +{% include 'loyalty/shared/pins-list.html' %} +{% endblock %} + +{% block extra_scripts %} + + +{% endblock %} diff --git a/app/modules/loyalty/templates/loyalty/admin/merchant-transactions.html b/app/modules/loyalty/templates/loyalty/admin/merchant-transactions.html new file mode 100644 index 00000000..131b1c61 --- /dev/null +++ b/app/modules/loyalty/templates/loyalty/admin/merchant-transactions.html @@ -0,0 +1,33 @@ +{# app/modules/loyalty/templates/loyalty/admin/merchant-transactions.html #} +{% extends "admin/base.html" %} +{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %} +{% from 'shared/macros/alerts.html' import loading_state, error_state %} + +{% block title %}{{ _('loyalty.admin.merchant_transactions.title') }}{% endblock %} +{% block i18n_modules %}['loyalty']{% endblock %} +{% block alpine_data %}adminMerchantTransactions(){% endblock %} + +{% block content %} +{% call page_header_flex(title=_('loyalty.admin.merchant_transactions.title'), subtitle=_('loyalty.admin.merchant_transactions.subtitle')) %} +
+ {{ refresh_button(loading_var='loading', onclick='loadTransactions()', variant='secondary') }} + + + {{ _('loyalty.common.back') }} + +
+{% endcall %} + +{{ loading_state(_('loyalty.admin.merchant_transactions.loading')) }} +{{ error_state(_('loyalty.admin.merchant_transactions.error_loading')) }} + +{% set transactions_api_prefix = '/admin/loyalty/merchants/' + merchant_id|string %} +{% set show_store_filter = true %} +{% include 'loyalty/shared/transactions-list.html' %} +{% endblock %} + +{% block extra_scripts %} + + +{% endblock %} diff --git a/app/modules/loyalty/templates/loyalty/merchant/card-detail.html b/app/modules/loyalty/templates/loyalty/merchant/card-detail.html new file mode 100644 index 00000000..e7f79af2 --- /dev/null +++ b/app/modules/loyalty/templates/loyalty/merchant/card-detail.html @@ -0,0 +1,30 @@ +{# app/modules/loyalty/templates/loyalty/merchant/card-detail.html #} +{% extends "merchant/base.html" %} +{% from 'shared/macros/headers.html' import detail_page_header %} +{% from 'shared/macros/alerts.html' import loading_state, error_state %} +{% from 'shared/macros/tables.html' import table_wrapper, table_header %} + +{% block title %}{{ _('loyalty.merchant.card_detail.title') }}{% endblock %} + +{% block i18n_modules %}['loyalty']{% endblock %} + +{% block alpine_data %}merchantLoyaltyCardDetail(){% endblock %} + +{% block content %} +{% call detail_page_header("card?.customer_name || '" + _('loyalty.merchant.card_detail.title') + "'", '/merchants/loyalty/cards', subtitle_show='card') %} + {{ _('loyalty.merchant.card_detail.card_label') }}: +{% endcall %} + +{{ loading_state(_('loyalty.merchant.card_detail.loading')) }} +{{ error_state(_('loyalty.merchant.card_detail.error_loading')) }} + +{% set card_detail_api_prefix = '/merchants/loyalty' %} +{% set card_detail_back_url = '/merchants/loyalty/cards' %} +{% include 'loyalty/shared/card-detail-view.html' %} + +{% endblock %} + +{% block extra_scripts %} + + +{% endblock %} diff --git a/app/modules/loyalty/templates/loyalty/merchant/cards.html b/app/modules/loyalty/templates/loyalty/merchant/cards.html new file mode 100644 index 00000000..6b30dcd9 --- /dev/null +++ b/app/modules/loyalty/templates/loyalty/merchant/cards.html @@ -0,0 +1,34 @@ +{# app/modules/loyalty/templates/loyalty/merchant/cards.html #} +{% extends "merchant/base.html" %} +{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %} +{% from 'shared/macros/alerts.html' import loading_state, error_state %} + +{% block title %}{{ _('loyalty.merchant.cards.title') }}{% endblock %} + +{% block i18n_modules %}['loyalty']{% endblock %} + +{% block alpine_data %}merchantLoyaltyCards(){% endblock %} + +{% block content %} +{% call page_header_flex(title=_('loyalty.merchant.cards.title'), subtitle=_('loyalty.merchant.cards.subtitle')) %} +
+ {{ refresh_button(loading_var='loading', onclick='loadCards()', variant='secondary') }} +
+{% endcall %} + +{{ loading_state(_('loyalty.merchant.cards.loading')) }} + +{{ error_state(_('loyalty.merchant.cards.error_loading')) }} + +{% set cards_api_prefix = '/merchants/loyalty' %} +{% set cards_base_url = '/merchants/loyalty/cards' %} +{% set show_store_filter = true %} +{% set show_enroll_button = false %} +{% include 'loyalty/shared/cards-list.html' %} + +{% endblock %} + +{% block extra_scripts %} + + +{% endblock %} diff --git a/app/modules/loyalty/templates/loyalty/merchant/pins.html b/app/modules/loyalty/templates/loyalty/merchant/pins.html new file mode 100644 index 00000000..b3cf825a --- /dev/null +++ b/app/modules/loyalty/templates/loyalty/merchant/pins.html @@ -0,0 +1,38 @@ +{# app/modules/loyalty/templates/loyalty/merchant/pins.html #} +{% extends "merchant/base.html" %} +{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %} +{% from 'shared/macros/alerts.html' import loading_state, error_state %} + +{% block title %}{{ _('loyalty.merchant.pins.title') }}{% endblock %} + +{% block i18n_modules %}['loyalty']{% endblock %} + +{% block alpine_data %}merchantLoyaltyPins(){% endblock %} + +{% block content %} +{% call page_header_flex(title=_('loyalty.merchant.pins.title'), subtitle=_('loyalty.merchant.pins.subtitle')) %} +
+ {{ refresh_button(loading_var='loading', onclick='loadPins()', variant='secondary') }} + +
+{% endcall %} + +{{ loading_state(_('loyalty.merchant.pins.loading')) }} + +{{ error_state(_('loyalty.merchant.pins.error_loading')) }} + +{% set pins_api_prefix = '/merchants/loyalty' %} +{% set show_store_filter = true %} +{% set show_crud = true %} +{% include 'loyalty/shared/pins-list.html' %} + +{% endblock %} + +{% block extra_scripts %} + + +{% endblock %} diff --git a/app/modules/loyalty/templates/loyalty/merchant/settings.html b/app/modules/loyalty/templates/loyalty/merchant/settings.html new file mode 100644 index 00000000..2f5bcc2a --- /dev/null +++ b/app/modules/loyalty/templates/loyalty/merchant/settings.html @@ -0,0 +1,89 @@ +{# app/modules/loyalty/templates/loyalty/merchant/settings.html #} +{% extends "merchant/base.html" %} +{% from 'shared/macros/headers.html' import page_header_flex %} +{% from 'shared/macros/alerts.html' import loading_state, error_state %} + +{% block title %}{{ _('loyalty.merchant.settings.title') }}{% endblock %} + +{% block i18n_modules %}['loyalty']{% endblock %} + +{% block alpine_data %}merchantLoyaltyMerchantSettings(){% endblock %} + +{% block content %} +{% call page_header_flex(title=_('loyalty.merchant.settings.title'), subtitle=_('loyalty.merchant.settings.subtitle')) %} +{% endcall %} + +{{ loading_state(_('loyalty.merchant.settings.loading')) }} + +{{ error_state(_('loyalty.merchant.settings.error_loading')) }} + + +
+
+ +

{{ _('loyalty.merchant.settings.managed_by_admin') }}

+
+
+ + +
+ + +
+

+ + {{ _('loyalty.merchant.settings.staff_pin_policy') }} +

+
+
+

{{ _('loyalty.merchant.settings.pin_policy') }}

+

+ +

+
+
+

{{ _('loyalty.merchant.settings.lockout_attempts') }}

+

+
+
+

{{ _('loyalty.merchant.settings.lockout_minutes') }}

+

+
+
+
+ + +
+

+ + {{ _('loyalty.merchant.settings.permissions') }} +

+
+
+ {{ _('loyalty.merchant.settings.self_enrollment') }} + +
+
+ {{ _('loyalty.merchant.settings.cross_location') }} + +
+
+ {{ _('loyalty.merchant.settings.void_transactions') }} + +
+
+
+ +
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/app/modules/loyalty/templates/loyalty/merchant/transactions.html b/app/modules/loyalty/templates/loyalty/merchant/transactions.html new file mode 100644 index 00000000..a116b14d --- /dev/null +++ b/app/modules/loyalty/templates/loyalty/merchant/transactions.html @@ -0,0 +1,32 @@ +{# app/modules/loyalty/templates/loyalty/merchant/transactions.html #} +{% extends "merchant/base.html" %} +{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %} +{% from 'shared/macros/alerts.html' import loading_state, error_state %} + +{% block title %}{{ _('loyalty.merchant.transactions.title') }}{% endblock %} + +{% block i18n_modules %}['loyalty']{% endblock %} + +{% block alpine_data %}merchantLoyaltyTransactions(){% endblock %} + +{% block content %} +{% call page_header_flex(title=_('loyalty.merchant.transactions.title'), subtitle=_('loyalty.merchant.transactions.subtitle')) %} +
+ {{ refresh_button(loading_var='loading', onclick='loadTransactions()', variant='secondary') }} +
+{% endcall %} + +{{ loading_state(_('loyalty.merchant.transactions.loading')) }} + +{{ error_state(_('loyalty.merchant.transactions.error_loading')) }} + +{% set transactions_api_prefix = '/merchants/loyalty' %} +{% set show_store_filter = true %} +{% include 'loyalty/shared/transactions-list.html' %} + +{% endblock %} + +{% block extra_scripts %} + + +{% endblock %} diff --git a/app/modules/loyalty/templates/loyalty/shared/card-detail-view.html b/app/modules/loyalty/templates/loyalty/shared/card-detail-view.html new file mode 100644 index 00000000..6b4a83f5 --- /dev/null +++ b/app/modules/loyalty/templates/loyalty/shared/card-detail-view.html @@ -0,0 +1,146 @@ +{# app/modules/loyalty/templates/loyalty/shared/card-detail-view.html #} +{# + Shared loyalty card detail view partial. Set these variables before including: + - card_detail_api_prefix (str): API base URL for card data + - card_detail_back_url (str): URL for the back button +#} +{% from 'shared/macros/alerts.html' import loading_state, error_state %} +{% from 'shared/macros/tables.html' import table_wrapper, table_header %} + + +
+
+
+ +
+
+

{{ _('loyalty.shared.card_detail.points_balance') }}

+

0

+
+
+
+
+ +
+
+

{{ _('loyalty.shared.card_detail.total_earned') }}

+

0

+
+
+
+
+ +
+
+

{{ _('loyalty.shared.card_detail.total_redeemed') }}

+

0

+
+
+
+
+ +
+
+

{{ _('loyalty.shared.card_detail.member_since') }}

+

-

+
+
+
+ + +
+ +
+

+ + {{ _('loyalty.shared.card_detail.customer_information') }} +

+
+
+

{{ _('loyalty.shared.card_detail.name') }}

+

-

+
+
+

{{ _('loyalty.shared.card_detail.email') }}

+

-

+
+
+

{{ _('loyalty.shared.card_detail.phone') }}

+

-

+
+
+

{{ _('loyalty.shared.card_detail.birthday') }}

+

-

+
+
+
+ + +
+

+ + {{ _('loyalty.shared.card_detail.card_details') }} +

+
+
+

{{ _('loyalty.shared.card_detail.card_number') }}

+

-

+
+
+

{{ _('loyalty.shared.card_detail.status') }}

+ +
+
+

{{ _('loyalty.shared.card_detail.last_activity') }}

+

-

+
+
+

{{ _('loyalty.shared.card_detail.enrolled_at') }}

+

-

+
+
+
+
+ + +
+

+ + {{ _('loyalty.shared.card_detail.transaction_history') }} +

+ {% call table_wrapper() %} + {{ table_header([_('loyalty.shared.card_detail.col_date'), _('loyalty.shared.card_detail.col_type'), _('loyalty.shared.card_detail.col_points'), _('loyalty.shared.card_detail.col_location'), _('loyalty.shared.card_detail.col_notes')]) }} + + + + + {% endcall %} +
diff --git a/app/modules/loyalty/templates/loyalty/shared/cards-list.html b/app/modules/loyalty/templates/loyalty/shared/cards-list.html new file mode 100644 index 00000000..7db85421 --- /dev/null +++ b/app/modules/loyalty/templates/loyalty/shared/cards-list.html @@ -0,0 +1,138 @@ +{# app/modules/loyalty/templates/loyalty/shared/cards-list.html #} +{# + Shared loyalty cards list partial. Set these variables before including: + - cards_api_prefix (str): API base URL (e.g. '/store/loyalty', '/merchants/loyalty', '/admin/loyalty/merchants/5') + - cards_base_url (str): URL prefix for card detail links + - show_store_filter (bool): Show store/location dropdown filter + - show_enroll_button (bool): Show "Enroll New" button (store persona only) + - enroll_url (str): URL for the enroll button (if show_enroll_button) +#} +{% from 'shared/macros/pagination.html' import pagination %} +{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %} +{% from 'shared/macros/alerts.html' import loading_state, error_state %} +{% from 'shared/macros/tables.html' import table_wrapper, table_header %} + + +
+
+
+ +
+
+

{{ _('loyalty.shared.cards.total_members') }}

+

0

+
+
+
+
+ +
+
+

{{ _('loyalty.shared.cards.active_30d') }}

+

0

+
+
+
+
+ +
+
+

{{ _('loyalty.shared.cards.new_this_month') }}

+

0

+
+
+
+
+ +
+
+

{{ _('loyalty.shared.cards.total_points_balance') }}

+

0

+
+
+
+ + +
+
+
+
+ + + + +
+
+ + {% if show_store_filter %} + + {% endif %} +
+
+ + +
+ {% call table_wrapper() %} + {{ table_header([_('loyalty.shared.cards.col_member'), _('loyalty.shared.cards.col_card_number'), _('loyalty.shared.cards.col_points_balance'), _('loyalty.shared.cards.col_last_activity'), _('loyalty.shared.cards.col_status'), _('loyalty.shared.cards.col_actions')]) }} + + + + + {% endcall %} + + {{ pagination() }} +
diff --git a/app/modules/loyalty/templates/loyalty/shared/pins-list.html b/app/modules/loyalty/templates/loyalty/shared/pins-list.html new file mode 100644 index 00000000..a06f536c --- /dev/null +++ b/app/modules/loyalty/templates/loyalty/shared/pins-list.html @@ -0,0 +1,242 @@ +{# app/modules/loyalty/templates/loyalty/shared/pins-list.html #} +{# + Shared staff PINs list partial. Set these variables before including: + - pins_api_prefix (str): API base URL for PINs data + - show_store_filter (bool): Show store/location dropdown filter + - show_crud (bool): Show create/edit/delete actions (false for admin read-only) +#} +{% from 'shared/macros/pagination.html' import pagination %} +{% from 'shared/macros/alerts.html' import loading_state, error_state %} +{% from 'shared/macros/tables.html' import table_wrapper, table_header %} +{% from 'shared/macros/modals.html' import modal, confirm_modal %} + + +
+
+
+ +
+
+

{{ _('loyalty.shared.pins.total_pins') }}

+

0

+
+
+
+
+ +
+
+

{{ _('loyalty.shared.pins.active') }}

+

0

+
+
+
+
+ +
+
+

{{ _('loyalty.shared.pins.locked') }}

+

0

+
+
+
+ + +
+
+ {% if show_store_filter %} + + {% endif %} + +
+
+ + +
+ {% call table_wrapper() %} + {% if show_store_filter %} + {{ table_header([_('loyalty.shared.pins.col_name'), _('loyalty.shared.pins.col_staff_id'), _('loyalty.shared.pins.col_store'), _('loyalty.shared.pins.col_status'), _('loyalty.shared.pins.col_locked'), _('loyalty.shared.pins.col_last_used'), _('loyalty.shared.pins.col_actions')]) }} + {% else %} + {{ table_header([_('loyalty.shared.pins.col_name'), _('loyalty.shared.pins.col_staff_id'), _('loyalty.shared.pins.col_status'), _('loyalty.shared.pins.col_locked'), _('loyalty.shared.pins.col_last_used'), _('loyalty.shared.pins.col_actions')]) }} + {% endif %} + + + + + {% endcall %} + + {{ pagination() }} +
+ +{% if show_crud %} + +{% call modal('createPinModal', _('loyalty.shared.pins.create_pin'), 'showCreateModal', size='md', show_footer=false) %} +
+
+
+ + +
+
+ + +
+
+ + +
+ {% if show_store_filter %} +
+ + +
+ {% endif %} +
+
+ + +
+
+{% endcall %} + + +{% call modal('editPinModal', _('loyalty.shared.pins.edit_pin'), 'showEditModal', size='md', show_footer=false) %} +
+
+
+ + +
+
+ + +
+
+ + +

{{ _('loyalty.shared.pins.pin_edit_hint') }}

+
+ {% if show_store_filter %} +
+ + +
+ {% endif %} +
+
+ + +
+
+{% endcall %} + + +{{ confirm_modal( + 'deletePinModal', + _('loyalty.shared.pins.delete_pin'), + _('loyalty.shared.pins.delete_confirm_message'), + 'deletePin()', + 'showDeleteModal', + confirm_text=_('loyalty.common.delete'), + cancel_text=_('loyalty.common.cancel'), + variant='danger' +) }} +{% endif %} diff --git a/app/modules/loyalty/templates/loyalty/shared/transactions-list.html b/app/modules/loyalty/templates/loyalty/shared/transactions-list.html new file mode 100644 index 00000000..239ce2ff --- /dev/null +++ b/app/modules/loyalty/templates/loyalty/shared/transactions-list.html @@ -0,0 +1,86 @@ +{# app/modules/loyalty/templates/loyalty/shared/transactions-list.html #} +{# + Shared transactions list partial. Set these variables before including: + - transactions_api_prefix (str): API base URL for transactions data + - show_store_filter (bool): Show store/location dropdown filter +#} +{% from 'shared/macros/pagination.html' import pagination %} +{% from 'shared/macros/alerts.html' import loading_state, error_state %} +{% from 'shared/macros/tables.html' import table_wrapper, table_header %} + + +
+
+ + {% if show_store_filter %} + + {% endif %} +
+
+ + +
+ {% call table_wrapper() %} + {{ table_header([_('loyalty.shared.transactions.col_date'), _('loyalty.shared.transactions.col_customer'), _('loyalty.shared.transactions.col_type'), _('loyalty.shared.transactions.col_points'), _('loyalty.shared.transactions.col_location'), _('loyalty.shared.transactions.col_notes')]) }} + + + + + {% endcall %} + + {{ pagination() }} +
diff --git a/app/modules/loyalty/templates/loyalty/store/pins.html b/app/modules/loyalty/templates/loyalty/store/pins.html new file mode 100644 index 00000000..83ab9af9 --- /dev/null +++ b/app/modules/loyalty/templates/loyalty/store/pins.html @@ -0,0 +1,49 @@ +{# app/modules/loyalty/templates/loyalty/store/pins.html #} +{% extends "store/base.html" %} +{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %} +{% from 'shared/macros/alerts.html' import loading_state, error_state %} + +{% block title %}{{ _('loyalty.store.pins.title') }}{% endblock %} + +{% block i18n_modules %}['loyalty']{% endblock %} + +{% block alpine_data %}storeLoyaltyPins(){% endblock %} + +{% block content %} + +{% call page_header_flex(title=_('loyalty.store.pins.title'), subtitle=_('loyalty.store.pins.subtitle')) %} +
+ {{ refresh_button(loading_var='loading', onclick='loadPins()', variant='secondary') }} + +
+{% endcall %} + +{{ loading_state(_('loyalty.store.pins.loading')) }} +{{ error_state(_('loyalty.store.pins.error_loading')) }} + + +
+
+ +
+

{{ _('loyalty.common.program_not_setup') }}

+

{{ _('loyalty.common.program_not_setup_desc') }}

+
+
+
+ +{% set pins_api_prefix = '/store/loyalty' %} +{% set show_store_filter = false %} +{% set show_crud = true %} +{% include 'loyalty/shared/pins-list.html' %} + +{% endblock %} + +{% block extra_scripts %} + + +{% endblock %} diff --git a/app/modules/loyalty/tests/integration/test_admin_api.py b/app/modules/loyalty/tests/integration/test_admin_api.py index 884a8ff3..01f8cb3e 100644 --- a/app/modules/loyalty/tests/integration/test_admin_api.py +++ b/app/modules/loyalty/tests/integration/test_admin_api.py @@ -532,3 +532,122 @@ class TestAdminCreateProgramDuplicate: ) # Should fail — merchant already has admin_program assert response.status_code in [409, 422] + + +# ============================================================================ +# GET /merchants/{merchant_id}/cards (On Behalf) +# ============================================================================ + + +@pytest.mark.integration +@pytest.mark.api +@pytest.mark.loyalty +class TestAdminListMerchantCards: + """Tests for GET /api/v1/admin/loyalty/merchants/{merchant_id}/cards.""" + + def test_list_merchant_cards( + self, client, super_admin_headers, admin_merchant + ): + """Returns cards list for a merchant.""" + response = client.get( + f"{BASE}/merchants/{admin_merchant.id}/cards", + headers=super_admin_headers, + ) + assert response.status_code == 200 + data = response.json() + assert "cards" in data + assert "total" in data + + def test_list_merchant_cards_requires_auth(self, client, admin_merchant): + """Unauthenticated request is rejected.""" + response = client.get(f"{BASE}/merchants/{admin_merchant.id}/cards") + assert response.status_code in [401, 403] + + +# ============================================================================ +# GET /merchants/{merchant_id}/transactions (On Behalf) +# ============================================================================ + + +@pytest.mark.integration +@pytest.mark.api +@pytest.mark.loyalty +class TestAdminListMerchantTransactions: + """Tests for GET /api/v1/admin/loyalty/merchants/{merchant_id}/transactions.""" + + def test_list_merchant_transactions( + self, client, super_admin_headers, admin_merchant + ): + """Returns transactions for a merchant.""" + response = client.get( + f"{BASE}/merchants/{admin_merchant.id}/transactions", + headers=super_admin_headers, + ) + assert response.status_code == 200 + data = response.json() + assert "transactions" in data + assert "total" in data + + def test_list_merchant_transactions_requires_auth(self, client, admin_merchant): + """Unauthenticated request is rejected.""" + response = client.get(f"{BASE}/merchants/{admin_merchant.id}/transactions") + assert response.status_code in [401, 403] + + +# ============================================================================ +# GET /merchants/{merchant_id}/pins (On Behalf, Read-Only) +# ============================================================================ + + +@pytest.mark.integration +@pytest.mark.api +@pytest.mark.loyalty +class TestAdminListMerchantPins: + """Tests for GET /api/v1/admin/loyalty/merchants/{merchant_id}/pins.""" + + def test_list_merchant_pins( + self, client, super_admin_headers, admin_merchant + ): + """Returns PINs for a merchant.""" + response = client.get( + f"{BASE}/merchants/{admin_merchant.id}/pins", + headers=super_admin_headers, + ) + assert response.status_code == 200 + data = response.json() + assert "pins" in data + assert "total" in data + + def test_list_merchant_pins_requires_auth(self, client, admin_merchant): + """Unauthenticated request is rejected.""" + response = client.get(f"{BASE}/merchants/{admin_merchant.id}/pins") + assert response.status_code in [401, 403] + + +# ============================================================================ +# GET /merchants/{merchant_id}/locations (On Behalf) +# ============================================================================ + + +@pytest.mark.integration +@pytest.mark.api +@pytest.mark.loyalty +class TestAdminListMerchantLocations: + """Tests for GET /api/v1/admin/loyalty/merchants/{merchant_id}/locations.""" + + def test_list_merchant_locations( + self, client, super_admin_headers, admin_merchant + ): + """Returns store locations for a merchant.""" + response = client.get( + f"{BASE}/merchants/{admin_merchant.id}/locations", + headers=super_admin_headers, + ) + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + + def test_list_merchant_locations_requires_auth(self, client, admin_merchant): + """Unauthenticated request is rejected.""" + response = client.get(f"{BASE}/merchants/{admin_merchant.id}/locations") + assert response.status_code in [401, 403] diff --git a/app/modules/loyalty/tests/integration/test_admin_pages.py b/app/modules/loyalty/tests/integration/test_admin_pages.py index eb2019ee..3b1f23a2 100644 --- a/app/modules/loyalty/tests/integration/test_admin_pages.py +++ b/app/modules/loyalty/tests/integration/test_admin_pages.py @@ -217,3 +217,117 @@ class TestAdminMerchantSettingsPage: f"{BASE}/merchants/{admin_merchant.id}/settings" ) assert response.status_code in [401, 403] + + +# ============================================================================ +# Merchant Cards Page (On Behalf) +# ============================================================================ + + +@pytest.mark.integration +@pytest.mark.loyalty +class TestAdminMerchantCardsPage: + """Tests for GET /loyalty/merchants/{merchant_id}/cards.""" + + def test_merchant_cards_page_renders( + self, client, super_admin_headers, admin_merchant + ): + """Merchant cards page returns HTML.""" + response = client.get( + f"{BASE}/merchants/{admin_merchant.id}/cards", + headers=super_admin_headers, + ) + assert response.status_code == 200 + assert "text/html" in response.headers["content-type"] + + def test_merchant_cards_page_requires_auth(self, client, admin_merchant): + """Unauthenticated request is rejected.""" + response = client.get(f"{BASE}/merchants/{admin_merchant.id}/cards") + assert response.status_code in [401, 403] + + +# ============================================================================ +# Merchant Card Detail Page (On Behalf) +# ============================================================================ + + +@pytest.mark.integration +@pytest.mark.loyalty +class TestAdminMerchantCardDetailPage: + """Tests for GET /loyalty/merchants/{merchant_id}/cards/{card_id}.""" + + def test_merchant_card_detail_page_renders( + self, client, super_admin_headers, admin_merchant + ): + """Card detail page returns HTML (even with non-existent card_id).""" + response = client.get( + f"{BASE}/merchants/{admin_merchant.id}/cards/99999", + headers=super_admin_headers, + ) + assert response.status_code == 200 + assert "text/html" in response.headers["content-type"] + + def test_merchant_card_detail_page_requires_auth(self, client, admin_merchant): + """Unauthenticated request is rejected.""" + response = client.get( + f"{BASE}/merchants/{admin_merchant.id}/cards/99999" + ) + assert response.status_code in [401, 403] + + +# ============================================================================ +# Merchant Transactions Page (On Behalf) +# ============================================================================ + + +@pytest.mark.integration +@pytest.mark.loyalty +class TestAdminMerchantTransactionsPage: + """Tests for GET /loyalty/merchants/{merchant_id}/transactions.""" + + def test_merchant_transactions_page_renders( + self, client, super_admin_headers, admin_merchant + ): + """Transactions page returns HTML.""" + response = client.get( + f"{BASE}/merchants/{admin_merchant.id}/transactions", + headers=super_admin_headers, + ) + assert response.status_code == 200 + assert "text/html" in response.headers["content-type"] + + def test_merchant_transactions_page_requires_auth(self, client, admin_merchant): + """Unauthenticated request is rejected.""" + response = client.get( + f"{BASE}/merchants/{admin_merchant.id}/transactions" + ) + assert response.status_code in [401, 403] + + +# ============================================================================ +# Merchant PINs Page (On Behalf, Read-Only) +# ============================================================================ + + +@pytest.mark.integration +@pytest.mark.loyalty +class TestAdminMerchantPinsPage: + """Tests for GET /loyalty/merchants/{merchant_id}/pins.""" + + def test_merchant_pins_page_renders( + self, client, super_admin_headers, admin_merchant + ): + """PINs page returns HTML.""" + response = client.get( + f"{BASE}/merchants/{admin_merchant.id}/pins", + headers=super_admin_headers, + ) + assert response.status_code == 200 + assert "text/html" in response.headers["content-type"] + + def test_merchant_pins_page_requires_auth(self, client, admin_merchant): + """Unauthenticated request is rejected.""" + response = client.get( + f"{BASE}/merchants/{admin_merchant.id}/pins" + ) + assert response.status_code in [401, 403] diff --git a/app/modules/loyalty/tests/integration/test_merchant_api.py b/app/modules/loyalty/tests/integration/test_merchant_api.py index 63e5fd13..605918ae 100644 --- a/app/modules/loyalty/tests/integration/test_merchant_api.py +++ b/app/modules/loyalty/tests/integration/test_merchant_api.py @@ -372,3 +372,138 @@ class TestMerchantGetStats: assert data["total_cards"] == 0 assert data["program"] is None assert data["locations"] == [] + + +# ============================================================================ +# GET /cards +# ============================================================================ + + +@pytest.mark.integration +@pytest.mark.api +@pytest.mark.loyalty +class TestMerchantListCards: + """Tests for GET /api/v1/merchants/loyalty/cards.""" + + def test_list_cards_success( + self, client, loyalty_merchant_headers, loyalty_store_setup + ): + """Returns cards list for the merchant.""" + response = client.get(f"{BASE}/cards", headers=loyalty_merchant_headers) + assert response.status_code == 200 + data = response.json() + assert "cards" in data + assert "total" in data + assert isinstance(data["cards"], list) + + def test_list_cards_requires_auth(self, client): + """Unauthenticated request is rejected.""" + response = client.get(f"{BASE}/cards") + assert response.status_code in [401, 403] + + +# ============================================================================ +# GET /transactions +# ============================================================================ + + +@pytest.mark.integration +@pytest.mark.api +@pytest.mark.loyalty +class TestMerchantListTransactions: + """Tests for GET /api/v1/merchants/loyalty/transactions.""" + + def test_list_transactions_empty( + self, client, loyalty_merchant_headers, loyalty_store_setup + ): + """Returns empty list when no transactions exist.""" + response = client.get(f"{BASE}/transactions", headers=loyalty_merchant_headers) + assert response.status_code == 200 + data = response.json() + assert data["transactions"] == [] + assert data["total"] == 0 + + def test_list_transactions_requires_auth(self, client): + """Unauthenticated request is rejected.""" + response = client.get(f"{BASE}/transactions") + assert response.status_code in [401, 403] + + +# ============================================================================ +# GET /pins +# ============================================================================ + + +@pytest.mark.integration +@pytest.mark.api +@pytest.mark.loyalty +class TestMerchantListPins: + """Tests for GET /api/v1/merchants/loyalty/pins.""" + + def test_list_pins_empty( + self, client, loyalty_merchant_headers, loyalty_store_setup + ): + """Returns empty list when no PINs exist.""" + response = client.get(f"{BASE}/pins", headers=loyalty_merchant_headers) + assert response.status_code == 200 + data = response.json() + assert data["pins"] == [] + assert data["total"] == 0 + + def test_list_pins_requires_auth(self, client): + """Unauthenticated request is rejected.""" + response = client.get(f"{BASE}/pins") + assert response.status_code in [401, 403] + + +# ============================================================================ +# GET /settings +# ============================================================================ + + +@pytest.mark.integration +@pytest.mark.api +@pytest.mark.loyalty +class TestMerchantGetSettings: + """Tests for GET /api/v1/merchants/loyalty/settings.""" + + def test_get_settings_success( + self, client, loyalty_merchant_headers, loyalty_store_setup + ): + """Returns merchant loyalty settings.""" + response = client.get(f"{BASE}/settings", headers=loyalty_merchant_headers) + assert response.status_code == 200 + data = response.json() + assert "staff_pin_policy" in data + + def test_get_settings_requires_auth(self, client): + """Unauthenticated request is rejected.""" + response = client.get(f"{BASE}/settings") + assert response.status_code in [401, 403] + + +# ============================================================================ +# GET /locations +# ============================================================================ + + +@pytest.mark.integration +@pytest.mark.api +@pytest.mark.loyalty +class TestMerchantGetLocations: + """Tests for GET /api/v1/merchants/loyalty/locations.""" + + def test_get_locations_success( + self, client, loyalty_merchant_headers, loyalty_store_setup + ): + """Returns merchant's store locations.""" + response = client.get(f"{BASE}/locations", headers=loyalty_merchant_headers) + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + assert len(data) >= 1 + + def test_get_locations_requires_auth(self, client): + """Unauthenticated request is rejected.""" + response = client.get(f"{BASE}/locations") + assert response.status_code in [401, 403] diff --git a/app/modules/loyalty/tests/integration/test_merchant_pages.py b/app/modules/loyalty/tests/integration/test_merchant_pages.py index cc4b6592..2326e003 100644 --- a/app/modules/loyalty/tests/integration/test_merchant_pages.py +++ b/app/modules/loyalty/tests/integration/test_merchant_pages.py @@ -158,3 +158,113 @@ class TestMerchantAnalyticsPage: """Unauthenticated request is rejected.""" response = client.get(f"{BASE}/analytics") assert response.status_code in [401, 403] + + +# ============================================================================ +# Cards Page +# ============================================================================ + + +@pytest.mark.integration +@pytest.mark.loyalty +class TestMerchantCardsPage: + """Tests for GET /merchants/loyalty/cards.""" + + def test_cards_page_renders(self, client, merchant_page_headers): + """Cards page returns HTML.""" + response = client.get(f"{BASE}/cards", headers=merchant_page_headers) + assert response.status_code == 200 + assert "text/html" in response.headers["content-type"] + + def test_cards_page_requires_auth(self, client): + """Unauthenticated request is rejected.""" + response = client.get(f"{BASE}/cards") + assert response.status_code in [401, 403] + + +# ============================================================================ +# Card Detail Page +# ============================================================================ + + +@pytest.mark.integration +@pytest.mark.loyalty +class TestMerchantCardDetailPage: + """Tests for GET /merchants/loyalty/cards/{card_id}.""" + + def test_card_detail_page_renders(self, client, merchant_page_headers): + """Card detail page returns HTML (even with non-existent card_id).""" + response = client.get(f"{BASE}/cards/99999", headers=merchant_page_headers) + assert response.status_code == 200 + assert "text/html" in response.headers["content-type"] + + def test_card_detail_page_requires_auth(self, client): + """Unauthenticated request is rejected.""" + response = client.get(f"{BASE}/cards/1") + assert response.status_code in [401, 403] + + +# ============================================================================ +# Transactions Page +# ============================================================================ + + +@pytest.mark.integration +@pytest.mark.loyalty +class TestMerchantTransactionsPage: + """Tests for GET /merchants/loyalty/transactions.""" + + def test_transactions_page_renders(self, client, merchant_page_headers): + """Transactions page returns HTML.""" + response = client.get(f"{BASE}/transactions", headers=merchant_page_headers) + assert response.status_code == 200 + assert "text/html" in response.headers["content-type"] + + def test_transactions_page_requires_auth(self, client): + """Unauthenticated request is rejected.""" + response = client.get(f"{BASE}/transactions") + assert response.status_code in [401, 403] + + +# ============================================================================ +# Staff PINs Page +# ============================================================================ + + +@pytest.mark.integration +@pytest.mark.loyalty +class TestMerchantPinsPage: + """Tests for GET /merchants/loyalty/pins.""" + + def test_pins_page_renders(self, client, merchant_page_headers): + """PINs page returns HTML.""" + response = client.get(f"{BASE}/pins", headers=merchant_page_headers) + assert response.status_code == 200 + assert "text/html" in response.headers["content-type"] + + def test_pins_page_requires_auth(self, client): + """Unauthenticated request is rejected.""" + response = client.get(f"{BASE}/pins") + assert response.status_code in [401, 403] + + +# ============================================================================ +# Settings Page +# ============================================================================ + + +@pytest.mark.integration +@pytest.mark.loyalty +class TestMerchantSettingsPage: + """Tests for GET /merchants/loyalty/settings.""" + + def test_settings_page_renders(self, client, merchant_page_headers): + """Settings page returns HTML.""" + response = client.get(f"{BASE}/settings", headers=merchant_page_headers) + assert response.status_code == 200 + assert "text/html" in response.headers["content-type"] + + def test_settings_page_requires_auth(self, client): + """Unauthenticated request is rejected.""" + response = client.get(f"{BASE}/settings") + assert response.status_code in [401, 403] diff --git a/app/modules/loyalty/tests/integration/test_store_pages.py b/app/modules/loyalty/tests/integration/test_store_pages.py index b2afc9ce..dcd60b06 100644 --- a/app/modules/loyalty/tests/integration/test_store_pages.py +++ b/app/modules/loyalty/tests/integration/test_store_pages.py @@ -266,3 +266,32 @@ class TestStoreEnrollPage: store_code = loyalty_store_setup["store"].subdomain response = client.get(f"{_base(store_code)}/enroll") assert response.status_code in [401, 403] + + +# ============================================================================ +# Staff PINs Page +# ============================================================================ + + +@pytest.mark.integration +@pytest.mark.loyalty +class TestStorePinsPage: + """Tests for GET /store/{store_code}/loyalty/pins.""" + + def test_pins_page_renders( + self, client, loyalty_store_headers, loyalty_store_setup + ): + """PINs page returns HTML.""" + store_code = loyalty_store_setup["store"].subdomain + response = client.get( + f"{_base(store_code)}/pins", + headers=loyalty_store_headers, + ) + assert response.status_code == 200 + assert "text/html" in response.headers["content-type"] + + def test_pins_page_requires_auth(self, client, loyalty_store_setup): + """Unauthenticated request is rejected.""" + store_code = loyalty_store_setup["store"].subdomain + response = client.get(f"{_base(store_code)}/pins") + assert response.status_code in [401, 403] diff --git a/mkdocs.yml b/mkdocs.yml index 064d82e1..e0d6d29b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -214,6 +214,7 @@ nav: - Overview: modules/loyalty/index.md - Data Model: modules/loyalty/data-model.md - Business Logic: modules/loyalty/business-logic.md + - Pages & Navigation: modules/loyalty/pages-and-navigation.md - User Journeys: modules/loyalty/user-journeys.md - Program Analysis: modules/loyalty/program-analysis.md - UI Design: modules/loyalty/ui-design.md