Compare commits

...

4 Commits

Author SHA1 Message Date
3015a490f9 fix: mount .build-info as volume instead of relying on COPY
All checks were successful
CI / ruff (push) Successful in 16s
CI / pytest (push) Successful in 2h40m25s
CI / validate (push) Successful in 31s
CI / dependency-scanning (push) Successful in 37s
CI / docs (push) Successful in 50s
CI / deploy (push) Successful in 1m11s
Docker build cache can skip picking up the .build-info file during
COPY. Mounting it as a read-only volume ensures the container always
reads the current host file.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 13:53:42 +01:00
5b4ed79f87 fix(loyalty): add GET /merchants/{merchant_id}/program to admin API
The shared JS modules (cards-list, pins-list, transactions-list) all
call {apiPrefix}/program to load the program before fetching data. For
admin on-behalf pages, this resolved to GET /admin/loyalty/merchants/
{id}/program which only had a POST endpoint, causing 405 Method Not
Allowed errors on all admin on-behalf pages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 12:33:41 +01:00
52a5f941fe fix(loyalty): resolve 40 missing i18n keys across all frontends
Fix template references to match existing locale key names (11 renames
in pins-list.html and settings.html) and add 29 missing keys to all 4
locale files (en/fr/de/lb). All 299 template keys now resolve correctly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 20:52:38 +01:00
6161d69ba2 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>
2026-03-22 19:28:07 +01:00
50 changed files with 4579 additions and 74 deletions

View File

@@ -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,
),
],
),
],

View File

@@ -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

View 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).

View File

@@ -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",
@@ -141,27 +143,32 @@
},
"common": {
"active": "Aktiv",
"inactive": "Inaktiv",
"all_stores": "Alle Filialen",
"at": "bei",
"back": "Zurück",
"cancel": "Abbrechen",
"save": "Speichern",
"delete": "Löschen",
"confirm": "Bestätigen",
"refresh": "Aktualisieren",
"loading": "Laden...",
"saving": "Speichern...",
"view": "Anzeigen",
"continue": "Weiter",
"delete": "Löschen",
"disabled": "Deaktiviert",
"edit": "Bearbeiten",
"yes": "Ja",
"enabled": "Aktiviert",
"inactive": "Inaktiv",
"loading": "Laden...",
"minutes": "Minuten",
"never": "Nie",
"no": "Nein",
"none": "Keine",
"never": "Nie",
"total": "GESAMT",
"continue": "Weiter",
"back": "Zurück",
"points": "Punkte",
"minutes": "Minuten",
"or": "oder",
"at": "bei"
"points": "Punkte",
"program_not_setup": "Treueprogramm nicht eingerichtet",
"program_not_setup_desc": "Bitte richten Sie ein Treueprogramm ein",
"refresh": "Aktualisieren",
"save": "Speichern",
"saving": "Speichern...",
"total": "GESAMT",
"view": "Anzeigen",
"yes": "Ja"
},
"transactions": {
"card_created": "Angemeldet",
@@ -242,6 +249,112 @@
"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",
"adjust_filters": "Versuchen Sie, Ihre Filter anzupassen",
"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",
"type_adjust": "Anpassen",
"type_earn": "Verdienen",
"type_expire": "Ablaufen",
"type_redeem": "Einlösen"
},
"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_edit_hint": "Leer lassen um aktuellen PIN zu behalten",
"pin_edit_placeholder": "Neuer PIN (optional)",
"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 +473,33 @@
"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",
"error_loading": "Fehler beim Laden",
"loading": "Kartendetails werden geladen..."
},
"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 +584,49 @@
"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",
"error_loading": "Fehler beim Laden",
"loading": "Karten werden geladen..."
},
"card_detail": {
"title": "Kartendetail",
"card_label": "Karte",
"error_loading": "Fehler beim Laden",
"loading": "Kartendetails werden geladen..."
},
"transactions": {
"title": "Transaktionen",
"subtitle": "Alle Transaktionen über alle Standorte anzeigen",
"error_loading": "Fehler beim Laden",
"loading": "Transaktionen werden geladen..."
},
"pins": {
"title": "Mitarbeiter-PINs",
"subtitle": "PINs für alle Standorte verwalten",
"error_loading": "Fehler beim Laden",
"loading": "PINs werden geladen..."
},
"settings": {
"title": "Treue-Einstellungen",
"subtitle": "Treueprogramm-Einstellungen anzeigen",
"admin_controlled": "Diese Einstellungen werden vom Plattformadministrator verwaltet",
"error_loading": "Fehler beim Laden",
"loading": "Einstellungen werden geladen...",
"lockout_attempts": "Sperrversuche",
"lockout_minutes": "Sperrdauer (Min)",
"permissions": "Registrierung & Berechtigungen",
"pin_policy": "PIN-Richtlinie",
"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 +791,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",

View File

@@ -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": {
@@ -141,27 +143,32 @@
},
"common": {
"active": "Active",
"inactive": "Inactive",
"all_stores": "All Stores",
"at": "at",
"back": "Back",
"cancel": "Cancel",
"save": "Save",
"delete": "Delete",
"confirm": "Confirm",
"refresh": "Refresh",
"loading": "Loading...",
"saving": "Saving...",
"view": "View",
"continue": "Continue",
"delete": "Delete",
"disabled": "Disabled",
"edit": "Edit",
"yes": "Yes",
"enabled": "Enabled",
"inactive": "Inactive",
"loading": "Loading...",
"minutes": "minutes",
"never": "Never",
"no": "No",
"none": "None",
"never": "Never",
"total": "TOTAL",
"continue": "Continue",
"back": "Back",
"points": "points",
"minutes": "minutes",
"or": "or",
"at": "at"
"points": "points",
"program_not_setup": "Loyalty program not set up",
"program_not_setup_desc": "Please set up a loyalty program to get started",
"refresh": "Refresh",
"save": "Save",
"saving": "Saving...",
"total": "TOTAL",
"view": "View",
"yes": "Yes"
},
"transactions": {
"card_created": "Enrolled",
@@ -242,6 +249,112 @@
"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",
"adjust_filters": "Try adjusting your filters",
"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",
"type_adjust": "Adjust",
"type_earn": "Earn",
"type_expire": "Expire",
"type_redeem": "Redeem"
},
"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_edit_hint": "Leave blank to keep current PIN",
"pin_edit_placeholder": "New PIN (optional)",
"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 +473,33 @@
"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",
"error_loading": "Error loading card",
"loading": "Loading card details..."
},
"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 +584,49 @@
"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",
"error_loading": "Error loading cards",
"loading": "Loading cards..."
},
"card_detail": {
"title": "Card Detail",
"card_label": "Card",
"error_loading": "Error loading card",
"loading": "Loading card details..."
},
"transactions": {
"title": "Transactions",
"subtitle": "View all loyalty transactions across locations",
"error_loading": "Error loading transactions",
"loading": "Loading transactions..."
},
"pins": {
"title": "Staff PINs",
"subtitle": "Manage staff PINs across all locations",
"error_loading": "Error loading PINs",
"loading": "Loading PINs..."
},
"settings": {
"title": "Loyalty Settings",
"subtitle": "View loyalty program settings",
"admin_controlled": "These settings are managed by the platform administrator",
"error_loading": "Error loading settings",
"loading": "Loading settings...",
"lockout_attempts": "Lockout Attempts",
"lockout_minutes": "Lockout Duration (min)",
"permissions": "Enrollment & Permissions",
"pin_policy": "PIN Policy",
"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 +791,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",

View File

@@ -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",
@@ -141,27 +143,32 @@
},
"common": {
"active": "Actif",
"inactive": "Inactif",
"all_stores": "Tous les magasins",
"at": "à",
"back": "Retour",
"cancel": "Annuler",
"save": "Enregistrer",
"delete": "Supprimer",
"confirm": "Confirmer",
"refresh": "Actualiser",
"loading": "Chargement...",
"saving": "Enregistrement...",
"view": "Voir",
"continue": "Continuer",
"delete": "Supprimer",
"disabled": "Désactivé",
"edit": "Modifier",
"yes": "Oui",
"enabled": "Activé",
"inactive": "Inactif",
"loading": "Chargement...",
"minutes": "minutes",
"never": "Jamais",
"no": "Non",
"none": "Aucun",
"never": "Jamais",
"total": "TOTAL",
"continue": "Continuer",
"back": "Retour",
"points": "points",
"minutes": "minutes",
"or": "ou",
"at": "à"
"points": "points",
"program_not_setup": "Programme fidélité non configuré",
"program_not_setup_desc": "Veuillez configurer un programme fidélité",
"refresh": "Actualiser",
"save": "Enregistrer",
"saving": "Enregistrement...",
"total": "TOTAL",
"view": "Voir",
"yes": "Oui"
},
"transactions": {
"card_created": "Inscrit",
@@ -242,6 +249,112 @@
"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",
"adjust_filters": "Essayez de modifier vos filtres",
"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",
"type_adjust": "Ajuster",
"type_earn": "Gagner",
"type_expire": "Expirer",
"type_redeem": "Échanger"
},
"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_edit_hint": "Laisser vide pour garder le PIN actuel",
"pin_edit_placeholder": "Nouveau PIN (optionnel)",
"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 +473,33 @@
"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",
"error_loading": "Erreur lors du chargement",
"loading": "Chargement des détails..."
},
"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 +584,49 @@
"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",
"error_loading": "Erreur lors du chargement",
"loading": "Chargement des cartes..."
},
"card_detail": {
"title": "Détail de la carte",
"card_label": "Carte",
"error_loading": "Erreur lors du chargement",
"loading": "Chargement des détails..."
},
"transactions": {
"title": "Transactions",
"subtitle": "Voir toutes les transactions sur tous les sites",
"error_loading": "Erreur lors du chargement",
"loading": "Chargement des transactions..."
},
"pins": {
"title": "PINs du personnel",
"subtitle": "Gérer les PINs sur tous les sites",
"error_loading": "Erreur lors du chargement",
"loading": "Chargement des PINs..."
},
"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",
"error_loading": "Erreur lors du chargement",
"loading": "Chargement des paramètres...",
"lockout_attempts": "Tentatives avant verrouillage",
"lockout_minutes": "Durée de verrouillage (min)",
"permissions": "Inscription et permissions",
"pin_policy": "Politique PIN",
"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 +791,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é",

View File

@@ -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",
@@ -141,27 +143,32 @@
},
"common": {
"active": "Aktiv",
"inactive": "Inaktiv",
"all_stores": "All Geschäfter",
"at": "bei",
"back": "Zréck",
"cancel": "Ofbriechen",
"save": "Späicheren",
"delete": "Läschen",
"confirm": "Bestätegen",
"refresh": "Aktualiséieren",
"loading": "Lueden...",
"saving": "Späicheren...",
"view": "Kucken",
"continue": "Weider",
"delete": "Läschen",
"disabled": "Deaktivéiert",
"edit": "Beaarbechten",
"yes": "Jo",
"enabled": "Aktivéiert",
"inactive": "Inaktiv",
"loading": "Lueden...",
"minutes": "Minutten",
"never": "Ni",
"no": "Neen",
"none": "Keen",
"never": "Ni",
"total": "TOTAL",
"continue": "Weider",
"back": "Zréck",
"points": "Punkten",
"minutes": "Minutten",
"or": "oder",
"at": "bei"
"points": "Punkten",
"program_not_setup": "Treieprogramm net ageriicht",
"program_not_setup_desc": "Riicht w.e.g. en Treieprogramm an",
"refresh": "Aktualiséieren",
"save": "Späicheren",
"saving": "Späicheren...",
"total": "TOTAL",
"view": "Kucken",
"yes": "Jo"
},
"transactions": {
"card_created": "Ageschriwwen",
@@ -242,6 +249,112 @@
"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",
"adjust_filters": "Probéiert Är Filteren unzepassen",
"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",
"type_adjust": "Upassen",
"type_earn": "Verdéngen",
"type_expire": "Oflafen",
"type_redeem": "Aléisen"
},
"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_edit_hint": "Eidel loossen fir den aktuelle PIN ze behalen",
"pin_edit_placeholder": "Neie PIN (optional)",
"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 +473,33 @@
"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",
"error_loading": "Feeler beim Lueden",
"loading": "Kaartendetailer ginn gelueden..."
},
"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 +584,49 @@
"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",
"error_loading": "Feeler beim Lueden",
"loading": "Kaarte ginn gelueden..."
},
"card_detail": {
"title": "Kaartendetail",
"card_label": "Kaart",
"error_loading": "Feeler beim Lueden",
"loading": "Kaartendetailer ginn gelueden..."
},
"transactions": {
"title": "Transaktionen",
"subtitle": "All Transaktioune vun all Standuerter kucken",
"error_loading": "Feeler beim Lueden",
"loading": "Transaktioune ginn gelueden..."
},
"pins": {
"title": "Mataarbechter-PINen",
"subtitle": "PINen fir all Standuerter verwalten",
"error_loading": "Feeler beim Lueden",
"loading": "PINe ginn gelueden..."
},
"settings": {
"title": "Treieprogramm-Astellungen",
"subtitle": "Treieprogramm-Astellungen kucken",
"admin_controlled": "Dës Astellungen ginn vum Plattformadministrateur geréiert",
"error_loading": "Feeler beim Lueden",
"loading": "Astellunge ginn gelueden...",
"lockout_attempts": "Spärversich",
"lockout_minutes": "Spärdauer (Min)",
"permissions": "Umeldung & Berechtigungen",
"pin_policy": "PIN-Richtlinn",
"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 +791,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",

View File

@@ -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__)
@@ -113,6 +120,23 @@ def get_program_stats(
return ProgramStatsResponse(**stats)
@router.get("/merchants/{merchant_id}/program", response_model=ProgramResponse)
def get_merchant_program(
merchant_id: int = Path(..., gt=0),
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""Get a merchant's loyalty program (on behalf)."""
program = program_service.require_program_by_merchant(db, merchant_id)
response = ProgramResponse.model_validate(program)
response.is_stamps_enabled = program.is_stamps_enabled
response.is_points_enabled = program.is_points_enabled
response.display_name = program.display_name
return response
@router.post(
"/merchants/{merchant_id}/program", response_model=ProgramResponse, status_code=201
)
@@ -260,6 +284,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
# =============================================================================

View File

@@ -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
]

View File

@@ -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,

View File

@@ -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,
)

View File

@@ -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
# ============================================================================

View File

@@ -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",
]

View File

@@ -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."""

View File

@@ -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');

View File

@@ -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');

View 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');

View File

@@ -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');

View File

@@ -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');

View 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');

View File

@@ -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');

View 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');

View File

@@ -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');

View 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');

View 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');

View 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');

View File

@@ -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');

View 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');

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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>

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View 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 %}

View 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 %}

View 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.admin_controlled') }}</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 %}

View File

@@ -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 %}

View File

@@ -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>

View 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>

View 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_pins') }}</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_pins') }}</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.pin_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.pin_name') }}">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('loyalty.shared.pins.pin_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.pin_staff_id') }}">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('loyalty.shared.pins.pin_code') }}</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_code_hint') }}">
</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.pin_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.pin_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.pin_name') }}">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('loyalty.shared.pins.pin_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.pin_staff_id') }}">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('loyalty.shared.pins.pin_code') }}</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.pin_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.confirm_delete'),
'deletePin()',
'showDeleteModal',
confirm_text=_('loyalty.common.delete'),
cancel_text=_('loyalty.common.cancel'),
variant='danger'
) }}
{% endif %}

View File

@@ -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>

View 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 %}

View File

@@ -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]

View File

@@ -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]

View File

@@ -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]

View File

@@ -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]

View File

@@ -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]

View File

@@ -52,6 +52,7 @@ services:
volumes:
- ./logs:/app/logs
- ./uploads:/app/uploads
- ./.build-info:/app/.build-info:ro
mem_limit: 512m
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]

View File

@@ -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