feat(loyalty): cross-persona page alignment with shared components
Align loyalty pages across admin, merchant, and store personas so each sees the same page set scoped to their access level. Admin acts as a superset of merchant with "on behalf" capabilities. New pages: - Store: Staff PINs management (CRUD) - Merchant: Cards, Card Detail, Transactions, Staff PINs (CRUD), Settings (read-only) - Admin: Merchant Cards, Card Detail, Transactions, PINs (read-only) Architecture: - 4 shared Jinja2 partials (cards-list, card-detail, transactions, pins) - 4 shared JS factory modules parameterized by apiPrefix/scope - Persona templates are thin wrappers including shared partials - PinDetailResponse schema for cross-store PIN listings API: 17 new endpoints (11 merchant, 6 admin on-behalf) Tests: 38 new integration tests, arch-check green i18n: ~130 new keys across en/fr/de/lb Docs: pages-and-navigation.md with full page matrix Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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
|
||||
|
||||
191
app/modules/loyalty/docs/pages-and-navigation.md
Normal file
191
app/modules/loyalty/docs/pages-and-navigation.md
Normal file
@@ -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).
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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é",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
# =============================================================================
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
# ============================================================================
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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');
|
||||
@@ -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');
|
||||
23
app/modules/loyalty/static/admin/js/loyalty-merchant-pins.js
Normal file
23
app/modules/loyalty/static/admin/js/loyalty-merchant-pins.js
Normal file
@@ -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');
|
||||
@@ -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');
|
||||
@@ -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');
|
||||
18
app/modules/loyalty/static/merchant/js/loyalty-cards.js
Normal file
18
app/modules/loyalty/static/merchant/js/loyalty-cards.js
Normal file
@@ -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');
|
||||
@@ -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');
|
||||
18
app/modules/loyalty/static/merchant/js/loyalty-pins.js
Normal file
18
app/modules/loyalty/static/merchant/js/loyalty-pins.js
Normal file
@@ -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');
|
||||
@@ -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');
|
||||
156
app/modules/loyalty/static/shared/js/loyalty-card-detail-view.js
Normal file
156
app/modules/loyalty/static/shared/js/loyalty-card-detail-view.js
Normal file
@@ -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');
|
||||
247
app/modules/loyalty/static/shared/js/loyalty-cards-list.js
Normal file
247
app/modules/loyalty/static/shared/js/loyalty-cards-list.js
Normal file
@@ -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');
|
||||
283
app/modules/loyalty/static/shared/js/loyalty-pins-list.js
Normal file
283
app/modules/loyalty/static/shared/js/loyalty-pins-list.js
Normal file
@@ -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');
|
||||
@@ -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');
|
||||
18
app/modules/loyalty/static/store/js/loyalty-pins.js
Normal file
18
app/modules/loyalty/static/store/js/loyalty-pins.js
Normal file
@@ -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');
|
||||
@@ -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') %}
|
||||
<span x-text="card ? (card.customer_name || card.customer_email || '') : ''"></span>
|
||||
{% 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 %}
|
||||
<script defer src="{{ url_for('loyalty_static', path='shared/js/loyalty-card-detail-view.js') }}"></script>
|
||||
<script defer src="{{ url_for('loyalty_static', path='admin/js/loyalty-merchant-card-detail.js') }}"></script>
|
||||
{% endblock %}
|
||||
@@ -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')) %}
|
||||
<div class="flex items-center gap-3">
|
||||
{{ refresh_button(loading_var='loading', onclick='loadCards()', variant='secondary') }}
|
||||
<a href="/admin/loyalty/merchants/{{ merchant_id }}"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600">
|
||||
<span x-html="$icon('arrow-left', 'w-4 h-4 mr-2')"></span>
|
||||
{{ _('loyalty.common.back') }}
|
||||
</a>
|
||||
</div>
|
||||
{% 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 %}
|
||||
<script defer src="{{ url_for('loyalty_static', path='shared/js/loyalty-cards-list.js') }}"></script>
|
||||
<script defer src="{{ url_for('loyalty_static', path='admin/js/loyalty-merchant-cards.js') }}"></script>
|
||||
{% endblock %}
|
||||
@@ -46,6 +46,24 @@
|
||||
<span x-html="$icon('building-office', 'w-4 h-4 mr-2')"></span>
|
||||
{{ _('loyalty.admin.merchant_detail.view_merchant') }}
|
||||
</a>
|
||||
<a x-show="program"
|
||||
:href="`/admin/loyalty/merchants/${merchantId}/cards`"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-gray-700 transition-colors duration-150 bg-gray-100 border border-gray-300 rounded-lg hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-600">
|
||||
<span x-html="$icon('identification', 'w-4 h-4 mr-2')"></span>
|
||||
{{ _('loyalty.admin.merchant_detail.view_cards') }}
|
||||
</a>
|
||||
<a x-show="program"
|
||||
:href="`/admin/loyalty/merchants/${merchantId}/transactions`"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-gray-700 transition-colors duration-150 bg-gray-100 border border-gray-300 rounded-lg hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-600">
|
||||
<span x-html="$icon('clock', 'w-4 h-4 mr-2')"></span>
|
||||
{{ _('loyalty.admin.merchant_detail.view_transactions') }}
|
||||
</a>
|
||||
<a
|
||||
:href="`/admin/loyalty/merchants/${merchantId}/pins`"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-gray-700 transition-colors duration-150 bg-gray-100 border border-gray-300 rounded-lg hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-600">
|
||||
<span x-html="$icon('key', 'w-4 h-4 mr-2')"></span>
|
||||
{{ _('loyalty.admin.merchant_detail.view_pins') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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')) %}
|
||||
<div class="flex items-center gap-3">
|
||||
{{ refresh_button(loading_var='loading', onclick='loadPins()', variant='secondary') }}
|
||||
<a href="/admin/loyalty/merchants/{{ merchant_id }}"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600">
|
||||
<span x-html="$icon('arrow-left', 'w-4 h-4 mr-2')"></span>
|
||||
{{ _('loyalty.common.back') }}
|
||||
</a>
|
||||
</div>
|
||||
{% 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 %}
|
||||
<script defer src="{{ url_for('loyalty_static', path='shared/js/loyalty-pins-list.js') }}"></script>
|
||||
<script defer src="{{ url_for('loyalty_static', path='admin/js/loyalty-merchant-pins.js') }}"></script>
|
||||
{% endblock %}
|
||||
@@ -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')) %}
|
||||
<div class="flex items-center gap-3">
|
||||
{{ refresh_button(loading_var='loading', onclick='loadTransactions()', variant='secondary') }}
|
||||
<a href="/admin/loyalty/merchants/{{ merchant_id }}"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600">
|
||||
<span x-html="$icon('arrow-left', 'w-4 h-4 mr-2')"></span>
|
||||
{{ _('loyalty.common.back') }}
|
||||
</a>
|
||||
</div>
|
||||
{% 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 %}
|
||||
<script defer src="{{ url_for('loyalty_static', path='shared/js/loyalty-transactions-list.js') }}"></script>
|
||||
<script defer src="{{ url_for('loyalty_static', path='admin/js/loyalty-merchant-transactions.js') }}"></script>
|
||||
{% endblock %}
|
||||
@@ -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') }}: <span x-text="card?.card_number"></span>
|
||||
{% 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 %}
|
||||
<script defer src="{{ url_for('loyalty_static', path='shared/js/loyalty-card-detail-view.js') }}"></script>
|
||||
<script defer src="{{ url_for('loyalty_static', path='merchant/js/loyalty-card-detail.js') }}"></script>
|
||||
{% endblock %}
|
||||
34
app/modules/loyalty/templates/loyalty/merchant/cards.html
Normal file
34
app/modules/loyalty/templates/loyalty/merchant/cards.html
Normal file
@@ -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')) %}
|
||||
<div class="flex items-center gap-3">
|
||||
{{ refresh_button(loading_var='loading', onclick='loadCards()', variant='secondary') }}
|
||||
</div>
|
||||
{% 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 %}
|
||||
<script defer src="{{ url_for('loyalty_static', path='shared/js/loyalty-cards-list.js') }}"></script>
|
||||
<script defer src="{{ url_for('loyalty_static', path='merchant/js/loyalty-cards.js') }}"></script>
|
||||
{% endblock %}
|
||||
38
app/modules/loyalty/templates/loyalty/merchant/pins.html
Normal file
38
app/modules/loyalty/templates/loyalty/merchant/pins.html
Normal file
@@ -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')) %}
|
||||
<div class="flex items-center gap-3">
|
||||
{{ refresh_button(loading_var='loading', onclick='loadPins()', variant='secondary') }}
|
||||
<button @click="openCreateModal()" x-show="program"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700">
|
||||
<span x-html="$icon('plus', 'w-4 h-4 mr-2')"></span>
|
||||
{{ _('loyalty.shared.pins.create_pin') }}
|
||||
</button>
|
||||
</div>
|
||||
{% 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 %}
|
||||
<script defer src="{{ url_for('loyalty_static', path='shared/js/loyalty-pins-list.js') }}"></script>
|
||||
<script defer src="{{ url_for('loyalty_static', path='merchant/js/loyalty-pins.js') }}"></script>
|
||||
{% endblock %}
|
||||
89
app/modules/loyalty/templates/loyalty/merchant/settings.html
Normal file
89
app/modules/loyalty/templates/loyalty/merchant/settings.html
Normal file
@@ -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')) }}
|
||||
|
||||
<!-- Managed by Admin Notice -->
|
||||
<div x-show="!loading && !error" class="mb-6 px-4 py-3 bg-blue-50 border border-blue-200 rounded-lg dark:bg-blue-900/20 dark:border-blue-800">
|
||||
<div class="flex items-center">
|
||||
<span x-html="$icon('information-circle', 'w-5 h-5 text-blue-500 mr-2 flex-shrink-0')"></span>
|
||||
<p class="text-sm text-blue-700 dark:text-blue-300">{{ _('loyalty.merchant.settings.managed_by_admin') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings Display -->
|
||||
<div x-show="!loading && !error && settings" class="space-y-6">
|
||||
|
||||
<!-- Staff PIN Policy -->
|
||||
<div class="px-4 py-5 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
<span x-html="$icon('key', 'inline w-5 h-5 mr-2')"></span>
|
||||
{{ _('loyalty.merchant.settings.staff_pin_policy') }}
|
||||
</h3>
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.merchant.settings.pin_policy') }}</p>
|
||||
<p class="mt-1 text-sm text-gray-700 dark:text-gray-300">
|
||||
<span class="px-2 py-1 text-xs font-semibold rounded-full bg-gray-100 dark:bg-gray-700"
|
||||
x-text="settings?.staff_pin_policy || '-'"></span>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.merchant.settings.lockout_attempts') }}</p>
|
||||
<p class="mt-1 text-sm text-gray-700 dark:text-gray-300" x-text="settings?.staff_pin_lockout_attempts || '-'"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.merchant.settings.lockout_minutes') }}</p>
|
||||
<p class="mt-1 text-sm text-gray-700 dark:text-gray-300" x-text="settings?.staff_pin_lockout_minutes || '-'"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Enrollment & Permissions -->
|
||||
<div class="px-4 py-5 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
<span x-html="$icon('shield-check', 'inline w-5 h-5 mr-2')"></span>
|
||||
{{ _('loyalty.merchant.settings.permissions') }}
|
||||
</h3>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg dark:bg-gray-700">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{ _('loyalty.merchant.settings.self_enrollment') }}</span>
|
||||
<span class="px-2 py-1 text-xs font-semibold rounded-full"
|
||||
:class="settings?.allow_self_enrollment ? 'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100' : 'text-gray-700 bg-gray-100 dark:bg-gray-700 dark:text-gray-100'"
|
||||
x-text="settings?.allow_self_enrollment ? '{{ _('loyalty.common.enabled') }}' : '{{ _('loyalty.common.disabled') }}'"></span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg dark:bg-gray-700">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{ _('loyalty.merchant.settings.cross_location') }}</span>
|
||||
<span class="px-2 py-1 text-xs font-semibold rounded-full"
|
||||
:class="settings?.allow_cross_location_redemption ? 'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100' : 'text-gray-700 bg-gray-100 dark:bg-gray-700 dark:text-gray-100'"
|
||||
x-text="settings?.allow_cross_location_redemption ? '{{ _('loyalty.common.enabled') }}' : '{{ _('loyalty.common.disabled') }}'"></span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg dark:bg-gray-700">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{ _('loyalty.merchant.settings.void_transactions') }}</span>
|
||||
<span class="px-2 py-1 text-xs font-semibold rounded-full"
|
||||
:class="settings?.allow_void_transactions ? 'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100' : 'text-gray-700 bg-gray-100 dark:bg-gray-700 dark:text-gray-100'"
|
||||
x-text="settings?.allow_void_transactions ? '{{ _('loyalty.common.enabled') }}' : '{{ _('loyalty.common.disabled') }}'"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script defer src="{{ url_for('loyalty_static', path='merchant/js/loyalty-merchant-settings.js') }}"></script>
|
||||
{% endblock %}
|
||||
@@ -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')) %}
|
||||
<div class="flex items-center gap-3">
|
||||
{{ refresh_button(loading_var='loading', onclick='loadTransactions()', variant='secondary') }}
|
||||
</div>
|
||||
{% 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 %}
|
||||
<script defer src="{{ url_for('loyalty_static', path='shared/js/loyalty-transactions-list.js') }}"></script>
|
||||
<script defer src="{{ url_for('loyalty_static', path='merchant/js/loyalty-transactions.js') }}"></script>
|
||||
{% endblock %}
|
||||
@@ -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 %}
|
||||
|
||||
<!-- Quick Stats -->
|
||||
<div class="grid gap-6 mb-8 md:grid-cols-4">
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-purple-500 bg-purple-100 rounded-full dark:text-purple-100 dark:bg-purple-500">
|
||||
<span x-html="$icon('currency-dollar', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">{{ _('loyalty.shared.card_detail.points_balance') }}</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="formatNumber(card?.points_balance)">0</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-green-500 bg-green-100 rounded-full dark:text-green-100 dark:bg-green-500">
|
||||
<span x-html="$icon('trending-up', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">{{ _('loyalty.shared.card_detail.total_earned') }}</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="formatNumber(card?.total_points_earned)">0</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-orange-500 bg-orange-100 rounded-full dark:text-orange-100 dark:bg-orange-500">
|
||||
<span x-html="$icon('gift', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">{{ _('loyalty.shared.card_detail.total_redeemed') }}</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="formatNumber(card?.total_points_redeemed)">0</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-blue-500 bg-blue-100 rounded-full dark:text-blue-100 dark:bg-blue-500">
|
||||
<span x-html="$icon('calendar', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">{{ _('loyalty.shared.card_detail.member_since') }}</p>
|
||||
<p class="text-sm font-semibold text-gray-700 dark:text-gray-200" x-text="formatDate(card?.created_at)">-</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Customer Info + Card Info (2-column) -->
|
||||
<div class="grid gap-6 mb-8 md:grid-cols-2">
|
||||
<!-- Customer Info -->
|
||||
<div class="px-4 py-5 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
<span x-html="$icon('user', 'inline w-5 h-5 mr-2')"></span>
|
||||
{{ _('loyalty.shared.card_detail.customer_information') }}
|
||||
</h3>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.shared.card_detail.name') }}</p>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="card?.customer_name || '-'">-</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.shared.card_detail.email') }}</p>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="card?.customer_email || '-'">-</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.shared.card_detail.phone') }}</p>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="card?.customer_phone || '-'">-</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.shared.card_detail.birthday') }}</p>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="card?.customer_birthday || '-'">-</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card Info -->
|
||||
<div class="px-4 py-5 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
<span x-html="$icon('credit-card', 'inline w-5 h-5 mr-2')"></span>
|
||||
{{ _('loyalty.shared.card_detail.card_details') }}
|
||||
</h3>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.shared.card_detail.card_number') }}</p>
|
||||
<p class="text-sm font-mono text-gray-700 dark:text-gray-300" x-text="card?.card_number">-</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.shared.card_detail.status') }}</p>
|
||||
<span class="px-2 py-1 text-xs font-semibold rounded-full"
|
||||
:class="card?.is_active ? 'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100' : 'text-gray-700 bg-gray-100 dark:bg-gray-700 dark:text-gray-100'"
|
||||
x-text="card?.is_active ? $t('loyalty.common.active') : $t('loyalty.common.inactive')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.shared.card_detail.last_activity') }}</p>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="formatDate(card?.last_activity_at) || 'Never'">-</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.shared.card_detail.enrolled_at') }}</p>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="card?.enrolled_at_store_name || 'Unknown'">-</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Transaction History -->
|
||||
<div class="px-4 py-5 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
<span x-html="$icon('clock', 'inline w-5 h-5 mr-2')"></span>
|
||||
{{ _('loyalty.shared.card_detail.transaction_history') }}
|
||||
</h3>
|
||||
{% 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')]) }}
|
||||
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
|
||||
<template x-if="transactions.length === 0">
|
||||
<tr>
|
||||
<td colspan="5" class="px-4 py-6 text-center text-gray-500 dark:text-gray-400">
|
||||
<div class="flex flex-col items-center">
|
||||
<span x-html="$icon('clock', 'w-12 h-12 mb-2 text-gray-300')"></span>
|
||||
<p class="font-medium">{{ _('loyalty.shared.card_detail.no_transactions') }}</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<template x-for="tx in transactions" :key="tx.id">
|
||||
<tr class="text-gray-700 dark:text-gray-400">
|
||||
<td class="px-4 py-3 text-sm" x-text="formatDateTime(tx.transaction_at)"></td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="px-2 py-1 text-xs font-semibold rounded-full"
|
||||
:class="{
|
||||
'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100': tx.points_delta > 0,
|
||||
'text-orange-700 bg-orange-100 dark:bg-orange-700 dark:text-orange-100': tx.points_delta < 0
|
||||
}"
|
||||
x-text="tx.transaction_type.replace(/_/g, ' ')"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm font-medium"
|
||||
:class="tx.points_delta > 0 ? 'text-green-600' : 'text-orange-600'"
|
||||
x-text="(tx.points_delta > 0 ? '+' : '') + formatNumber(tx.points_delta)"></td>
|
||||
<td class="px-4 py-3 text-sm" x-text="tx.store_name || '-'"></td>
|
||||
<td class="px-4 py-3 text-sm text-gray-500" x-text="tx.notes || '-'"></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
{% endcall %}
|
||||
</div>
|
||||
138
app/modules/loyalty/templates/loyalty/shared/cards-list.html
Normal file
138
app/modules/loyalty/templates/loyalty/shared/cards-list.html
Normal file
@@ -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 %}
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div x-show="!loading && program" class="grid gap-6 mb-8 md:grid-cols-4">
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-purple-500 bg-purple-100 rounded-full dark:text-purple-100 dark:bg-purple-500">
|
||||
<span x-html="$icon('users', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">{{ _('loyalty.shared.cards.total_members') }}</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="formatNumber(stats.total_cards)">0</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-green-500 bg-green-100 rounded-full dark:text-green-100 dark:bg-green-500">
|
||||
<span x-html="$icon('check-circle', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">{{ _('loyalty.shared.cards.active_30d') }}</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="formatNumber(stats.active_cards)">0</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-blue-500 bg-blue-100 rounded-full dark:text-blue-100 dark:bg-blue-500">
|
||||
<span x-html="$icon('user-plus', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">{{ _('loyalty.shared.cards.new_this_month') }}</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="formatNumber(stats.new_this_month)">0</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-orange-500 bg-orange-100 rounded-full dark:text-orange-100 dark:bg-orange-500">
|
||||
<span x-html="$icon('currency-dollar', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">{{ _('loyalty.shared.cards.total_points_balance') }}</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="formatNumber(stats.total_points_balance)">0</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search and Filters -->
|
||||
<div x-show="!loading && program" class="mb-6 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<div class="relative">
|
||||
<span class="absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<span x-html="$icon('search', 'w-5 h-5 text-gray-400')"></span>
|
||||
</span>
|
||||
<input type="text"
|
||||
x-model="filters.search"
|
||||
@input="debouncedSearch()"
|
||||
placeholder="{{ _('loyalty.shared.cards.search_placeholder') }}"
|
||||
class="w-full pl-10 pr-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
|
||||
</div>
|
||||
</div>
|
||||
<select x-model="filters.status" @change="applyFilter()"
|
||||
class="px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
|
||||
<option value="">{{ _('loyalty.shared.cards.all_status') }}</option>
|
||||
<option value="active">{{ _('loyalty.common.active') }}</option>
|
||||
<option value="inactive">{{ _('loyalty.common.inactive') }}</option>
|
||||
</select>
|
||||
{% if show_store_filter %}
|
||||
<select x-model="filters.store_id" @change="applyFilter()"
|
||||
class="px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
|
||||
<option value="">{{ _('loyalty.common.all_stores') }}</option>
|
||||
<template x-for="loc in locations" :key="loc.store_id">
|
||||
<option :value="loc.store_id" x-text="loc.store_name"></option>
|
||||
</template>
|
||||
</select>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cards Table -->
|
||||
<div x-show="!loading && program">
|
||||
{% 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')]) }}
|
||||
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
|
||||
<template x-if="cards.length === 0">
|
||||
<tr>
|
||||
<td colspan="6" class="px-4 py-8 text-center text-gray-600 dark:text-gray-400">
|
||||
<div class="flex flex-col items-center">
|
||||
<span x-html="$icon('users', 'w-12 h-12 mb-2 text-gray-300')"></span>
|
||||
<p class="font-medium">{{ _('loyalty.shared.cards.no_members') }}</p>
|
||||
<p class="text-xs mt-1" x-show="filters.search">{{ _('loyalty.shared.cards.adjust_search') }}</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<template x-for="card in cards" :key="card.id">
|
||||
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center text-sm">
|
||||
<div class="relative hidden w-8 h-8 mr-3 rounded-full md:block"
|
||||
:style="'background-color: ' + (program?.card_color || '#4F46E5') + '20'">
|
||||
<span class="absolute inset-0 flex items-center justify-center text-xs font-semibold"
|
||||
:style="'color: ' + (program?.card_color || '#4F46E5')"
|
||||
x-text="card.customer_name?.charAt(0).toUpperCase() || '?'"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-semibold" x-text="card.customer_name || 'Unknown'"></p>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400" x-text="card.customer_email || card.customer_phone || '-'"></p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm font-mono" x-text="card.card_number"></td>
|
||||
<td class="px-4 py-3 text-sm font-semibold" x-text="formatNumber(card.points_balance)"></td>
|
||||
<td class="px-4 py-3 text-sm" x-text="formatDate(card.last_activity_at)"></td>
|
||||
<td class="px-4 py-3 text-xs">
|
||||
<span class="px-2 py-1 font-semibold leading-tight rounded-full"
|
||||
:class="card.is_active ? 'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100' : 'text-gray-700 bg-gray-100 dark:bg-gray-700 dark:text-gray-100'"
|
||||
x-text="card.is_active ? $t('loyalty.common.active') : $t('loyalty.common.inactive')"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<a :href="'{{ cards_base_url }}/' + card.id"
|
||||
class="text-purple-600 hover:text-purple-700 dark:text-purple-400">{{ _('loyalty.common.view') }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
{% endcall %}
|
||||
|
||||
{{ pagination() }}
|
||||
</div>
|
||||
242
app/modules/loyalty/templates/loyalty/shared/pins-list.html
Normal file
242
app/modules/loyalty/templates/loyalty/shared/pins-list.html
Normal file
@@ -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 %}
|
||||
|
||||
<!-- Stats Summary -->
|
||||
<div x-show="!loading" class="mb-6 flex flex-wrap items-center gap-6 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="flex items-center">
|
||||
<div class="p-2 mr-3 text-purple-500 bg-purple-100 rounded-full dark:text-purple-100 dark:bg-purple-500">
|
||||
<span x-html="$icon('key', 'w-4 h-4')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-600 dark:text-gray-400">{{ _('loyalty.shared.pins.total_pins') }}</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="formatNumber(pinStats.total)">0</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="p-2 mr-3 text-green-500 bg-green-100 rounded-full dark:text-green-100 dark:bg-green-500">
|
||||
<span x-html="$icon('check-circle', 'w-4 h-4')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-600 dark:text-gray-400">{{ _('loyalty.shared.pins.active') }}</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="formatNumber(pinStats.active)">0</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="p-2 mr-3 text-red-500 bg-red-100 rounded-full dark:text-red-100 dark:bg-red-500">
|
||||
<span x-html="$icon('lock-closed', 'w-4 h-4')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-600 dark:text-gray-400">{{ _('loyalty.shared.pins.locked') }}</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="formatNumber(pinStats.locked)">0</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Bar -->
|
||||
<div x-show="!loading" class="mb-6 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
{% if show_store_filter %}
|
||||
<select x-model="filters.store_id" @change="applyFilter()"
|
||||
class="px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
|
||||
<option value="">{{ _('loyalty.common.all_stores') }}</option>
|
||||
<template x-for="loc in locations" :key="loc.store_id">
|
||||
<option :value="loc.store_id" x-text="loc.store_name"></option>
|
||||
</template>
|
||||
</select>
|
||||
{% endif %}
|
||||
<select x-model="filters.status" @change="applyFilter()"
|
||||
class="px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
|
||||
<option value="">{{ _('loyalty.shared.pins.all_status') }}</option>
|
||||
<option value="active">{{ _('loyalty.common.active') }}</option>
|
||||
<option value="inactive">{{ _('loyalty.common.inactive') }}</option>
|
||||
<option value="locked">{{ _('loyalty.shared.pins.status_locked') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PINs Table -->
|
||||
<div x-show="!loading">
|
||||
{% 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 %}
|
||||
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
|
||||
<template x-if="pins.length === 0">
|
||||
<tr>
|
||||
<td :colspan="'{{ '7' if show_store_filter else '6' }}'" class="px-4 py-8 text-center text-gray-600 dark:text-gray-400">
|
||||
<div class="flex flex-col items-center">
|
||||
<span x-html="$icon('key', 'w-12 h-12 mb-2 text-gray-300')"></span>
|
||||
<p class="font-medium">{{ _('loyalty.shared.pins.no_pins') }}</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<template x-for="pin in pins" :key="pin.id">
|
||||
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<td class="px-4 py-3 text-sm font-semibold" x-text="pin.name"></td>
|
||||
<td class="px-4 py-3 text-sm font-mono" x-text="pin.staff_id"></td>
|
||||
{% if show_store_filter %}
|
||||
<td class="px-4 py-3 text-sm" x-text="pin.store_name || '-'"></td>
|
||||
{% endif %}
|
||||
<td class="px-4 py-3 text-xs">
|
||||
<span class="px-2 py-1 font-semibold leading-tight rounded-full"
|
||||
:class="pin.is_active ? 'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100' : 'text-gray-700 bg-gray-100 dark:bg-gray-700 dark:text-gray-100'"
|
||||
x-text="pin.is_active ? $t('loyalty.common.active') : $t('loyalty.common.inactive')"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-xs">
|
||||
<span x-show="pin.is_locked" class="px-2 py-1 font-semibold leading-tight rounded-full text-red-700 bg-red-100 dark:bg-red-700 dark:text-red-100">{{ _('loyalty.shared.pins.status_locked') }}</span>
|
||||
<span x-show="!pin.is_locked" class="text-sm text-gray-400">-</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm" x-text="pin.last_used_at ? formatDate(pin.last_used_at) : '-'"></td>
|
||||
<td class="px-4 py-3">
|
||||
{% if show_crud %}
|
||||
<div class="flex items-center gap-2">
|
||||
<button @click="openEditPin(pin)"
|
||||
class="text-purple-600 hover:text-purple-700 dark:text-purple-400 text-sm">
|
||||
<span x-html="$icon('pencil', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
<button @click="confirmDeletePin(pin)"
|
||||
class="text-red-600 hover:text-red-700 dark:text-red-400 text-sm">
|
||||
<span x-html="$icon('trash', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
<button x-show="pin.is_locked" @click="unlockPin(pin)"
|
||||
class="text-orange-600 hover:text-orange-700 dark:text-orange-400 text-sm"
|
||||
:title="$t('loyalty.shared.pins.unlock')">
|
||||
<span x-html="$icon('lock-open', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="text-sm text-gray-400">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
{% endcall %}
|
||||
|
||||
{{ pagination() }}
|
||||
</div>
|
||||
|
||||
{% if show_crud %}
|
||||
<!-- Create PIN Modal -->
|
||||
{% call modal('createPinModal', _('loyalty.shared.pins.create_pin'), 'showCreateModal', size='md', show_footer=false) %}
|
||||
<form @submit.prevent="createPin()">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('loyalty.shared.pins.field_name') }}</label>
|
||||
<input type="text" x-model="pinForm.name" required
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
|
||||
placeholder="{{ _('loyalty.shared.pins.name_placeholder') }}">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('loyalty.shared.pins.field_staff_id') }}</label>
|
||||
<input type="text" x-model="pinForm.staff_id" required
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
|
||||
placeholder="{{ _('loyalty.shared.pins.staff_id_placeholder') }}">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('loyalty.shared.pins.field_pin') }}</label>
|
||||
<input type="password" x-model="pinForm.pin" required minlength="4" maxlength="8"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
|
||||
placeholder="{{ _('loyalty.shared.pins.pin_placeholder') }}">
|
||||
</div>
|
||||
{% if show_store_filter %}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('loyalty.shared.pins.field_store') }}</label>
|
||||
<select x-model="pinForm.store_id" required
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
|
||||
<option value="">{{ _('loyalty.shared.pins.select_store') }}</option>
|
||||
<template x-for="loc in locations" :key="loc.store_id">
|
||||
<option :value="loc.store_id" x-text="loc.store_name"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-3 mt-6">
|
||||
<button type="button" @click="showCreateModal = false"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors">
|
||||
{{ _('loyalty.common.cancel') }}
|
||||
</button>
|
||||
<button type="submit" :disabled="saving"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
|
||||
<span x-show="saving" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-text="saving ? $t('loyalty.common.saving') : $t('loyalty.shared.pins.create_pin')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endcall %}
|
||||
|
||||
<!-- Edit PIN Modal -->
|
||||
{% call modal('editPinModal', _('loyalty.shared.pins.edit_pin'), 'showEditModal', size='md', show_footer=false) %}
|
||||
<form @submit.prevent="updatePin()">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('loyalty.shared.pins.field_name') }}</label>
|
||||
<input type="text" x-model="pinForm.name" required
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
|
||||
placeholder="{{ _('loyalty.shared.pins.name_placeholder') }}">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('loyalty.shared.pins.field_staff_id') }}</label>
|
||||
<input type="text" x-model="pinForm.staff_id" required
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
|
||||
placeholder="{{ _('loyalty.shared.pins.staff_id_placeholder') }}">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('loyalty.shared.pins.field_pin') }}</label>
|
||||
<input type="password" x-model="pinForm.pin" minlength="4" maxlength="8"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
|
||||
placeholder="{{ _('loyalty.shared.pins.pin_edit_placeholder') }}">
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ _('loyalty.shared.pins.pin_edit_hint') }}</p>
|
||||
</div>
|
||||
{% if show_store_filter %}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('loyalty.shared.pins.field_store') }}</label>
|
||||
<select x-model="pinForm.store_id" required
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
|
||||
<option value="">{{ _('loyalty.shared.pins.select_store') }}</option>
|
||||
<template x-for="loc in locations" :key="loc.store_id">
|
||||
<option :value="loc.store_id" x-text="loc.store_name"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-3 mt-6">
|
||||
<button type="button" @click="showEditModal = false"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors">
|
||||
{{ _('loyalty.common.cancel') }}
|
||||
</button>
|
||||
<button type="submit" :disabled="saving"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
|
||||
<span x-show="saving" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-text="saving ? $t('loyalty.common.saving') : $t('loyalty.shared.pins.save_changes')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endcall %}
|
||||
|
||||
<!-- Delete Confirm Modal -->
|
||||
{{ 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 %}
|
||||
@@ -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 %}
|
||||
|
||||
<!-- Filter Bar -->
|
||||
<div x-show="!loading" class="mb-6 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<select x-model="filters.type" @change="applyFilter()"
|
||||
class="px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
|
||||
<option value="">{{ _('loyalty.shared.transactions.all_types') }}</option>
|
||||
<option value="earn">{{ _('loyalty.shared.transactions.type_earn') }}</option>
|
||||
<option value="redeem">{{ _('loyalty.shared.transactions.type_redeem') }}</option>
|
||||
<option value="adjust">{{ _('loyalty.shared.transactions.type_adjust') }}</option>
|
||||
<option value="expire">{{ _('loyalty.shared.transactions.type_expire') }}</option>
|
||||
</select>
|
||||
{% if show_store_filter %}
|
||||
<select x-model="filters.store_id" @change="applyFilter()"
|
||||
class="px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
|
||||
<option value="">{{ _('loyalty.common.all_stores') }}</option>
|
||||
<template x-for="loc in locations" :key="loc.store_id">
|
||||
<option :value="loc.store_id" x-text="loc.store_name"></option>
|
||||
</template>
|
||||
</select>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Transactions Table -->
|
||||
<div x-show="!loading">
|
||||
{% 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')]) }}
|
||||
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
|
||||
<template x-if="transactions.length === 0">
|
||||
<tr>
|
||||
<td colspan="6" class="px-4 py-8 text-center text-gray-600 dark:text-gray-400">
|
||||
<div class="flex flex-col items-center">
|
||||
<span x-html="$icon('clock', 'w-12 h-12 mb-2 text-gray-300')"></span>
|
||||
<p class="font-medium">{{ _('loyalty.shared.transactions.no_transactions') }}</p>
|
||||
<p class="text-xs mt-1" x-show="filters.type">{{ _('loyalty.shared.transactions.adjust_filters') }}</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<template x-for="tx in transactions" :key="tx.id">
|
||||
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<td class="px-4 py-3 text-sm" x-text="formatDateTime(tx.transaction_at)"></td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center text-sm">
|
||||
<div class="relative hidden w-8 h-8 mr-3 rounded-full md:block"
|
||||
:style="'background-color: ' + (program?.card_color || '#4F46E5') + '20'">
|
||||
<span class="absolute inset-0 flex items-center justify-center text-xs font-semibold"
|
||||
:style="'color: ' + (program?.card_color || '#4F46E5')"
|
||||
x-text="tx.customer_name?.charAt(0).toUpperCase() || '?'"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-semibold" x-text="tx.customer_name || 'Unknown'"></p>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400" x-text="tx.card_number || '-'"></p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="px-2 py-1 text-xs font-semibold rounded-full"
|
||||
:class="{
|
||||
'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100': tx.points_delta > 0,
|
||||
'text-orange-700 bg-orange-100 dark:bg-orange-700 dark:text-orange-100': tx.points_delta < 0
|
||||
}"
|
||||
x-text="tx.transaction_type.replace(/_/g, ' ')"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm font-medium"
|
||||
:class="tx.points_delta > 0 ? 'text-green-600' : 'text-orange-600'"
|
||||
x-text="(tx.points_delta > 0 ? '+' : '') + formatNumber(tx.points_delta)"></td>
|
||||
<td class="px-4 py-3 text-sm" x-text="tx.store_name || '-'"></td>
|
||||
<td class="px-4 py-3 text-sm text-gray-500" x-text="tx.notes || '-'"></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
{% endcall %}
|
||||
|
||||
{{ pagination() }}
|
||||
</div>
|
||||
49
app/modules/loyalty/templates/loyalty/store/pins.html
Normal file
49
app/modules/loyalty/templates/loyalty/store/pins.html
Normal file
@@ -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 %}
|
||||
<!-- Page Header -->
|
||||
{% call page_header_flex(title=_('loyalty.store.pins.title'), subtitle=_('loyalty.store.pins.subtitle')) %}
|
||||
<div class="flex items-center gap-3">
|
||||
{{ refresh_button(loading_var='loading', onclick='loadPins()', variant='secondary') }}
|
||||
<button @click="openCreateModal()" x-show="program"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700">
|
||||
<span x-html="$icon('plus', 'w-4 h-4 mr-2')"></span>
|
||||
{{ _('loyalty.shared.pins.create_pin') }}
|
||||
</button>
|
||||
</div>
|
||||
{% endcall %}
|
||||
|
||||
{{ loading_state(_('loyalty.store.pins.loading')) }}
|
||||
{{ error_state(_('loyalty.store.pins.error_loading')) }}
|
||||
|
||||
<!-- No Program Setup Notice (same pattern as cards.html) -->
|
||||
<div x-show="!loading && !program" class="mb-6 px-4 py-5 bg-yellow-50 border border-yellow-200 rounded-lg dark:bg-yellow-900/20 dark:border-yellow-800">
|
||||
<div class="flex items-start">
|
||||
<span x-html="$icon('exclamation-triangle', 'w-6 h-6 text-yellow-500 mr-3 flex-shrink-0')"></span>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-yellow-800 dark:text-yellow-200">{{ _('loyalty.common.program_not_setup') }}</h3>
|
||||
<p class="mt-1 text-sm text-yellow-700 dark:text-yellow-300">{{ _('loyalty.common.program_not_setup_desc') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% 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 %}
|
||||
<script defer src="{{ url_for('loyalty_static', path='shared/js/loyalty-pins-list.js') }}"></script>
|
||||
<script defer src="{{ url_for('loyalty_static', path='store/js/loyalty-pins.js') }}"></script>
|
||||
{% endblock %}
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user