feat(loyalty): implement Phase 2 - company-wide points system

Complete implementation of loyalty module Phase 2 features:

Database & Models:
- Add company_id to LoyaltyProgram for chain-wide loyalty
- Add company_id to LoyaltyCard for multi-location support
- Add CompanyLoyaltySettings model for admin-controlled settings
- Add points expiration, welcome bonus, and minimum redemption fields
- Add POINTS_EXPIRED, WELCOME_BONUS transaction types

Services:
- Update program_service for company-based queries
- Update card_service with enrollment and welcome bonus
- Update points_service with void_points for returns
- Update stamp_service for company context
- Update pin_service for company-wide operations

API Endpoints:
- Admin: Program listing with stats, company detail views
- Vendor: Terminal operations, card management, settings
- Storefront: Customer card/transactions, self-enrollment

UI Templates:
- Admin: Programs dashboard, company detail, settings
- Vendor: Terminal, cards list, card detail, settings, stats, enrollment
- Storefront: Dashboard, history, enrollment, success pages

Background Tasks:
- Point expiration task (daily, based on inactivity)
- Wallet sync task (hourly)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-05 22:10:27 +01:00
parent 3bdf1695fd
commit d8f3338bc8
54 changed files with 7252 additions and 186 deletions

View File

@@ -12,8 +12,10 @@ Usage:
LoyaltyTransaction,
StaffPin,
AppleDeviceRegistration,
CompanyLoyaltySettings,
LoyaltyType,
TransactionType,
StaffPinPolicy,
)
"""
@@ -41,15 +43,23 @@ from app.modules.loyalty.models.apple_device import (
# Model
AppleDeviceRegistration,
)
from app.modules.loyalty.models.company_settings import (
# Enums
StaffPinPolicy,
# Model
CompanyLoyaltySettings,
)
__all__ = [
# Enums
"LoyaltyType",
"TransactionType",
"StaffPinPolicy",
# Models
"LoyaltyProgram",
"LoyaltyCard",
"LoyaltyTransaction",
"StaffPin",
"AppleDeviceRegistration",
"CompanyLoyaltySettings",
]

View File

@@ -0,0 +1,135 @@
# app/modules/loyalty/models/company_settings.py
"""
Company loyalty settings database model.
Admin-controlled settings that apply to a company's loyalty program.
These settings are managed by platform administrators, not vendors.
"""
from sqlalchemy import (
Boolean,
Column,
ForeignKey,
Index,
Integer,
String,
)
from sqlalchemy.orm import relationship
from app.core.database import Base
from models.database.base import TimestampMixin
class StaffPinPolicy(str):
"""Staff PIN policy options."""
REQUIRED = "required" # Staff PIN always required
OPTIONAL = "optional" # Vendor can choose
DISABLED = "disabled" # Staff PIN not used
class CompanyLoyaltySettings(Base, TimestampMixin):
"""
Admin-controlled settings for company loyalty programs.
These settings are managed by platform administrators and
cannot be changed by vendors. They apply to all vendors
within the company.
"""
__tablename__ = "company_loyalty_settings"
id = Column(Integer, primary_key=True, index=True)
# Company association (one settings per company)
company_id = Column(
Integer,
ForeignKey("companies.id", ondelete="CASCADE"),
unique=True,
nullable=False,
index=True,
comment="Company these settings apply to",
)
# =========================================================================
# Staff PIN Policy (Admin-controlled)
# =========================================================================
staff_pin_policy = Column(
String(20),
default=StaffPinPolicy.REQUIRED,
nullable=False,
comment="Staff PIN policy: required, optional, disabled",
)
staff_pin_lockout_attempts = Column(
Integer,
default=5,
nullable=False,
comment="Max failed PIN attempts before lockout",
)
staff_pin_lockout_minutes = Column(
Integer,
default=30,
nullable=False,
comment="Lockout duration in minutes",
)
# =========================================================================
# Feature Toggles (Admin-controlled)
# =========================================================================
allow_self_enrollment = Column(
Boolean,
default=True,
nullable=False,
comment="Allow customers to self-enroll via QR code",
)
allow_void_transactions = Column(
Boolean,
default=True,
nullable=False,
comment="Allow voiding points for returns",
)
allow_cross_location_redemption = Column(
Boolean,
default=True,
nullable=False,
comment="Allow redemption at any company location",
)
# =========================================================================
# Audit Settings
# =========================================================================
require_order_reference = Column(
Boolean,
default=False,
nullable=False,
comment="Require order reference when earning points",
)
log_ip_addresses = Column(
Boolean,
default=True,
nullable=False,
comment="Log IP addresses for transactions",
)
# =========================================================================
# Relationships
# =========================================================================
company = relationship("Company", backref="loyalty_settings")
# Indexes
__table_args__ = (
Index("idx_company_loyalty_settings_company", "company_id"),
)
def __repr__(self) -> str:
return f"<CompanyLoyaltySettings(id={self.id}, company_id={self.company_id}, pin_policy='{self.staff_pin_policy}')>"
@property
def is_staff_pin_required(self) -> bool:
"""Check if staff PIN is required."""
return self.staff_pin_policy == StaffPinPolicy.REQUIRED
@property
def is_staff_pin_disabled(self) -> bool:
"""Check if staff PIN is disabled."""
return self.staff_pin_policy == StaffPinPolicy.DISABLED

View File

@@ -2,11 +2,16 @@
"""
Loyalty card database model.
Company-based loyalty cards:
- Cards belong to a Company's loyalty program
- Customers can earn and redeem at any vendor within the company
- Tracks where customer enrolled for analytics
Represents a customer's loyalty card (PassObject) that tracks:
- Stamp count and history
- Points balance and history
- Wallet integration (Google/Apple pass IDs)
- QR code for scanning
- QR code/barcode for scanning
"""
import secrets
@@ -28,8 +33,9 @@ from models.database.base import TimestampMixin
def generate_card_number() -> str:
"""Generate a unique 12-digit card number."""
return "".join([str(secrets.randbelow(10)) for _ in range(12)])
"""Generate a unique 12-digit card number formatted as XXXX-XXXX-XXXX."""
digits = "".join([str(secrets.randbelow(10)) for _ in range(12)])
return f"{digits[:4]}-{digits[4:8]}-{digits[8:]}"
def generate_qr_code_data() -> str:
@@ -46,7 +52,10 @@ class LoyaltyCard(Base, TimestampMixin):
"""
Customer's loyalty card (PassObject).
Links a customer to a vendor's loyalty program and tracks:
Card belongs to a Company's loyalty program.
The customer can earn and redeem at any vendor within the company.
Links a customer to a company's loyalty program and tracks:
- Stamps and points balances
- Wallet pass integration
- Activity timestamps
@@ -56,7 +65,16 @@ class LoyaltyCard(Base, TimestampMixin):
id = Column(Integer, primary_key=True, index=True)
# Relationships
# Company association (card belongs to company's program)
company_id = Column(
Integer,
ForeignKey("companies.id", ondelete="CASCADE"),
nullable=False,
index=True,
comment="Company whose program this card belongs to",
)
# Customer and program relationships
customer_id = Column(
Integer,
ForeignKey("customers.id", ondelete="CASCADE"),
@@ -69,12 +87,14 @@ class LoyaltyCard(Base, TimestampMixin):
nullable=False,
index=True,
)
vendor_id = Column(
# Track where customer enrolled (for analytics)
enrolled_at_vendor_id = Column(
Integer,
ForeignKey("vendors.id", ondelete="CASCADE"),
nullable=False,
ForeignKey("vendors.id", ondelete="SET NULL"),
nullable=True,
index=True,
comment="Denormalized for query performance",
comment="Vendor where customer enrolled (for analytics)",
)
# =========================================================================
@@ -86,7 +106,7 @@ class LoyaltyCard(Base, TimestampMixin):
nullable=False,
default=generate_card_number,
index=True,
comment="Human-readable card number",
comment="Human-readable card number (XXXX-XXXX-XXXX)",
)
qr_code_data = Column(
String(50),
@@ -183,13 +203,18 @@ class LoyaltyCard(Base, TimestampMixin):
last_points_at = Column(
DateTime(timezone=True),
nullable=True,
comment="Last points earned",
comment="Last points earned (for expiration tracking)",
)
last_redemption_at = Column(
DateTime(timezone=True),
nullable=True,
comment="Last reward redemption",
)
last_activity_at = Column(
DateTime(timezone=True),
nullable=True,
comment="Any activity (for expiration calculation)",
)
# =========================================================================
# Status
@@ -204,9 +229,13 @@ class LoyaltyCard(Base, TimestampMixin):
# =========================================================================
# Relationships
# =========================================================================
company = relationship("Company", backref="loyalty_cards")
customer = relationship("Customer", backref="loyalty_cards")
program = relationship("LoyaltyProgram", back_populates="cards")
vendor = relationship("Vendor", backref="loyalty_cards")
enrolled_at_vendor = relationship(
"Vendor",
backref="enrolled_loyalty_cards",
)
transactions = relationship(
"LoyaltyTransaction",
back_populates="card",
@@ -219,14 +248,15 @@ class LoyaltyCard(Base, TimestampMixin):
cascade="all, delete-orphan",
)
# Indexes
# Indexes - one card per customer per company
__table_args__ = (
Index("idx_loyalty_card_company_customer", "company_id", "customer_id", unique=True),
Index("idx_loyalty_card_company_active", "company_id", "is_active"),
Index("idx_loyalty_card_customer_program", "customer_id", "program_id", unique=True),
Index("idx_loyalty_card_vendor_active", "vendor_id", "is_active"),
)
def __repr__(self) -> str:
return f"<LoyaltyCard(id={self.id}, card_number='{self.card_number}', stamps={self.stamp_count})>"
return f"<LoyaltyCard(id={self.id}, card_number='{self.card_number}', stamps={self.stamp_count}, points={self.points_balance})>"
# =========================================================================
# Stamp Operations
@@ -241,6 +271,7 @@ class LoyaltyCard(Base, TimestampMixin):
self.stamp_count += 1
self.total_stamps_earned += 1
self.last_stamp_at = datetime.now(UTC)
self.last_activity_at = datetime.now(UTC)
# Check if reward cycle is complete (handled by caller with program.stamps_target)
return False # Caller checks against program.stamps_target
@@ -258,6 +289,7 @@ class LoyaltyCard(Base, TimestampMixin):
self.stamp_count -= stamps_target
self.stamps_redeemed += 1
self.last_redemption_at = datetime.now(UTC)
self.last_activity_at = datetime.now(UTC)
return True
return False
@@ -270,6 +302,7 @@ class LoyaltyCard(Base, TimestampMixin):
self.points_balance += points
self.total_points_earned += points
self.last_points_at = datetime.now(UTC)
self.last_activity_at = datetime.now(UTC)
def redeem_points(self, points: int) -> bool:
"""
@@ -281,9 +314,29 @@ class LoyaltyCard(Base, TimestampMixin):
self.points_balance -= points
self.points_redeemed += points
self.last_redemption_at = datetime.now(UTC)
self.last_activity_at = datetime.now(UTC)
return True
return False
def void_points(self, points: int) -> None:
"""
Void points (for returns).
Args:
points: Number of points to void
"""
self.points_balance = max(0, self.points_balance - points)
self.last_activity_at = datetime.now(UTC)
def expire_points(self, points: int) -> None:
"""
Expire points due to inactivity.
Args:
points: Number of points to expire
"""
self.points_balance = max(0, self.points_balance - points)
# =========================================================================
# Properties
# =========================================================================

View File

@@ -2,7 +2,12 @@
"""
Loyalty program database model.
Defines the vendor's loyalty program configuration including:
Company-based loyalty program configuration:
- Program belongs to Company (one program per company)
- All vendors under a company share the same loyalty program
- Customers earn and redeem points at any location (vendor) within the company
Defines:
- Program type (stamps, points, hybrid)
- Stamp configuration (target, reward description)
- Points configuration (per euro rate, rewards catalog)
@@ -41,9 +46,13 @@ class LoyaltyType(str, enum.Enum):
class LoyaltyProgram(Base, TimestampMixin):
"""
Vendor's loyalty program configuration.
Company's loyalty program configuration.
Each vendor can have one loyalty program that defines:
Program belongs to Company (chain-wide shared points).
All vendors under a company share the same loyalty program.
Customers can earn and redeem at any vendor within the company.
Each company can have one loyalty program that defines:
- Program type and mechanics
- Stamp or points configuration
- Anti-fraud rules
@@ -54,19 +63,20 @@ class LoyaltyProgram(Base, TimestampMixin):
id = Column(Integer, primary_key=True, index=True)
# Vendor association (one program per vendor)
vendor_id = Column(
# Company association (one program per company)
company_id = Column(
Integer,
ForeignKey("vendors.id", ondelete="CASCADE"),
ForeignKey("companies.id", ondelete="CASCADE"),
unique=True,
nullable=False,
index=True,
comment="Company that owns this program (chain-wide)",
)
# Program type
loyalty_type = Column(
String(20),
default=LoyaltyType.STAMPS.value,
default=LoyaltyType.POINTS.value,
nullable=False,
)
@@ -96,9 +106,9 @@ class LoyaltyProgram(Base, TimestampMixin):
# =========================================================================
points_per_euro = Column(
Integer,
default=10,
default=1,
nullable=False,
comment="Points earned per euro spent",
comment="Points earned per euro spent (1 euro = X points)",
)
points_rewards = Column(
JSON,
@@ -107,6 +117,38 @@ class LoyaltyProgram(Base, TimestampMixin):
comment="List of point rewards: [{id, name, points_required, description}]",
)
# Points expiration and bonus settings
points_expiration_days = Column(
Integer,
nullable=True,
comment="Days of inactivity before points expire (None = never expire)",
)
welcome_bonus_points = Column(
Integer,
default=0,
nullable=False,
comment="Bonus points awarded on enrollment",
)
minimum_redemption_points = Column(
Integer,
default=100,
nullable=False,
comment="Minimum points required for any redemption",
)
minimum_purchase_cents = Column(
Integer,
default=0,
nullable=False,
comment="Minimum purchase amount (cents) to earn points (0 = no minimum)",
)
# Future tier configuration (Bronze/Silver/Gold)
tier_config = Column(
JSON,
nullable=True,
comment='Future: Tier thresholds {"bronze": 0, "silver": 1000, "gold": 5000}',
)
# =========================================================================
# Anti-Fraud Settings
# =========================================================================
@@ -151,7 +193,7 @@ class LoyaltyProgram(Base, TimestampMixin):
logo_url = Column(
String(500),
nullable=True,
comment="URL to vendor logo for card",
comment="URL to company logo for card",
)
hero_image_url = Column(
String(500),
@@ -210,7 +252,7 @@ class LoyaltyProgram(Base, TimestampMixin):
# =========================================================================
# Relationships
# =========================================================================
vendor = relationship("Vendor", back_populates="loyalty_program")
company = relationship("Company", backref="loyalty_program")
cards = relationship(
"LoyaltyCard",
back_populates="program",
@@ -224,11 +266,11 @@ class LoyaltyProgram(Base, TimestampMixin):
# Indexes
__table_args__ = (
Index("idx_loyalty_program_vendor_active", "vendor_id", "is_active"),
Index("idx_loyalty_program_company_active", "company_id", "is_active"),
)
def __repr__(self) -> str:
return f"<LoyaltyProgram(id={self.id}, vendor_id={self.vendor_id}, type='{self.loyalty_type}')>"
return f"<LoyaltyProgram(id={self.id}, company_id={self.company_id}, type='{self.loyalty_type}')>"
# =========================================================================
# Properties

View File

@@ -2,9 +2,15 @@
"""
Loyalty transaction database model.
Company-based transaction tracking:
- Tracks which company and vendor processed each transaction
- Enables chain-wide reporting while maintaining per-location audit trails
- Supports voiding transactions for returns
Records all loyalty events including:
- Stamps earned and redeemed
- Points earned and redeemed
- Welcome bonuses and expirations
- Associated metadata (staff PIN, purchase amount, IP, etc.)
"""
@@ -31,10 +37,12 @@ class TransactionType(str, enum.Enum):
# Stamps
STAMP_EARNED = "stamp_earned"
STAMP_REDEEMED = "stamp_redeemed"
STAMP_VOIDED = "stamp_voided" # Stamps voided due to return
# Points
POINTS_EARNED = "points_earned"
POINTS_REDEEMED = "points_redeemed"
POINTS_VOIDED = "points_voided" # Points voided due to return
# Adjustments (manual corrections by staff)
STAMP_ADJUSTMENT = "stamp_adjustment"
@@ -44,6 +52,10 @@ class TransactionType(str, enum.Enum):
CARD_CREATED = "card_created"
CARD_DEACTIVATED = "card_deactivated"
# Bonuses and expiration
WELCOME_BONUS = "welcome_bonus" # Welcome bonus points on enrollment
POINTS_EXPIRED = "points_expired" # Points expired due to inactivity
class LoyaltyTransaction(Base, TimestampMixin):
"""
@@ -51,12 +63,25 @@ class LoyaltyTransaction(Base, TimestampMixin):
Immutable audit log of all loyalty operations for fraud
detection, analytics, and customer support.
Tracks which vendor (location) processed the transaction,
enabling chain-wide reporting while maintaining per-location
audit trails.
"""
__tablename__ = "loyalty_transactions"
id = Column(Integer, primary_key=True, index=True)
# Company association
company_id = Column(
Integer,
ForeignKey("companies.id", ondelete="CASCADE"),
nullable=False,
index=True,
comment="Company that owns the loyalty program",
)
# Core relationships
card_id = Column(
Integer,
@@ -66,10 +91,10 @@ class LoyaltyTransaction(Base, TimestampMixin):
)
vendor_id = Column(
Integer,
ForeignKey("vendors.id", ondelete="CASCADE"),
nullable=False,
ForeignKey("vendors.id", ondelete="SET NULL"),
nullable=True,
index=True,
comment="Denormalized for query performance",
comment="Vendor (location) that processed this transaction",
)
staff_pin_id = Column(
Integer,
@@ -79,6 +104,15 @@ class LoyaltyTransaction(Base, TimestampMixin):
comment="Staff PIN used for this operation",
)
# Related transaction (for voids/returns)
related_transaction_id = Column(
Integer,
ForeignKey("loyalty_transactions.id", ondelete="SET NULL"),
nullable=True,
index=True,
comment="Original transaction (for voids/returns)",
)
# =========================================================================
# Transaction Details
# =========================================================================
@@ -175,15 +209,23 @@ class LoyaltyTransaction(Base, TimestampMixin):
# =========================================================================
# Relationships
# =========================================================================
company = relationship("Company", backref="loyalty_transactions")
card = relationship("LoyaltyCard", back_populates="transactions")
vendor = relationship("Vendor", backref="loyalty_transactions")
staff_pin = relationship("StaffPin", backref="transactions")
related_transaction = relationship(
"LoyaltyTransaction",
remote_side=[id],
backref="voiding_transactions",
)
# Indexes
__table_args__ = (
Index("idx_loyalty_tx_card_type", "card_id", "transaction_type"),
Index("idx_loyalty_tx_vendor_date", "vendor_id", "transaction_at"),
Index("idx_loyalty_tx_type_date", "transaction_type", "transaction_at"),
Index("idx_loyalty_tx_company_date", "company_id", "transaction_at"),
Index("idx_loyalty_tx_company_vendor", "company_id", "vendor_id"),
)
def __repr__(self) -> str:
@@ -202,6 +244,7 @@ class LoyaltyTransaction(Base, TimestampMixin):
return self.transaction_type in (
TransactionType.STAMP_EARNED.value,
TransactionType.STAMP_REDEEMED.value,
TransactionType.STAMP_VOIDED.value,
TransactionType.STAMP_ADJUSTMENT.value,
)
@@ -212,6 +255,9 @@ class LoyaltyTransaction(Base, TimestampMixin):
TransactionType.POINTS_EARNED.value,
TransactionType.POINTS_REDEEMED.value,
TransactionType.POINTS_ADJUSTMENT.value,
TransactionType.POINTS_VOIDED.value,
TransactionType.WELCOME_BONUS.value,
TransactionType.POINTS_EXPIRED.value,
)
@property
@@ -220,6 +266,7 @@ class LoyaltyTransaction(Base, TimestampMixin):
return self.transaction_type in (
TransactionType.STAMP_EARNED.value,
TransactionType.POINTS_EARNED.value,
TransactionType.WELCOME_BONUS.value,
)
@property
@@ -230,9 +277,24 @@ class LoyaltyTransaction(Base, TimestampMixin):
TransactionType.POINTS_REDEEMED.value,
)
@property
def is_void_transaction(self) -> bool:
"""Check if this is a void transaction (for returns)."""
return self.transaction_type in (
TransactionType.POINTS_VOIDED.value,
TransactionType.STAMP_VOIDED.value,
)
@property
def staff_name(self) -> str | None:
"""Get the name of the staff member who performed this transaction."""
if self.staff_pin:
return self.staff_pin.name
return None
@property
def vendor_name(self) -> str | None:
"""Get the name of the vendor where this transaction occurred."""
if self.vendor:
return self.vendor.name
return None

View File

@@ -2,6 +2,11 @@
"""
Staff PIN database model.
Company-based staff PINs:
- PINs belong to a company's loyalty program
- Each vendor (location) has its own set of staff PINs
- Staff can only use PINs at their assigned location
Provides fraud prevention by requiring staff to authenticate
before performing stamp/points operations. Includes:
- Secure PIN hashing with bcrypt
@@ -34,13 +39,25 @@ class StaffPin(Base, TimestampMixin):
Each staff member can have their own PIN to authenticate
stamp/points operations. PINs are hashed with bcrypt and
include lockout protection.
PINs are scoped to a specific vendor (location) within the
company's loyalty program.
"""
__tablename__ = "staff_pins"
id = Column(Integer, primary_key=True, index=True)
# Relationships
# Company association
company_id = Column(
Integer,
ForeignKey("companies.id", ondelete="CASCADE"),
nullable=False,
index=True,
comment="Company that owns the loyalty program",
)
# Program and vendor relationships
program_id = Column(
Integer,
ForeignKey("loyalty_programs.id", ondelete="CASCADE"),
@@ -52,7 +69,7 @@ class StaffPin(Base, TimestampMixin):
ForeignKey("vendors.id", ondelete="CASCADE"),
nullable=False,
index=True,
comment="Denormalized for query performance",
comment="Vendor (location) where this staff member works",
)
# Staff identity
@@ -104,17 +121,19 @@ class StaffPin(Base, TimestampMixin):
# =========================================================================
# Relationships
# =========================================================================
company = relationship("Company", backref="staff_pins")
program = relationship("LoyaltyProgram", back_populates="staff_pins")
vendor = relationship("Vendor", backref="staff_pins")
# Indexes
__table_args__ = (
Index("idx_staff_pin_company_active", "company_id", "is_active"),
Index("idx_staff_pin_vendor_active", "vendor_id", "is_active"),
Index("idx_staff_pin_program_active", "program_id", "is_active"),
)
def __repr__(self) -> str:
return f"<StaffPin(id={self.id}, name='{self.name}', active={self.is_active})>"
return f"<StaffPin(id={self.id}, name='{self.name}', vendor_id={self.vendor_id}, active={self.is_active})>"
# =========================================================================
# PIN Operations

View File

@@ -3,13 +3,14 @@
Loyalty module admin routes.
Platform admin endpoints for:
- Viewing all loyalty programs
- Viewing all loyalty programs (company-based)
- Company loyalty settings management
- Platform-wide analytics
"""
import logging
from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, HTTPException, Path, Query
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api, require_module_access
@@ -19,6 +20,9 @@ from app.modules.loyalty.schemas import (
ProgramListResponse,
ProgramResponse,
ProgramStatsResponse,
CompanyStatsResponse,
CompanySettingsResponse,
CompanySettingsUpdate,
)
from app.modules.loyalty.services import program_service
from app.modules.tenancy.models import User
@@ -42,15 +46,22 @@ def list_programs(
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, description="Search by company name"),
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""List all loyalty programs (platform admin)."""
from sqlalchemy import func
from app.modules.loyalty.models import LoyaltyCard, LoyaltyTransaction
from app.modules.tenancy.models import Company
programs, total = program_service.list_programs(
db,
skip=skip,
limit=limit,
is_active=is_active,
search=search,
)
program_responses = []
@@ -59,6 +70,47 @@ def list_programs(
response.is_stamps_enabled = program.is_stamps_enabled
response.is_points_enabled = program.is_points_enabled
response.display_name = program.display_name
# Get company name
company = db.query(Company).filter(Company.id == program.company_id).first()
if company:
response.company_name = company.name
# Get basic stats for this program
response.total_cards = (
db.query(func.count(LoyaltyCard.id))
.filter(LoyaltyCard.company_id == program.company_id)
.scalar()
or 0
)
response.active_cards = (
db.query(func.count(LoyaltyCard.id))
.filter(
LoyaltyCard.company_id == program.company_id,
LoyaltyCard.is_active == True,
)
.scalar()
or 0
)
response.total_points_issued = (
db.query(func.sum(LoyaltyTransaction.points_delta))
.filter(
LoyaltyTransaction.company_id == program.company_id,
LoyaltyTransaction.points_delta > 0,
)
.scalar()
or 0
)
response.total_points_redeemed = (
db.query(func.sum(func.abs(LoyaltyTransaction.points_delta)))
.filter(
LoyaltyTransaction.company_id == program.company_id,
LoyaltyTransaction.points_delta < 0,
)
.scalar()
or 0
)
program_responses.append(response)
return ProgramListResponse(programs=program_responses, total=total)
@@ -92,6 +144,60 @@ def get_program_stats(
return ProgramStatsResponse(**stats)
# =============================================================================
# Company Management
# =============================================================================
@admin_router.get("/companies/{company_id}/stats", response_model=CompanyStatsResponse)
def get_company_stats(
company_id: int = Path(..., gt=0),
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""Get company-wide loyalty statistics across all locations."""
stats = program_service.get_company_stats(db, company_id)
if "error" in stats:
raise HTTPException(status_code=404, detail=stats["error"])
return CompanyStatsResponse(**stats)
@admin_router.get("/companies/{company_id}/settings", response_model=CompanySettingsResponse)
def get_company_settings(
company_id: int = Path(..., gt=0),
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""Get company loyalty settings."""
settings = program_service.get_or_create_company_settings(db, company_id)
return CompanySettingsResponse.model_validate(settings)
@admin_router.patch("/companies/{company_id}/settings", response_model=CompanySettingsResponse)
def update_company_settings(
data: CompanySettingsUpdate,
company_id: int = Path(..., gt=0),
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""Update company loyalty settings (admin only)."""
from app.modules.loyalty.models import CompanyLoyaltySettings
settings = program_service.get_or_create_company_settings(db, company_id)
update_data = data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(settings, field, value)
db.commit()
db.refresh(settings)
logger.info(f"Updated company {company_id} loyalty settings: {list(update_data.keys())}")
return CompanySettingsResponse.model_validate(settings)
# =============================================================================
# Platform Stats
# =============================================================================
@@ -136,10 +242,39 @@ def get_platform_stats(
or 0
)
# Points issued/redeemed (last 30 days)
points_issued_30d = (
db.query(func.sum(LoyaltyTransaction.points_delta))
.filter(
LoyaltyTransaction.transaction_at >= thirty_days_ago,
LoyaltyTransaction.points_delta > 0,
)
.scalar()
or 0
)
points_redeemed_30d = (
db.query(func.sum(func.abs(LoyaltyTransaction.points_delta)))
.filter(
LoyaltyTransaction.transaction_at >= thirty_days_ago,
LoyaltyTransaction.points_delta < 0,
)
.scalar()
or 0
)
# Company count with programs
companies_with_programs = (
db.query(func.count(func.distinct(LoyaltyProgram.company_id))).scalar() or 0
)
return {
"total_programs": total_programs,
"active_programs": active_programs,
"companies_with_programs": companies_with_programs,
"total_cards": total_cards,
"active_cards": active_cards,
"transactions_30d": transactions_30d,
"points_issued_30d": points_issued_30d,
"points_redeemed_30d": points_redeemed_30d,
}

View File

@@ -0,0 +1,216 @@
# app/modules/loyalty/routes/api/storefront.py
"""
Loyalty Module - Storefront API Routes
Customer-facing endpoints for:
- View loyalty card and balance
- View transaction history
- Self-service enrollment
- Get program information
Uses vendor from middleware context (VendorContextMiddleware).
"""
import logging
from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy.orm import Session
from app.api.deps import get_current_customer_api
from app.core.database import get_db
from app.modules.customers.schemas import CustomerContext
from app.modules.loyalty.services import card_service, program_service
from app.modules.loyalty.schemas import (
CardResponse,
CardEnrollRequest,
TransactionListResponse,
TransactionResponse,
ProgramResponse,
)
from app.modules.tenancy.exceptions import VendorNotFoundException
router = APIRouter()
logger = logging.getLogger(__name__)
# =============================================================================
# Public Endpoints (No Authentication Required)
# =============================================================================
@router.get("/loyalty/program")
def get_program_info(
request: Request,
db: Session = Depends(get_db),
):
"""
Get loyalty program information for current vendor.
Public endpoint - no authentication required.
"""
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise VendorNotFoundException("context", identifier_type="subdomain")
program = program_service.get_program_by_vendor(db, vendor.id)
if not program:
return None
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("/loyalty/enroll")
def self_enroll(
request: Request,
data: CardEnrollRequest,
db: Session = Depends(get_db),
):
"""
Self-service enrollment.
Public endpoint - customers can enroll via QR code without authentication.
"""
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise VendorNotFoundException("context", identifier_type="subdomain")
logger.info(f"Self-enrollment for {data.customer_email} at vendor {vendor.subdomain}")
card = card_service.enroll_customer(
db,
vendor_id=vendor.id,
customer_email=data.customer_email,
customer_phone=data.customer_phone,
customer_name=data.customer_name,
)
return CardResponse.model_validate(card)
# =============================================================================
# Authenticated Endpoints
# =============================================================================
@router.get("/loyalty/card")
def get_my_card(
request: Request,
customer: CustomerContext = Depends(get_current_customer_api),
db: Session = Depends(get_db),
):
"""
Get customer's loyalty card and program info.
Returns card details, program info, and available rewards.
"""
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise VendorNotFoundException("context", identifier_type="subdomain")
logger.debug(f"Getting loyalty card for customer {customer.id}")
# Get program
program = program_service.get_program_by_vendor(db, vendor.id)
if not program:
return {"card": None, "program": None, "locations": []}
# Look up card by customer email
card = card_service.get_card_by_customer_email(
db,
company_id=program.company_id,
customer_email=customer.email,
)
if not card:
return {"card": None, "program": None, "locations": []}
# Get company locations
from app.modules.tenancy.models import Vendor as VendorModel
locations = (
db.query(VendorModel)
.filter(VendorModel.company_id == program.company_id, VendorModel.is_active == True)
.all()
)
program_response = ProgramResponse.model_validate(program)
program_response.is_stamps_enabled = program.is_stamps_enabled
program_response.is_points_enabled = program.is_points_enabled
program_response.display_name = program.display_name
return {
"card": CardResponse.model_validate(card),
"program": program_response,
"locations": [{"id": v.id, "name": v.name} for v in locations],
}
@router.get("/loyalty/transactions")
def get_my_transactions(
request: Request,
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
customer: CustomerContext = Depends(get_current_customer_api),
db: Session = Depends(get_db),
):
"""
Get customer's loyalty transaction history.
"""
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise VendorNotFoundException("context", identifier_type="subdomain")
logger.debug(f"Getting transactions for customer {customer.id}")
# Get program
program = program_service.get_program_by_vendor(db, vendor.id)
if not program:
return {"transactions": [], "total": 0}
# Get card
card = card_service.get_card_by_customer_email(
db,
company_id=program.company_id,
customer_email=customer.email,
)
if not card:
return {"transactions": [], "total": 0}
# Get transactions
from sqlalchemy import func
from app.modules.loyalty.models import LoyaltyTransaction
from app.modules.tenancy.models import Vendor as VendorModel
query = (
db.query(LoyaltyTransaction)
.filter(LoyaltyTransaction.card_id == card.id)
.order_by(LoyaltyTransaction.transaction_at.desc())
)
total = query.count()
transactions = query.offset(skip).limit(limit).all()
# Build response with vendor names
tx_responses = []
for tx in transactions:
tx_data = {
"id": tx.id,
"transaction_type": tx.transaction_type.value if hasattr(tx.transaction_type, 'value') else str(tx.transaction_type),
"points_delta": tx.points_delta,
"stamps_delta": tx.stamps_delta,
"balance_after": tx.balance_after,
"transaction_at": tx.transaction_at.isoformat() if tx.transaction_at else None,
"notes": tx.notes,
"vendor_name": None,
}
if tx.vendor_id:
vendor_obj = db.query(VendorModel).filter(VendorModel.id == tx.vendor_id).first()
if vendor_obj:
tx_data["vendor_name"] = vendor_obj.name
tx_responses.append(tx_data)
return {"transactions": tx_responses, "total": total}

View File

@@ -2,12 +2,15 @@
"""
Loyalty module vendor routes.
Store/vendor endpoints for:
- Program management
- Staff PINs
- Card operations (stamps, points, redemptions)
Company-based vendor endpoints for:
- Program management (company-wide, managed by vendor)
- Staff PINs (per-vendor)
- Card operations (stamps, points, redemptions, voids)
- Customer cards lookup
- Dashboard stats
All operations are scoped to the vendor's company.
Cards can be used at any vendor within the same company.
"""
import logging
@@ -36,14 +39,23 @@ from app.modules.loyalty.schemas import (
PointsEarnResponse,
PointsRedeemRequest,
PointsRedeemResponse,
PointsVoidRequest,
PointsVoidResponse,
PointsAdjustRequest,
PointsAdjustResponse,
ProgramCreate,
ProgramResponse,
ProgramStatsResponse,
ProgramUpdate,
CompanyStatsResponse,
StampRedeemRequest,
StampRedeemResponse,
StampRequest,
StampResponse,
StampVoidRequest,
StampVoidResponse,
TransactionListResponse,
TransactionResponse,
)
from app.modules.loyalty.services import (
card_service,
@@ -54,7 +66,7 @@ from app.modules.loyalty.services import (
wallet_service,
)
from app.modules.enums import FrontendType
from app.modules.tenancy.models import User
from app.modules.tenancy.models import User, Vendor
logger = logging.getLogger(__name__)
@@ -72,6 +84,14 @@ def get_client_info(request: Request) -> tuple[str | None, str | None]:
return ip, user_agent
def get_vendor_company_id(db: Session, vendor_id: int) -> int:
"""Get the company ID for a vendor."""
vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first()
if not vendor:
raise HTTPException(status_code=404, detail="Vendor not found")
return vendor.company_id
# =============================================================================
# Program Management
# =============================================================================
@@ -82,7 +102,7 @@ def get_program(
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Get the vendor's loyalty program."""
"""Get the company's loyalty program."""
vendor_id = current_user.token_vendor_id
program = program_service.get_program_by_vendor(db, vendor_id)
@@ -103,11 +123,12 @@ def create_program(
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Create a loyalty program for the vendor."""
"""Create a loyalty program for the company."""
vendor_id = current_user.token_vendor_id
company_id = get_vendor_company_id(db, vendor_id)
try:
program = program_service.create_program(db, vendor_id, data)
program = program_service.create_program(db, company_id, data)
except LoyaltyException as e:
raise HTTPException(status_code=e.status_code, detail=e.message)
@@ -125,7 +146,7 @@ def update_program(
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Update the vendor's loyalty program."""
"""Update the company's loyalty program."""
vendor_id = current_user.token_vendor_id
program = program_service.get_program_by_vendor(db, vendor_id)
@@ -158,6 +179,22 @@ def get_stats(
return ProgramStatsResponse(**stats)
@vendor_router.get("/stats/company", response_model=CompanyStatsResponse)
def get_company_stats(
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Get company-wide loyalty statistics across all locations."""
vendor_id = current_user.token_vendor_id
company_id = get_vendor_company_id(db, vendor_id)
stats = program_service.get_company_stats(db, company_id)
if "error" in stats:
raise HTTPException(status_code=404, detail=stats["error"])
return CompanyStatsResponse(**stats)
# =============================================================================
# Staff PINs
# =============================================================================
@@ -168,14 +205,15 @@ def list_pins(
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""List all staff PINs for the loyalty program."""
"""List staff PINs for this vendor location."""
vendor_id = current_user.token_vendor_id
program = program_service.get_program_by_vendor(db, vendor_id)
if not program:
raise HTTPException(status_code=404, detail="No loyalty program configured")
pins = pin_service.list_pins(db, program.id)
# List PINs for this vendor only
pins = pin_service.list_pins(db, program.id, vendor_id=vendor_id)
return PinListResponse(
pins=[PinResponse.model_validate(pin) for pin in pins],
@@ -189,7 +227,7 @@ def create_pin(
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Create a new staff PIN."""
"""Create a new staff PIN for this vendor location."""
vendor_id = current_user.token_vendor_id
program = program_service.get_program_by_vendor(db, vendor_id)
@@ -244,19 +282,30 @@ def list_cards(
limit: int = Query(50, ge=1, le=100),
is_active: bool | None = Query(None),
search: str | None = Query(None, max_length=100),
enrolled_here: bool = Query(False, description="Only show cards enrolled at this location"),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""List loyalty cards for the vendor."""
"""
List loyalty cards for the company.
By default lists all cards in the company's loyalty program.
Use enrolled_here=true to filter to cards enrolled at this location.
"""
vendor_id = current_user.token_vendor_id
company_id = get_vendor_company_id(db, vendor_id)
program = program_service.get_program_by_vendor(db, vendor_id)
if not program:
raise HTTPException(status_code=404, detail="No loyalty program configured")
# Filter by enrolled_at_vendor_id if requested
filter_vendor_id = vendor_id if enrolled_here else None
cards, total = card_service.list_cards(
db,
vendor_id,
company_id,
vendor_id=filter_vendor_id,
skip=skip,
limit=limit,
is_active=is_active,
@@ -269,8 +318,9 @@ def list_cards(
id=card.id,
card_number=card.card_number,
customer_id=card.customer_id,
vendor_id=card.vendor_id,
company_id=card.company_id,
program_id=card.program_id,
enrolled_at_vendor_id=card.enrolled_at_vendor_id,
stamp_count=card.stamp_count,
stamps_target=program.stamps_target,
stamps_until_reward=max(0, program.stamps_target - card.stamp_count),
@@ -298,12 +348,18 @@ def lookup_card(
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Look up a card by ID, QR code, or card number."""
"""
Look up a card by ID, QR code, or card number.
Card must belong to the same company as the vendor.
"""
vendor_id = current_user.token_vendor_id
try:
card = card_service.lookup_card(
# Uses lookup_card_for_vendor which validates company membership
card = card_service.lookup_card_for_vendor(
db,
vendor_id,
card_id=card_id,
qr_code=qr_code,
card_number=card_number,
@@ -311,10 +367,6 @@ def lookup_card(
except LoyaltyCardNotFoundException:
raise HTTPException(status_code=404, detail="Card not found")
# Verify card belongs to this vendor
if card.vendor_id != vendor_id:
raise HTTPException(status_code=404, detail="Card not found")
program = card.program
# Check cooldown
@@ -328,18 +380,27 @@ def lookup_card(
# Get stamps today
stamps_today = card_service.get_stamps_today(db, card.id)
# Get available points rewards
available_rewards = []
for reward in program.points_rewards or []:
if reward.get("is_active", True) and card.points_balance >= reward.get("points_required", 0):
available_rewards.append(reward)
return CardLookupResponse(
card_id=card.id,
card_number=card.card_number,
customer_id=card.customer_id,
customer_name=card.customer.full_name if card.customer else None,
customer_email=card.customer.email if card.customer else "",
company_id=card.company_id,
company_name=card.company.name if card.company else None,
stamp_count=card.stamp_count,
stamps_target=program.stamps_target,
stamps_until_reward=max(0, program.stamps_target - card.stamp_count),
points_balance=card.points_balance,
can_redeem_stamps=card.stamp_count >= program.stamps_target,
stamp_reward_description=program.stamps_reward_description,
available_rewards=available_rewards,
can_stamp=can_stamp,
cooldown_ends_at=cooldown_ends,
stamps_today=stamps_today,
@@ -354,14 +415,19 @@ def enroll_customer(
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Enroll a customer in the loyalty program."""
"""
Enroll a customer in the company's loyalty program.
The card will be associated with the company and track which
vendor enrolled them.
"""
vendor_id = current_user.token_vendor_id
if not data.customer_id:
raise HTTPException(status_code=400, detail="customer_id is required")
try:
card = card_service.enroll_customer(db, data.customer_id, vendor_id)
card = card_service.enroll_customer_for_vendor(db, data.customer_id, vendor_id)
except LoyaltyException as e:
raise HTTPException(status_code=e.status_code, detail=e.message)
@@ -371,11 +437,12 @@ def enroll_customer(
id=card.id,
card_number=card.card_number,
customer_id=card.customer_id,
vendor_id=card.vendor_id,
company_id=card.company_id,
program_id=card.program_id,
enrolled_at_vendor_id=card.enrolled_at_vendor_id,
stamp_count=card.stamp_count,
stamps_target=program.stamps_target,
stamps_until_reward=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,
@@ -386,6 +453,33 @@ def enroll_customer(
)
@vendor_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),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Get transaction history for a card."""
vendor_id = current_user.token_vendor_id
# Verify card belongs to this company
try:
card = card_service.lookup_card_for_vendor(db, vendor_id, card_id=card_id)
except LoyaltyCardNotFoundException:
raise HTTPException(status_code=404, detail="Card not found")
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,
)
# =============================================================================
# Stamp Operations
# =============================================================================
@@ -399,11 +493,13 @@ def add_stamp(
db: Session = Depends(get_db),
):
"""Add a stamp to a loyalty card."""
vendor_id = current_user.token_vendor_id
ip, user_agent = get_client_info(request)
try:
result = stamp_service.add_stamp(
db,
vendor_id=vendor_id,
card_id=data.card_id,
qr_code=data.qr_code,
card_number=data.card_number,
@@ -426,11 +522,13 @@ def redeem_stamps(
db: Session = Depends(get_db),
):
"""Redeem stamps for a reward."""
vendor_id = current_user.token_vendor_id
ip, user_agent = get_client_info(request)
try:
result = stamp_service.redeem_stamps(
db,
vendor_id=vendor_id,
card_id=data.card_id,
qr_code=data.qr_code,
card_number=data.card_number,
@@ -445,6 +543,37 @@ def redeem_stamps(
return StampRedeemResponse(**result)
@vendor_router.post("/stamp/void", response_model=StampVoidResponse)
def void_stamps(
request: Request,
data: StampVoidRequest,
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Void stamps for a return."""
vendor_id = current_user.token_vendor_id
ip, user_agent = get_client_info(request)
try:
result = stamp_service.void_stamps(
db,
vendor_id=vendor_id,
card_id=data.card_id,
qr_code=data.qr_code,
card_number=data.card_number,
stamps_to_void=data.stamps_to_void,
original_transaction_id=data.original_transaction_id,
staff_pin=data.staff_pin,
ip_address=ip,
user_agent=user_agent,
notes=data.notes,
)
except LoyaltyException as e:
raise HTTPException(status_code=e.status_code, detail=e.message)
return StampVoidResponse(**result)
# =============================================================================
# Points Operations
# =============================================================================
@@ -458,11 +587,13 @@ def earn_points(
db: Session = Depends(get_db),
):
"""Earn points from a purchase."""
vendor_id = current_user.token_vendor_id
ip, user_agent = get_client_info(request)
try:
result = points_service.earn_points(
db,
vendor_id=vendor_id,
card_id=data.card_id,
qr_code=data.qr_code,
card_number=data.card_number,
@@ -487,11 +618,13 @@ def redeem_points(
db: Session = Depends(get_db),
):
"""Redeem points for a reward."""
vendor_id = current_user.token_vendor_id
ip, user_agent = get_client_info(request)
try:
result = points_service.redeem_points(
db,
vendor_id=vendor_id,
card_id=data.card_id,
qr_code=data.qr_code,
card_number=data.card_number,
@@ -505,3 +638,64 @@ def redeem_points(
raise HTTPException(status_code=e.status_code, detail=e.message)
return PointsRedeemResponse(**result)
@vendor_router.post("/points/void", response_model=PointsVoidResponse)
def void_points(
request: Request,
data: PointsVoidRequest,
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Void points for a return."""
vendor_id = current_user.token_vendor_id
ip, user_agent = get_client_info(request)
try:
result = points_service.void_points(
db,
vendor_id=vendor_id,
card_id=data.card_id,
qr_code=data.qr_code,
card_number=data.card_number,
points_to_void=data.points_to_void,
original_transaction_id=data.original_transaction_id,
order_reference=data.order_reference,
staff_pin=data.staff_pin,
ip_address=ip,
user_agent=user_agent,
notes=data.notes,
)
except LoyaltyException as e:
raise HTTPException(status_code=e.status_code, detail=e.message)
return PointsVoidResponse(**result)
@vendor_router.post("/cards/{card_id}/points/adjust", response_model=PointsAdjustResponse)
def adjust_points(
request: Request,
data: PointsAdjustRequest,
card_id: int = Path(..., gt=0),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Manually adjust points (vendor operation)."""
vendor_id = current_user.token_vendor_id
ip, user_agent = get_client_info(request)
try:
result = points_service.adjust_points(
db,
card_id=card_id,
points_delta=data.points_delta,
vendor_id=vendor_id,
reason=data.reason,
staff_pin=data.staff_pin,
ip_address=ip,
user_agent=user_agent,
)
except LoyaltyException as e:
raise HTTPException(status_code=e.status_code, detail=e.message)
return PointsAdjustResponse(**result)

View File

@@ -1,8 +1,15 @@
# app/modules/loyalty/routes/pages/__init__.py
"""
Loyalty module page routes.
Loyalty module page routes (HTML rendering).
Reserved for future HTML page endpoints (enrollment pages, etc.).
Provides Jinja2 template rendering for:
- Admin pages: Platform loyalty programs dashboard and company management
- Vendor pages: Loyalty terminal, cards management, settings
- Storefront pages: Customer loyalty dashboard, self-enrollment
"""
__all__: list[str] = []
from app.modules.loyalty.routes.pages.admin import router as admin_router
from app.modules.loyalty.routes.pages.vendor import router as vendor_router
from app.modules.loyalty.routes.pages.storefront import router as storefront_router
__all__ = ["admin_router", "vendor_router", "storefront_router"]

View File

@@ -0,0 +1,124 @@
# app/modules/loyalty/routes/pages/admin.py
"""
Loyalty Admin Page Routes (HTML rendering).
Admin pages for:
- Platform loyalty programs dashboard
- Company loyalty program detail/configuration
- Platform-wide loyalty statistics
"""
from fastapi import APIRouter, Depends, Path, Request
from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session
from app.api.deps import get_db, require_menu_access
from app.templates_config import templates
from app.modules.enums import FrontendType
from app.modules.tenancy.models import User
router = APIRouter()
# Route configuration for module route discovery
ROUTE_CONFIG = {
"prefix": "/loyalty",
"tags": ["admin-loyalty"],
}
# ============================================================================
# LOYALTY PROGRAMS DASHBOARD
# ============================================================================
@router.get("/programs", response_class=HTMLResponse, include_in_schema=False)
async def admin_loyalty_programs(
request: Request,
current_user: User = Depends(require_menu_access("loyalty-programs", FrontendType.ADMIN)),
db: Session = Depends(get_db),
):
"""
Render loyalty programs dashboard.
Shows all companies with loyalty programs and platform-wide statistics.
"""
return templates.TemplateResponse(
"loyalty/admin/programs.html",
{
"request": request,
"user": current_user,
},
)
@router.get("/analytics", response_class=HTMLResponse, include_in_schema=False)
async def admin_loyalty_analytics(
request: Request,
current_user: User = Depends(require_menu_access("loyalty-analytics", FrontendType.ADMIN)),
db: Session = Depends(get_db),
):
"""
Render loyalty analytics dashboard.
Shows platform-wide loyalty statistics and trends.
"""
return templates.TemplateResponse(
"loyalty/admin/analytics.html",
{
"request": request,
"user": current_user,
},
)
# ============================================================================
# COMPANY LOYALTY DETAIL
# ============================================================================
@router.get(
"/companies/{company_id}",
response_class=HTMLResponse,
include_in_schema=False,
)
async def admin_loyalty_company_detail(
request: Request,
company_id: int = Path(..., description="Company ID"),
current_user: User = Depends(require_menu_access("loyalty-programs", FrontendType.ADMIN)),
db: Session = Depends(get_db),
):
"""
Render company loyalty program detail page.
Shows company's loyalty program configuration and location breakdown.
"""
return templates.TemplateResponse(
"loyalty/admin/company-detail.html",
{
"request": request,
"user": current_user,
"company_id": company_id,
},
)
@router.get(
"/companies/{company_id}/settings",
response_class=HTMLResponse,
include_in_schema=False,
)
async def admin_loyalty_company_settings(
request: Request,
company_id: int = Path(..., description="Company ID"),
current_user: User = Depends(require_menu_access("loyalty-programs", FrontendType.ADMIN)),
db: Session = Depends(get_db),
):
"""
Render company loyalty settings page.
Admin-controlled settings like staff PIN policy.
"""
return templates.TemplateResponse(
"loyalty/admin/company-settings.html",
{
"request": request,
"user": current_user,
"company_id": company_id,
},
)

View File

@@ -0,0 +1,151 @@
# app/modules/loyalty/routes/pages/storefront.py
"""
Loyalty Storefront Page Routes (HTML rendering).
Customer-facing pages for:
- Loyalty dashboard (view points, rewards, history)
- Self-service enrollment
- Digital card display
"""
import logging
from fastapi import APIRouter, Depends, Query, Request
from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session
from app.api.deps import get_current_customer_from_cookie_or_header, get_db
from app.modules.core.utils.page_context import get_storefront_context
from app.modules.customers.models import Customer
from app.templates_config import templates
logger = logging.getLogger(__name__)
router = APIRouter()
# No custom prefix - routes are mounted directly at /storefront/
# Following same pattern as customers module
# ============================================================================
# CUSTOMER LOYALTY DASHBOARD (Authenticated)
# ============================================================================
@router.get(
"/account/loyalty",
response_class=HTMLResponse,
include_in_schema=False,
)
async def customer_loyalty_dashboard(
request: Request,
current_customer: Customer = Depends(get_current_customer_from_cookie_or_header),
db: Session = Depends(get_db),
):
"""
Render customer loyalty dashboard.
Shows points balance, available rewards, and transaction history.
"""
logger.debug(
"[STOREFRONT] customer_loyalty_dashboard REACHED",
extra={
"path": request.url.path,
"customer_id": current_customer.id if current_customer else None,
},
)
context = get_storefront_context(request, db=db)
return templates.TemplateResponse(
"loyalty/storefront/dashboard.html",
context,
)
@router.get(
"/account/loyalty/history",
response_class=HTMLResponse,
include_in_schema=False,
)
async def customer_loyalty_history(
request: Request,
current_customer: Customer = Depends(get_current_customer_from_cookie_or_header),
db: Session = Depends(get_db),
):
"""
Render full transaction history page.
"""
logger.debug(
"[STOREFRONT] customer_loyalty_history REACHED",
extra={
"path": request.url.path,
"customer_id": current_customer.id if current_customer else None,
},
)
context = get_storefront_context(request, db=db)
return templates.TemplateResponse(
"loyalty/storefront/history.html",
context,
)
# ============================================================================
# SELF-SERVICE ENROLLMENT (Public - No Authentication)
# ============================================================================
@router.get(
"/loyalty/join",
response_class=HTMLResponse,
include_in_schema=False,
)
async def loyalty_self_enrollment(
request: Request,
db: Session = Depends(get_db),
):
"""
Render self-service enrollment page.
Public page - customers can sign up via QR code at store counter.
"""
logger.debug(
"[STOREFRONT] loyalty_self_enrollment REACHED",
extra={
"path": request.url.path,
},
)
context = get_storefront_context(request, db=db)
return templates.TemplateResponse(
"loyalty/storefront/enroll.html",
context,
)
@router.get(
"/loyalty/join/success",
response_class=HTMLResponse,
include_in_schema=False,
)
async def loyalty_enrollment_success(
request: Request,
card: str = Query(None, description="Card number"),
db: Session = Depends(get_db),
):
"""
Render enrollment success page.
Shows card number and next steps.
"""
logger.debug(
"[STOREFRONT] loyalty_enrollment_success REACHED",
extra={
"path": request.url.path,
"card": card,
},
)
context = get_storefront_context(request, db=db)
context["enrolled_card_number"] = card
return templates.TemplateResponse(
"loyalty/storefront/enroll-success.html",
context,
)

View File

@@ -0,0 +1,226 @@
# app/modules/loyalty/routes/pages/vendor.py
"""
Loyalty Vendor Page Routes (HTML rendering).
Vendor pages for:
- Loyalty terminal (primary daily interface for staff)
- Loyalty members management
- Program settings
- Stats dashboard
"""
import logging
from fastapi import APIRouter, Depends, Path, Request
from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_from_cookie_or_header, get_db
from app.modules.core.services.platform_settings_service import platform_settings_service
from app.templates_config import templates
from app.modules.tenancy.models import User, Vendor
logger = logging.getLogger(__name__)
router = APIRouter()
# Route configuration for module route discovery
ROUTE_CONFIG = {
"prefix": "/loyalty",
"tags": ["vendor-loyalty"],
}
# ============================================================================
# HELPER: Build Vendor Context
# ============================================================================
def get_vendor_context(
request: Request,
db: Session,
current_user: User,
vendor_code: str,
**extra_context,
) -> dict:
"""Build template context for vendor loyalty pages."""
# Load vendor from database
vendor = db.query(Vendor).filter(Vendor.subdomain == vendor_code).first()
# Get platform defaults
platform_config = platform_settings_service.get_storefront_config(db)
# Resolve with vendor override
storefront_locale = platform_config["locale"]
storefront_currency = platform_config["currency"]
if vendor and vendor.storefront_locale:
storefront_locale = vendor.storefront_locale
context = {
"request": request,
"user": current_user,
"vendor": vendor,
"vendor_code": vendor_code,
"storefront_locale": storefront_locale,
"storefront_currency": storefront_currency,
"dashboard_language": vendor.dashboard_language if vendor else "en",
}
# Add any extra context
if extra_context:
context.update(extra_context)
return context
# ============================================================================
# LOYALTY TERMINAL (Primary Daily Interface)
# ============================================================================
@router.get(
"/{vendor_code}/terminal",
response_class=HTMLResponse,
include_in_schema=False,
)
async def vendor_loyalty_terminal(
request: Request,
vendor_code: str = Path(..., description="Vendor code"),
current_user: User = Depends(get_current_vendor_from_cookie_or_header),
db: Session = Depends(get_db),
):
"""
Render loyalty terminal page.
Primary interface for staff to look up customers, award points, and process redemptions.
"""
return templates.TemplateResponse(
"loyalty/vendor/terminal.html",
get_vendor_context(request, db, current_user, vendor_code),
)
# ============================================================================
# LOYALTY MEMBERS
# ============================================================================
@router.get(
"/{vendor_code}/cards",
response_class=HTMLResponse,
include_in_schema=False,
)
async def vendor_loyalty_cards(
request: Request,
vendor_code: str = Path(..., description="Vendor code"),
current_user: User = Depends(get_current_vendor_from_cookie_or_header),
db: Session = Depends(get_db),
):
"""
Render loyalty members list page.
Shows all loyalty card holders for this company.
"""
return templates.TemplateResponse(
"loyalty/vendor/cards.html",
get_vendor_context(request, db, current_user, vendor_code),
)
@router.get(
"/{vendor_code}/cards/{card_id}",
response_class=HTMLResponse,
include_in_schema=False,
)
async def vendor_loyalty_card_detail(
request: Request,
vendor_code: str = Path(..., description="Vendor code"),
card_id: int = Path(..., description="Loyalty card ID"),
current_user: User = Depends(get_current_vendor_from_cookie_or_header),
db: Session = Depends(get_db),
):
"""
Render loyalty card detail page.
Shows card holder info, transaction history, and actions.
"""
return templates.TemplateResponse(
"loyalty/vendor/card-detail.html",
get_vendor_context(request, db, current_user, vendor_code, card_id=card_id),
)
# ============================================================================
# PROGRAM SETTINGS
# ============================================================================
@router.get(
"/{vendor_code}/settings",
response_class=HTMLResponse,
include_in_schema=False,
)
async def vendor_loyalty_settings(
request: Request,
vendor_code: str = Path(..., description="Vendor code"),
current_user: User = Depends(get_current_vendor_from_cookie_or_header),
db: Session = Depends(get_db),
):
"""
Render loyalty program settings page.
Allows vendor to configure points rate, rewards, branding, etc.
"""
return templates.TemplateResponse(
"loyalty/vendor/settings.html",
get_vendor_context(request, db, current_user, vendor_code),
)
# ============================================================================
# STATS DASHBOARD
# ============================================================================
@router.get(
"/{vendor_code}/stats",
response_class=HTMLResponse,
include_in_schema=False,
)
async def vendor_loyalty_stats(
request: Request,
vendor_code: str = Path(..., description="Vendor code"),
current_user: User = Depends(get_current_vendor_from_cookie_or_header),
db: Session = Depends(get_db),
):
"""
Render loyalty statistics dashboard.
Shows vendor's loyalty program metrics and trends.
"""
return templates.TemplateResponse(
"loyalty/vendor/stats.html",
get_vendor_context(request, db, current_user, vendor_code),
)
# ============================================================================
# ENROLLMENT
# ============================================================================
@router.get(
"/{vendor_code}/enroll",
response_class=HTMLResponse,
include_in_schema=False,
)
async def vendor_loyalty_enroll(
request: Request,
vendor_code: str = Path(..., description="Vendor code"),
current_user: User = Depends(get_current_vendor_from_cookie_or_header),
db: Session = Depends(get_db),
):
"""
Render customer enrollment page.
Staff interface for enrolling new customers into the loyalty program.
"""
return templates.TemplateResponse(
"loyalty/vendor/enroll.html",
get_vendor_context(request, db, current_user, vendor_code),
)

View File

@@ -33,8 +33,13 @@ from app.modules.loyalty.schemas.program import (
ProgramListResponse,
# Points rewards
PointsRewardConfig,
TierConfig,
# Stats
ProgramStatsResponse,
CompanyStatsResponse,
# Company settings
CompanySettingsResponse,
CompanySettingsUpdate,
)
from app.modules.loyalty.schemas.card import (
@@ -44,6 +49,9 @@ from app.modules.loyalty.schemas.card import (
CardDetailResponse,
CardListResponse,
CardLookupResponse,
# Transactions
TransactionResponse,
TransactionListResponse,
)
from app.modules.loyalty.schemas.stamp import (
@@ -52,6 +60,8 @@ from app.modules.loyalty.schemas.stamp import (
StampResponse,
StampRedeemRequest,
StampRedeemResponse,
StampVoidRequest,
StampVoidResponse,
)
from app.modules.loyalty.schemas.points import (
@@ -60,6 +70,10 @@ from app.modules.loyalty.schemas.points import (
PointsEarnResponse,
PointsRedeemRequest,
PointsRedeemResponse,
PointsVoidRequest,
PointsVoidResponse,
PointsAdjustRequest,
PointsAdjustResponse,
)
from app.modules.loyalty.schemas.pin import (
@@ -79,23 +93,35 @@ __all__ = [
"ProgramResponse",
"ProgramListResponse",
"PointsRewardConfig",
"TierConfig",
"ProgramStatsResponse",
"CompanyStatsResponse",
"CompanySettingsResponse",
"CompanySettingsUpdate",
# Card
"CardEnrollRequest",
"CardResponse",
"CardDetailResponse",
"CardListResponse",
"CardLookupResponse",
"TransactionResponse",
"TransactionListResponse",
# Stamp
"StampRequest",
"StampResponse",
"StampRedeemRequest",
"StampRedeemResponse",
"StampVoidRequest",
"StampVoidResponse",
# Points
"PointsEarnRequest",
"PointsEarnResponse",
"PointsRedeemRequest",
"PointsRedeemResponse",
"PointsVoidRequest",
"PointsVoidResponse",
"PointsAdjustRequest",
"PointsAdjustResponse",
# PIN
"PinCreate",
"PinUpdate",

View File

@@ -1,6 +1,11 @@
# app/modules/loyalty/schemas/card.py
"""
Pydantic schemas for loyalty card operations.
Company-based cards:
- Cards belong to a company's loyalty program
- One card per customer per company
- Can be used at any vendor within the company
"""
from datetime import datetime
@@ -29,8 +34,9 @@ class CardResponse(BaseModel):
id: int
card_number: str
customer_id: int
vendor_id: int
company_id: int
program_id: int
enrolled_at_vendor_id: int | None = None
# Stamps
stamp_count: int
@@ -64,6 +70,9 @@ class CardDetailResponse(CardResponse):
customer_name: str | None = None
customer_email: str | None = None
# Company info
company_name: str | None = None
# Program info
program_name: str
program_type: str
@@ -73,6 +82,7 @@ class CardDetailResponse(CardResponse):
last_stamp_at: datetime | None = None
last_points_at: datetime | None = None
last_redemption_at: datetime | None = None
last_activity_at: datetime | None = None
# Wallet URLs
google_wallet_url: str | None = None
@@ -98,6 +108,10 @@ class CardLookupResponse(BaseModel):
customer_name: str | None = None
customer_email: str
# Company context
company_id: int
company_name: str | None = None
# Current balances
stamp_count: int
stamps_target: int
@@ -108,6 +122,9 @@ class CardLookupResponse(BaseModel):
can_redeem_stamps: bool = False
stamp_reward_description: str | None = None
# Available points rewards
available_rewards: list[dict] = []
# Cooldown status
can_stamp: bool = True
cooldown_ends_at: datetime | None = None
@@ -116,3 +133,44 @@ class CardLookupResponse(BaseModel):
stamps_today: int = 0
max_daily_stamps: int = 5
can_earn_more_stamps: bool = True
class TransactionResponse(BaseModel):
"""Schema for a loyalty transaction."""
model_config = ConfigDict(from_attributes=True)
id: int
card_id: int
vendor_id: int | None = None
vendor_name: str | None = None
transaction_type: str
# Deltas
stamps_delta: int = 0
points_delta: int = 0
# Balances after
stamps_balance_after: int | None = None
points_balance_after: int | None = None
# Context
purchase_amount_cents: int | None = None
order_reference: str | None = None
reward_id: str | None = None
reward_description: str | None = None
notes: str | None = None
# Staff
staff_name: str | None = None
# Timestamps
transaction_at: datetime
created_at: datetime
class TransactionListResponse(BaseModel):
"""Schema for listing transactions."""
transactions: list[TransactionResponse]
total: int

View File

@@ -1,6 +1,11 @@
# app/modules/loyalty/schemas/points.py
"""
Pydantic schemas for points operations.
Company-based points:
- Points earned at any vendor count toward company total
- Points can be redeemed at any vendor within the company
- Supports voiding points for returns
"""
from pydantic import BaseModel, Field
@@ -67,6 +72,9 @@ class PointsEarnResponse(BaseModel):
points_balance: int
total_points_earned: int
# Location
vendor_id: int | None = None
class PointsRedeemRequest(BaseModel):
"""Schema for redeeming points for a reward."""
@@ -122,3 +130,108 @@ class PointsRedeemResponse(BaseModel):
card_number: str
points_balance: int
total_points_redeemed: int
# Location
vendor_id: int | None = None
class PointsVoidRequest(BaseModel):
"""Schema for voiding points (for returns)."""
card_id: int | None = Field(
None,
description="Card ID (use this or qr_code)",
)
qr_code: str | None = Field(
None,
description="QR code data from card scan",
)
card_number: str | None = Field(
None,
description="Card number (manual entry)",
)
# Points to void (use one method)
points_to_void: int | None = Field(
None,
gt=0,
description="Number of points to void",
)
original_transaction_id: int | None = Field(
None,
description="ID of original transaction to void",
)
order_reference: str | None = Field(
None,
max_length=100,
description="Order reference to find and void",
)
# Authentication
staff_pin: str | None = Field(
None,
min_length=4,
max_length=6,
description="Staff PIN for verification",
)
# Required metadata
notes: str | None = Field(
None,
max_length=500,
description="Reason for voiding",
)
class PointsVoidResponse(BaseModel):
"""Schema for points void response."""
success: bool = True
message: str = "Points voided successfully"
# Void info
points_voided: int
# Card state after void
card_id: int
card_number: str
points_balance: int
# Location
vendor_id: int | None = None
class PointsAdjustRequest(BaseModel):
"""Schema for manual points adjustment (admin)."""
points_delta: int = Field(
...,
description="Points to add (positive) or remove (negative)",
)
reason: str = Field(
...,
min_length=5,
max_length=500,
description="Reason for adjustment (required)",
)
staff_pin: str | None = Field(
None,
min_length=4,
max_length=6,
description="Staff PIN for verification",
)
class PointsAdjustResponse(BaseModel):
"""Schema for points adjustment response."""
success: bool = True
message: str = "Points adjusted successfully"
# Adjustment info
points_delta: int
# Card state after adjustment
card_id: int
card_number: str
points_balance: int

View File

@@ -1,6 +1,11 @@
# app/modules/loyalty/schemas/program.py
"""
Pydantic schemas for loyalty program operations.
Company-based programs:
- One program per company
- All vendors under a company share the same program
- Supports chain-wide loyalty across locations
"""
from datetime import datetime
@@ -18,12 +23,22 @@ class PointsRewardConfig(BaseModel):
is_active: bool = Field(True, description="Whether reward is currently available")
class TierConfig(BaseModel):
"""Configuration for a loyalty tier (future use)."""
id: str = Field(..., description="Tier identifier")
name: str = Field(..., max_length=50, description="Tier name (e.g., Bronze, Silver, Gold)")
points_threshold: int = Field(..., ge=0, description="Points needed to reach this tier")
benefits: list[str] = Field(default_factory=list, description="List of tier benefits")
multiplier: float = Field(1.0, ge=1.0, description="Points earning multiplier")
class ProgramCreate(BaseModel):
"""Schema for creating a loyalty program."""
# Program type
loyalty_type: str = Field(
"stamps",
"points",
pattern="^(stamps|points|hybrid)$",
description="Program type: stamps, points, or hybrid",
)
@@ -42,11 +57,37 @@ class ProgramCreate(BaseModel):
)
# Points configuration
points_per_euro: int = Field(10, ge=1, le=1000, description="Points per euro spent")
points_per_euro: int = Field(1, ge=1, le=100, description="Points per euro spent")
points_rewards: list[PointsRewardConfig] = Field(
default_factory=list,
description="Available point rewards",
)
points_expiration_days: int | None = Field(
None,
ge=30,
description="Days of inactivity before points expire (None = never)",
)
welcome_bonus_points: int = Field(
0,
ge=0,
description="Bonus points awarded on enrollment",
)
minimum_redemption_points: int = Field(
100,
ge=1,
description="Minimum points required for redemption",
)
minimum_purchase_cents: int = Field(
0,
ge=0,
description="Minimum purchase amount to earn points (0 = no minimum)",
)
# Future: Tier configuration
tier_config: list[TierConfig] | None = Field(
None,
description="Tier configuration (future use)",
)
# Anti-fraud
cooldown_minutes: int = Field(15, ge=0, le=1440, description="Minutes between stamps")
@@ -90,8 +131,15 @@ class ProgramUpdate(BaseModel):
stamps_reward_value_cents: int | None = Field(None, ge=0)
# Points configuration
points_per_euro: int | None = Field(None, ge=1, le=1000)
points_per_euro: int | None = Field(None, ge=1, le=100)
points_rewards: list[PointsRewardConfig] | None = None
points_expiration_days: int | None = Field(None, ge=30)
welcome_bonus_points: int | None = Field(None, ge=0)
minimum_redemption_points: int | None = Field(None, ge=1)
minimum_purchase_cents: int | None = Field(None, ge=0)
# Future: Tier configuration
tier_config: list[TierConfig] | None = None
# Anti-fraud
cooldown_minutes: int | None = Field(None, ge=0, le=1440)
@@ -123,7 +171,8 @@ class ProgramResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
vendor_id: int
company_id: int
company_name: str | None = None # Populated by API from Company join
loyalty_type: str
# Stamps
@@ -134,6 +183,10 @@ class ProgramResponse(BaseModel):
# Points
points_per_euro: int
points_rewards: list[PointsRewardConfig] = []
points_expiration_days: int | None = None
welcome_bonus_points: int = 0
minimum_redemption_points: int = 100
minimum_purchase_cents: int = 0
# Anti-fraud
cooldown_minutes: int
@@ -167,6 +220,12 @@ class ProgramResponse(BaseModel):
is_points_enabled: bool = False
display_name: str = "Loyalty Card"
# Stats (populated by API)
total_cards: int | None = None
active_cards: int | None = None
total_points_issued: int | None = None
total_points_redeemed: int | None = None
class ProgramListResponse(BaseModel):
"""Schema for listing loyalty programs (admin)."""
@@ -201,3 +260,61 @@ class ProgramStatsResponse(BaseModel):
# Value
estimated_liability_cents: int = 0 # Unredeemed stamps/points value
class CompanyStatsResponse(BaseModel):
"""Schema for company-wide loyalty statistics across all locations."""
company_id: int
program_id: int | None = None # May be None if no program set up
# Cards
total_cards: int = 0
active_cards: int = 0
# Points - all time
total_points_issued: int = 0
total_points_redeemed: int = 0
# Points - last 30 days
points_issued_30d: int = 0
points_redeemed_30d: int = 0
transactions_30d: int = 0
# Program info (optional)
program: dict | None = None
# Location breakdown
locations: list[dict] = [] # Per-location breakdown
class CompanySettingsResponse(BaseModel):
"""Schema for company loyalty settings."""
model_config = ConfigDict(from_attributes=True)
id: int
company_id: int
staff_pin_policy: str
staff_pin_lockout_attempts: int
staff_pin_lockout_minutes: int
allow_self_enrollment: bool
allow_void_transactions: bool
allow_cross_location_redemption: bool
created_at: datetime
updated_at: datetime
class CompanySettingsUpdate(BaseModel):
"""Schema for updating company loyalty settings."""
staff_pin_policy: str | None = Field(
None,
pattern="^(required|optional|disabled)$",
description="Staff PIN policy: required, optional, or disabled",
)
staff_pin_lockout_attempts: int | None = Field(None, ge=3, le=10)
staff_pin_lockout_minutes: int | None = Field(None, ge=5, le=120)
allow_self_enrollment: bool | None = None
allow_void_transactions: bool | None = None
allow_cross_location_redemption: bool | None = None

View File

@@ -1,6 +1,11 @@
# app/modules/loyalty/schemas/stamp.py
"""
Pydantic schemas for stamp operations.
Company-based stamps:
- Stamps earned at any vendor count toward company total
- Stamps can be redeemed at any vendor within the company
- Supports voiding stamps for returns
"""
from datetime import datetime
@@ -64,6 +69,9 @@ class StampResponse(BaseModel):
stamps_today: int
stamps_remaining_today: int
# Location
vendor_id: int | None = None
class StampRedeemRequest(BaseModel):
"""Schema for redeeming stamps for a reward."""
@@ -112,3 +120,67 @@ class StampRedeemResponse(BaseModel):
# Reward info
reward_description: str
total_redemptions: int # Lifetime redemptions for this card
# Location
vendor_id: int | None = None
class StampVoidRequest(BaseModel):
"""Schema for voiding stamps (for returns)."""
card_id: int | None = Field(
None,
description="Card ID (use this or qr_code)",
)
qr_code: str | None = Field(
None,
description="QR code data from card scan",
)
card_number: str | None = Field(
None,
description="Card number (manual entry)",
)
# Stamps to void (use one method)
stamps_to_void: int | None = Field(
None,
gt=0,
description="Number of stamps to void",
)
original_transaction_id: int | None = Field(
None,
description="ID of original transaction to void",
)
# Authentication
staff_pin: str | None = Field(
None,
min_length=4,
max_length=6,
description="Staff PIN for verification",
)
# Required metadata
notes: str | None = Field(
None,
max_length=500,
description="Reason for voiding",
)
class StampVoidResponse(BaseModel):
"""Schema for stamp void response."""
success: bool = True
message: str = "Stamps voided successfully"
# Void info
stamps_voided: int
# Card state after void
card_id: int
card_number: str
stamp_count: int
# Location
vendor_id: int | None = None

View File

@@ -2,9 +2,14 @@
"""
Loyalty card service.
Company-based card operations:
- Cards belong to a company's loyalty program
- One card per customer per company
- Can be used at any vendor within the company
Handles card operations including:
- Customer enrollment
- Card lookup (by ID, QR code, card number)
- Customer enrollment (with welcome bonus)
- Card lookup (by ID, QR code, card number, email, phone)
- Card management (activation, deactivation)
"""
@@ -19,7 +24,12 @@ from app.modules.loyalty.exceptions import (
LoyaltyProgramInactiveException,
LoyaltyProgramNotFoundException,
)
from app.modules.loyalty.models import LoyaltyCard, LoyaltyProgram, LoyaltyTransaction, TransactionType
from app.modules.loyalty.models import (
LoyaltyCard,
LoyaltyProgram,
LoyaltyTransaction,
TransactionType,
)
logger = logging.getLogger(__name__)
@@ -51,10 +61,31 @@ class CardService:
def get_card_by_number(self, db: Session, card_number: str) -> LoyaltyCard | None:
"""Get a loyalty card by card number."""
# Normalize card number (remove dashes)
normalized = card_number.replace("-", "").replace(" ", "")
return (
db.query(LoyaltyCard)
.options(joinedload(LoyaltyCard.program))
.filter(LoyaltyCard.card_number == card_number)
.filter(
LoyaltyCard.card_number.replace("-", "") == normalized
)
.first()
)
def get_card_by_customer_and_company(
self,
db: Session,
customer_id: int,
company_id: int,
) -> LoyaltyCard | None:
"""Get a customer's card for a company's program."""
return (
db.query(LoyaltyCard)
.options(joinedload(LoyaltyCard.program))
.filter(
LoyaltyCard.customer_id == customer_id,
LoyaltyCard.company_id == company_id,
)
.first()
)
@@ -89,6 +120,7 @@ class CardService:
card_id: int | None = None,
qr_code: str | None = None,
card_number: str | None = None,
company_id: int | None = None,
) -> LoyaltyCard:
"""
Look up a card by any identifier.
@@ -97,7 +129,8 @@ class CardService:
db: Database session
card_id: Card ID
qr_code: QR code data
card_number: Card number
card_number: Card number (with or without dashes)
company_id: Optional company filter
Returns:
Found card
@@ -118,28 +151,73 @@ class CardService:
identifier = card_id or qr_code or card_number or "unknown"
raise LoyaltyCardNotFoundException(str(identifier))
# Filter by company if specified
if company_id and card.company_id != company_id:
raise LoyaltyCardNotFoundException(str(card_id or qr_code or card_number))
return card
def list_cards(
def lookup_card_for_vendor(
self,
db: Session,
vendor_id: int,
*,
card_id: int | None = None,
qr_code: str | None = None,
card_number: str | None = None,
) -> LoyaltyCard:
"""
Look up a card for a specific vendor (must be in same company).
Args:
db: Database session
vendor_id: Vendor ID (to get company context)
card_id: Card ID
qr_code: QR code data
card_number: Card number
Returns:
Found card (verified to be in vendor's company)
Raises:
LoyaltyCardNotFoundException: If no card found or wrong company
"""
from app.modules.tenancy.models import Vendor
vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first()
if not vendor:
raise LoyaltyCardNotFoundException("vendor not found")
return self.lookup_card(
db,
card_id=card_id,
qr_code=qr_code,
card_number=card_number,
company_id=vendor.company_id,
)
def list_cards(
self,
db: Session,
company_id: int,
*,
vendor_id: int | None = None,
skip: int = 0,
limit: int = 50,
is_active: bool | None = None,
search: str | None = None,
) -> tuple[list[LoyaltyCard], int]:
"""
List loyalty cards for a vendor.
List loyalty cards for a company.
Args:
db: Database session
vendor_id: Vendor ID
company_id: Company ID
vendor_id: Optional filter by enrolled vendor
skip: Pagination offset
limit: Pagination limit
is_active: Filter by active status
search: Search by card number or customer email
search: Search by card number, email, or name
Returns:
(cards, total_count)
@@ -149,18 +227,24 @@ class CardService:
query = (
db.query(LoyaltyCard)
.options(joinedload(LoyaltyCard.customer))
.filter(LoyaltyCard.vendor_id == vendor_id)
.filter(LoyaltyCard.company_id == company_id)
)
if vendor_id:
query = query.filter(LoyaltyCard.enrolled_at_vendor_id == vendor_id)
if is_active is not None:
query = query.filter(LoyaltyCard.is_active == is_active)
if search:
# Normalize search term for card number matching
search_normalized = search.replace("-", "").replace(" ", "")
query = query.join(Customer).filter(
(LoyaltyCard.card_number.ilike(f"%{search}%"))
(LoyaltyCard.card_number.replace("-", "").ilike(f"%{search_normalized}%"))
| (Customer.email.ilike(f"%{search}%"))
| (Customer.first_name.ilike(f"%{search}%"))
| (Customer.last_name.ilike(f"%{search}%"))
| (Customer.phone.ilike(f"%{search}%"))
)
total = query.count()
@@ -181,7 +265,7 @@ class CardService:
"""List all loyalty cards for a customer."""
return (
db.query(LoyaltyCard)
.options(joinedload(LoyaltyCard.program))
.options(joinedload(LoyaltyCard.program), joinedload(LoyaltyCard.company))
.filter(LoyaltyCard.customer_id == customer_id)
.all()
)
@@ -194,18 +278,18 @@ class CardService:
self,
db: Session,
customer_id: int,
vendor_id: int,
company_id: int,
*,
program_id: int | None = None,
enrolled_at_vendor_id: int | None = None,
) -> LoyaltyCard:
"""
Enroll a customer in a loyalty program.
Enroll a customer in a company's loyalty program.
Args:
db: Database session
customer_id: Customer ID
vendor_id: Vendor ID
program_id: Optional program ID (defaults to vendor's program)
company_id: Company ID
enrolled_at_vendor_id: Vendor where customer enrolled (for analytics)
Returns:
Created loyalty card
@@ -216,35 +300,29 @@ class CardService:
LoyaltyCardAlreadyExistsException: If customer already enrolled
"""
# Get the program
if program_id:
program = (
db.query(LoyaltyProgram)
.filter(LoyaltyProgram.id == program_id)
.first()
)
else:
program = (
db.query(LoyaltyProgram)
.filter(LoyaltyProgram.vendor_id == vendor_id)
.first()
)
program = (
db.query(LoyaltyProgram)
.filter(LoyaltyProgram.company_id == company_id)
.first()
)
if not program:
raise LoyaltyProgramNotFoundException(f"vendor:{vendor_id}")
raise LoyaltyProgramNotFoundException(f"company:{company_id}")
if not program.is_active:
raise LoyaltyProgramInactiveException(program.id)
# Check if customer already has a card
existing = self.get_card_by_customer_and_program(db, customer_id, program.id)
existing = self.get_card_by_customer_and_company(db, customer_id, company_id)
if existing:
raise LoyaltyCardAlreadyExistsException(customer_id, program.id)
# Create the card
card = LoyaltyCard(
company_id=company_id,
customer_id=customer_id,
program_id=program.id,
vendor_id=vendor_id,
enrolled_at_vendor_id=enrolled_at_vendor_id,
)
db.add(card)
@@ -252,32 +330,88 @@ class CardService:
# Create enrollment transaction
transaction = LoyaltyTransaction(
company_id=company_id,
card_id=card.id,
vendor_id=vendor_id,
vendor_id=enrolled_at_vendor_id,
transaction_type=TransactionType.CARD_CREATED.value,
transaction_at=datetime.now(UTC),
)
db.add(transaction)
# Award welcome bonus if configured
if program.welcome_bonus_points > 0:
card.add_points(program.welcome_bonus_points)
bonus_transaction = LoyaltyTransaction(
company_id=company_id,
card_id=card.id,
vendor_id=enrolled_at_vendor_id,
transaction_type=TransactionType.WELCOME_BONUS.value,
points_delta=program.welcome_bonus_points,
points_balance_after=card.points_balance,
notes="Welcome bonus on enrollment",
transaction_at=datetime.now(UTC),
)
db.add(bonus_transaction)
db.commit()
db.refresh(card)
logger.info(
f"Enrolled customer {customer_id} in loyalty program {program.id} "
f"(card: {card.card_number})"
f"Enrolled customer {customer_id} in company {company_id} loyalty program "
f"(card: {card.card_number}, bonus: {program.welcome_bonus_points} pts)"
)
return card
def deactivate_card(self, db: Session, card_id: int) -> LoyaltyCard:
def enroll_customer_for_vendor(
self,
db: Session,
customer_id: int,
vendor_id: int,
) -> LoyaltyCard:
"""
Enroll a customer through a specific vendor.
Looks up the vendor's company and enrolls in the company's program.
Args:
db: Database session
customer_id: Customer ID
vendor_id: Vendor ID
Returns:
Created loyalty card
"""
from app.modules.tenancy.models import Vendor
vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first()
if not vendor:
raise LoyaltyProgramNotFoundException(f"vendor:{vendor_id}")
return self.enroll_customer(
db,
customer_id,
vendor.company_id,
enrolled_at_vendor_id=vendor_id,
)
def deactivate_card(
self,
db: Session,
card_id: int,
*,
vendor_id: int | None = None,
) -> LoyaltyCard:
"""Deactivate a loyalty card."""
card = self.require_card(db, card_id)
card.is_active = False
# Create deactivation transaction
transaction = LoyaltyTransaction(
company_id=card.company_id,
card_id=card.id,
vendor_id=card.vendor_id,
vendor_id=vendor_id,
transaction_type=TransactionType.CARD_DEACTIVATED.value,
transaction_at=datetime.now(UTC),
)
@@ -334,6 +468,7 @@ class CardService:
"""Get transaction history for a card."""
query = (
db.query(LoyaltyTransaction)
.options(joinedload(LoyaltyTransaction.vendor))
.filter(LoyaltyTransaction.card_id == card_id)
.order_by(LoyaltyTransaction.transaction_at.desc())
)

View File

@@ -2,9 +2,14 @@
"""
Staff PIN service.
Company-based PIN operations:
- PINs belong to a company's loyalty program
- Each vendor (location) has its own set of staff PINs
- Staff can only use PINs at their assigned location
Handles PIN operations including:
- PIN creation and management
- PIN verification with lockout
- PIN verification with lockout (per vendor)
- PIN security (failed attempts, lockout)
"""
@@ -41,16 +46,17 @@ class PinService:
db: Session,
program_id: int,
staff_id: str,
*,
vendor_id: int | None = None,
) -> StaffPin | None:
"""Get a staff PIN by employee ID."""
return (
db.query(StaffPin)
.filter(
StaffPin.program_id == program_id,
StaffPin.staff_id == staff_id,
)
.first()
query = db.query(StaffPin).filter(
StaffPin.program_id == program_id,
StaffPin.staff_id == staff_id,
)
if vendor_id:
query = query.filter(StaffPin.vendor_id == vendor_id)
return query.first()
def require_pin(self, db: Session, pin_id: int) -> StaffPin:
"""Get a PIN or raise exception if not found."""
@@ -64,16 +70,61 @@ class PinService:
db: Session,
program_id: int,
*,
vendor_id: int | None = None,
is_active: bool | None = None,
) -> list[StaffPin]:
"""List all staff PINs for a program."""
"""
List staff PINs for a program.
Args:
db: Database session
program_id: Program ID
vendor_id: Optional filter by vendor (location)
is_active: Filter by active status
Returns:
List of StaffPin objects
"""
query = db.query(StaffPin).filter(StaffPin.program_id == program_id)
if vendor_id is not None:
query = query.filter(StaffPin.vendor_id == vendor_id)
if is_active is not None:
query = query.filter(StaffPin.is_active == is_active)
return query.order_by(StaffPin.name).all()
def list_pins_for_company(
self,
db: Session,
company_id: int,
*,
vendor_id: int | None = None,
is_active: bool | None = None,
) -> list[StaffPin]:
"""
List staff PINs for a company.
Args:
db: Database session
company_id: Company ID
vendor_id: Optional filter by vendor (location)
is_active: Filter by active status
Returns:
List of StaffPin objects
"""
query = db.query(StaffPin).filter(StaffPin.company_id == company_id)
if vendor_id is not None:
query = query.filter(StaffPin.vendor_id == vendor_id)
if is_active is not None:
query = query.filter(StaffPin.is_active == is_active)
return query.order_by(StaffPin.vendor_id, StaffPin.name).all()
# =========================================================================
# Write Operations
# =========================================================================
@@ -91,13 +142,21 @@ class PinService:
Args:
db: Database session
program_id: Program ID
vendor_id: Vendor ID
vendor_id: Vendor ID (location where staff works)
data: PIN creation data
Returns:
Created PIN
"""
from app.modules.loyalty.models import LoyaltyProgram
# Get company_id from program
program = db.query(LoyaltyProgram).filter(LoyaltyProgram.id == program_id).first()
if not program:
raise StaffPinNotFoundException(f"program:{program_id}")
pin = StaffPin(
company_id=program.company_id,
program_id=program_id,
vendor_id=vendor_id,
name=data.name,
@@ -109,7 +168,9 @@ class PinService:
db.commit()
db.refresh(pin)
logger.info(f"Created staff PIN {pin.id} for '{pin.name}' in program {program_id}")
logger.info(
f"Created staff PIN {pin.id} for '{pin.name}' at vendor {vendor_id}"
)
return pin
@@ -158,11 +219,12 @@ class PinService:
"""Delete a staff PIN."""
pin = self.require_pin(db, pin_id)
program_id = pin.program_id
vendor_id = pin.vendor_id
db.delete(pin)
db.commit()
logger.info(f"Deleted staff PIN {pin_id} from program {program_id}")
logger.info(f"Deleted staff PIN {pin_id} from vendor {vendor_id}")
def unlock_pin(self, db: Session, pin_id: int) -> StaffPin:
"""Unlock a locked staff PIN."""
@@ -184,16 +246,21 @@ class PinService:
db: Session,
program_id: int,
plain_pin: str,
*,
vendor_id: int | None = None,
) -> StaffPin:
"""
Verify a staff PIN.
Checks all active PINs for the program and returns the matching one.
For company-wide programs, if vendor_id is provided, only checks
PINs assigned to that vendor. This ensures staff can only use
their PIN at their assigned location.
Args:
db: Database session
program_id: Program ID
plain_pin: Plain text PIN to verify
vendor_id: Optional vendor ID to restrict PIN lookup
Returns:
Verified StaffPin object
@@ -202,8 +269,8 @@ class PinService:
InvalidStaffPinException: PIN is invalid
StaffPinLockedException: PIN is locked
"""
# Get all active PINs for the program
pins = self.list_pins(db, program_id, is_active=True)
# Get active PINs (optionally filtered by vendor)
pins = self.list_pins(db, program_id, vendor_id=vendor_id, is_active=True)
if not pins:
raise InvalidStaffPinException()
@@ -220,7 +287,9 @@ class PinService:
pin.record_success()
db.commit()
logger.debug(f"PIN verified for '{pin.name}' in program {program_id}")
logger.debug(
f"PIN verified for '{pin.name}' at vendor {pin.vendor_id}"
)
return pin
@@ -254,6 +323,8 @@ class PinService:
db: Session,
program_id: int,
plain_pin: str,
*,
vendor_id: int | None = None,
) -> StaffPin | None:
"""
Find a matching PIN without recording attempts.
@@ -264,11 +335,12 @@ class PinService:
db: Database session
program_id: Program ID
plain_pin: Plain text PIN to check
vendor_id: Optional vendor ID to restrict lookup
Returns:
Matching StaffPin or None
"""
pins = self.list_pins(db, program_id, is_active=True)
pins = self.list_pins(db, program_id, vendor_id=vendor_id, is_active=True)
for pin in pins:
if not pin.is_locked and pin.verify_pin(plain_pin):

View File

@@ -2,9 +2,15 @@
"""
Points service.
Company-based points operations:
- Points earned at any vendor count toward company total
- Points can be redeemed at any vendor within the company
- Supports voiding points for returns
Handles points operations including:
- Earning points from purchases
- Redeeming points for rewards
- Voiding points (for returns)
- Points balance management
"""
@@ -34,6 +40,7 @@ class PointsService:
self,
db: Session,
*,
vendor_id: int,
card_id: int | None = None,
qr_code: str | None = None,
card_number: str | None = None,
@@ -51,6 +58,7 @@ class PointsService:
Args:
db: Database session
vendor_id: Vendor ID (where purchase is being made)
card_id: Card ID
qr_code: QR code data
card_number: Card number
@@ -64,9 +72,10 @@ class PointsService:
Returns:
Dict with operation result
"""
# Look up the card
card = card_service.lookup_card(
# Look up the card (validates it belongs to vendor's company)
card = card_service.lookup_card_for_vendor(
db,
vendor_id,
card_id=card_id,
qr_code=qr_code,
card_number=card_number,
@@ -85,12 +94,26 @@ class PointsService:
logger.warning(f"Points attempted on stamps-only program {program.id}")
raise LoyaltyCardInactiveException(card.id)
# Check minimum purchase amount
if program.minimum_purchase_cents > 0 and purchase_amount_cents < program.minimum_purchase_cents:
return {
"success": True,
"message": f"Purchase below minimum of €{program.minimum_purchase_cents/100:.2f}",
"points_earned": 0,
"points_per_euro": program.points_per_euro,
"purchase_amount_cents": purchase_amount_cents,
"card_id": card.id,
"card_number": card.card_number,
"points_balance": card.points_balance,
"total_points_earned": card.total_points_earned,
}
# Verify staff PIN if required
verified_pin = None
if program.require_staff_pin:
if not staff_pin:
raise StaffPinRequiredException()
verified_pin = pin_service.verify_pin(db, program.id, staff_pin)
verified_pin = pin_service.verify_pin(db, program.id, staff_pin, vendor_id=vendor_id)
# Calculate points
# points_per_euro is per full euro, so divide cents by 100
@@ -115,11 +138,13 @@ class PointsService:
card.points_balance += points_earned
card.total_points_earned += points_earned
card.last_points_at = now
card.last_activity_at = now
# Create transaction
transaction = LoyaltyTransaction(
company_id=card.company_id,
card_id=card.id,
vendor_id=card.vendor_id,
vendor_id=vendor_id,
staff_pin_id=verified_pin.id if verified_pin else None,
transaction_type=TransactionType.POINTS_EARNED.value,
points_delta=points_earned,
@@ -138,7 +163,7 @@ class PointsService:
db.refresh(card)
logger.info(
f"Added {points_earned} points to card {card.id} "
f"Added {points_earned} points to card {card.id} at vendor {vendor_id} "
f"(purchase: €{purchase_euros:.2f}, balance: {card.points_balance})"
)
@@ -152,12 +177,14 @@ class PointsService:
"card_number": card.card_number,
"points_balance": card.points_balance,
"total_points_earned": card.total_points_earned,
"vendor_id": vendor_id,
}
def redeem_points(
self,
db: Session,
*,
vendor_id: int,
card_id: int | None = None,
qr_code: str | None = None,
card_number: str | None = None,
@@ -172,6 +199,7 @@ class PointsService:
Args:
db: Database session
vendor_id: Vendor ID (where redemption is happening)
card_id: Card ID
qr_code: QR code data
card_number: Card number
@@ -188,9 +216,10 @@ class PointsService:
InvalidRewardException: Reward not found or inactive
InsufficientPointsException: Not enough points
"""
# Look up the card
card = card_service.lookup_card(
# Look up the card (validates it belongs to vendor's company)
card = card_service.lookup_card_for_vendor(
db,
vendor_id,
card_id=card_id,
qr_code=qr_code,
card_number=card_number,
@@ -215,6 +244,10 @@ class PointsService:
points_required = reward["points_required"]
reward_name = reward["name"]
# Check minimum redemption
if points_required < program.minimum_redemption_points:
raise InvalidRewardException(reward_id)
# Check if enough points
if card.points_balance < points_required:
raise InsufficientPointsException(card.points_balance, points_required)
@@ -224,18 +257,20 @@ class PointsService:
if program.require_staff_pin:
if not staff_pin:
raise StaffPinRequiredException()
verified_pin = pin_service.verify_pin(db, program.id, staff_pin)
verified_pin = pin_service.verify_pin(db, program.id, staff_pin, vendor_id=vendor_id)
# Redeem points
now = datetime.now(UTC)
card.points_balance -= points_required
card.points_redeemed += points_required
card.last_redemption_at = now
card.last_activity_at = now
# Create transaction
transaction = LoyaltyTransaction(
company_id=card.company_id,
card_id=card.id,
vendor_id=card.vendor_id,
vendor_id=vendor_id,
staff_pin_id=verified_pin.id if verified_pin else None,
transaction_type=TransactionType.POINTS_REDEEMED.value,
points_delta=-points_required,
@@ -254,7 +289,7 @@ class PointsService:
db.refresh(card)
logger.info(
f"Redeemed {points_required} points from card {card.id} "
f"Redeemed {points_required} points from card {card.id} at vendor {vendor_id} "
f"(reward: {reward_name}, balance: {card.points_balance})"
)
@@ -268,6 +303,140 @@ class PointsService:
"card_number": card.card_number,
"points_balance": card.points_balance,
"total_points_redeemed": card.points_redeemed,
"vendor_id": vendor_id,
}
def void_points(
self,
db: Session,
*,
vendor_id: int,
card_id: int | None = None,
qr_code: str | None = None,
card_number: str | None = None,
points_to_void: int | None = None,
original_transaction_id: int | None = None,
order_reference: str | None = None,
staff_pin: str | None = None,
ip_address: str | None = None,
user_agent: str | None = None,
notes: str | None = None,
) -> dict:
"""
Void points for a return.
Args:
db: Database session
vendor_id: Vendor ID
card_id: Card ID
qr_code: QR code data
card_number: Card number
points_to_void: Number of points to void (if not using original_transaction_id)
original_transaction_id: ID of original earn transaction to void
order_reference: Order reference (to find original transaction)
staff_pin: Staff PIN for verification
ip_address: Request IP for audit
user_agent: Request user agent for audit
notes: Reason for voiding
Returns:
Dict with operation result
"""
# Look up the card
card = card_service.lookup_card_for_vendor(
db,
vendor_id,
card_id=card_id,
qr_code=qr_code,
card_number=card_number,
)
program = card.program
# Verify staff PIN if required
verified_pin = None
if program.require_staff_pin:
if not staff_pin:
raise StaffPinRequiredException()
verified_pin = pin_service.verify_pin(db, program.id, staff_pin, vendor_id=vendor_id)
# Determine points to void
original_transaction = None
if original_transaction_id:
original_transaction = (
db.query(LoyaltyTransaction)
.filter(
LoyaltyTransaction.id == original_transaction_id,
LoyaltyTransaction.card_id == card.id,
LoyaltyTransaction.transaction_type == TransactionType.POINTS_EARNED.value,
)
.first()
)
if original_transaction:
points_to_void = original_transaction.points_delta
elif order_reference:
original_transaction = (
db.query(LoyaltyTransaction)
.filter(
LoyaltyTransaction.order_reference == order_reference,
LoyaltyTransaction.card_id == card.id,
LoyaltyTransaction.transaction_type == TransactionType.POINTS_EARNED.value,
)
.first()
)
if original_transaction:
points_to_void = original_transaction.points_delta
if not points_to_void or points_to_void <= 0:
return {
"success": False,
"message": "No points to void",
"card_id": card.id,
"card_number": card.card_number,
"points_balance": card.points_balance,
}
# Void the points (can reduce balance below what was earned)
now = datetime.now(UTC)
actual_voided = min(points_to_void, card.points_balance)
card.points_balance = max(0, card.points_balance - points_to_void)
card.last_activity_at = now
# Create void transaction
transaction = LoyaltyTransaction(
company_id=card.company_id,
card_id=card.id,
vendor_id=vendor_id,
staff_pin_id=verified_pin.id if verified_pin else None,
transaction_type=TransactionType.POINTS_VOIDED.value,
points_delta=-actual_voided,
stamps_balance_after=card.stamp_count,
points_balance_after=card.points_balance,
related_transaction_id=original_transaction.id if original_transaction else None,
order_reference=order_reference,
ip_address=ip_address,
user_agent=user_agent,
notes=notes or "Points voided for return",
transaction_at=now,
)
db.add(transaction)
db.commit()
db.refresh(card)
logger.info(
f"Voided {actual_voided} points from card {card.id} at vendor {vendor_id} "
f"(balance: {card.points_balance})"
)
return {
"success": True,
"message": "Points voided successfully",
"points_voided": actual_voided,
"card_id": card.id,
"card_number": card.card_number,
"points_balance": card.points_balance,
"vendor_id": vendor_id,
}
def adjust_points(
@@ -276,18 +445,20 @@ class PointsService:
card_id: int,
points_delta: int,
*,
vendor_id: int | None = None,
reason: str,
staff_pin: str | None = None,
ip_address: str | None = None,
user_agent: str | None = None,
) -> dict:
"""
Manually adjust points (admin operation).
Manually adjust points (admin/vendor operation).
Args:
db: Database session
card_id: Card ID
points_delta: Points to add (positive) or remove (negative)
vendor_id: Vendor ID
reason: Reason for adjustment
staff_pin: Staff PIN for verification
ip_address: Request IP for audit
@@ -299,14 +470,15 @@ class PointsService:
card = card_service.require_card(db, card_id)
program = card.program
# Verify staff PIN if required
# Verify staff PIN if required and vendor provided
verified_pin = None
if program.require_staff_pin and staff_pin:
verified_pin = pin_service.verify_pin(db, program.id, staff_pin)
if program.require_staff_pin and staff_pin and vendor_id:
verified_pin = pin_service.verify_pin(db, program.id, staff_pin, vendor_id=vendor_id)
# Apply adjustment
now = datetime.now(UTC)
card.points_balance += points_delta
card.last_activity_at = now
if points_delta > 0:
card.total_points_earned += points_delta
@@ -320,8 +492,9 @@ class PointsService:
# Create transaction
transaction = LoyaltyTransaction(
company_id=card.company_id,
card_id=card.id,
vendor_id=card.vendor_id,
vendor_id=vendor_id,
staff_pin_id=verified_pin.id if verified_pin else None,
transaction_type=TransactionType.POINTS_ADJUSTMENT.value,
points_delta=points_delta,

View File

@@ -2,6 +2,11 @@
"""
Loyalty program service.
Company-based program management:
- Programs belong to companies, not individual vendors
- All vendors under a company share the same loyalty program
- One program per company
Handles CRUD operations for loyalty programs including:
- Program creation and configuration
- Program updates
@@ -18,7 +23,11 @@ from app.modules.loyalty.exceptions import (
LoyaltyProgramAlreadyExistsException,
LoyaltyProgramNotFoundException,
)
from app.modules.loyalty.models import LoyaltyProgram, LoyaltyType
from app.modules.loyalty.models import (
LoyaltyProgram,
LoyaltyType,
CompanyLoyaltySettings,
)
from app.modules.loyalty.schemas.program import (
ProgramCreate,
ProgramUpdate,
@@ -42,25 +51,53 @@ class ProgramService:
.first()
)
def get_program_by_vendor(self, db: Session, vendor_id: int) -> LoyaltyProgram | None:
"""Get a vendor's loyalty program."""
def get_program_by_company(self, db: Session, company_id: int) -> LoyaltyProgram | None:
"""Get a company's loyalty program."""
return (
db.query(LoyaltyProgram)
.filter(LoyaltyProgram.vendor_id == vendor_id)
.filter(LoyaltyProgram.company_id == company_id)
.first()
)
def get_active_program_by_vendor(self, db: Session, vendor_id: int) -> LoyaltyProgram | None:
"""Get a vendor's active loyalty program."""
def get_active_program_by_company(self, db: Session, company_id: int) -> LoyaltyProgram | None:
"""Get a company's active loyalty program."""
return (
db.query(LoyaltyProgram)
.filter(
LoyaltyProgram.vendor_id == vendor_id,
LoyaltyProgram.company_id == company_id,
LoyaltyProgram.is_active == True,
)
.first()
)
def get_program_by_vendor(self, db: Session, vendor_id: int) -> LoyaltyProgram | None:
"""
Get the loyalty program for a vendor.
Looks up the vendor's company and returns the company's program.
"""
from app.modules.tenancy.models import Vendor
vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first()
if not vendor:
return None
return self.get_program_by_company(db, vendor.company_id)
def get_active_program_by_vendor(self, db: Session, vendor_id: int) -> LoyaltyProgram | None:
"""
Get the active loyalty program for a vendor.
Looks up the vendor's company and returns the company's active program.
"""
from app.modules.tenancy.models import Vendor
vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first()
if not vendor:
return None
return self.get_active_program_by_company(db, vendor.company_id)
def require_program(self, db: Session, program_id: int) -> LoyaltyProgram:
"""Get a program or raise exception if not found."""
program = self.get_program(db, program_id)
@@ -68,6 +105,13 @@ class ProgramService:
raise LoyaltyProgramNotFoundException(str(program_id))
return program
def require_program_by_company(self, db: Session, company_id: int) -> LoyaltyProgram:
"""Get a company's program or raise exception if not found."""
program = self.get_program_by_company(db, company_id)
if not program:
raise LoyaltyProgramNotFoundException(f"company:{company_id}")
return program
def require_program_by_vendor(self, db: Session, vendor_id: int) -> LoyaltyProgram:
"""Get a vendor's program or raise exception if not found."""
program = self.get_program_by_vendor(db, vendor_id)
@@ -82,15 +126,32 @@ class ProgramService:
skip: int = 0,
limit: int = 100,
is_active: bool | None = None,
search: str | None = None,
) -> tuple[list[LoyaltyProgram], int]:
"""List all loyalty programs (admin)."""
query = db.query(LoyaltyProgram)
"""List all loyalty programs (admin).
Args:
db: Database session
skip: Number of records to skip
limit: Maximum records to return
is_active: Filter by active status
search: Search by company name (case-insensitive)
"""
from app.modules.tenancy.models import Company
query = db.query(LoyaltyProgram).join(
Company, LoyaltyProgram.company_id == Company.id
)
if is_active is not None:
query = query.filter(LoyaltyProgram.is_active == is_active)
if search:
search_pattern = f"%{search}%"
query = query.filter(Company.name.ilike(search_pattern))
total = query.count()
programs = query.offset(skip).limit(limit).all()
programs = query.order_by(LoyaltyProgram.created_at.desc()).offset(skip).limit(limit).all()
return programs, total
@@ -101,33 +162,33 @@ class ProgramService:
def create_program(
self,
db: Session,
vendor_id: int,
company_id: int,
data: ProgramCreate,
) -> LoyaltyProgram:
"""
Create a new loyalty program for a vendor.
Create a new loyalty program for a company.
Args:
db: Database session
vendor_id: Vendor ID
company_id: Company ID
data: Program configuration
Returns:
Created program
Raises:
LoyaltyProgramAlreadyExistsException: If vendor already has a program
LoyaltyProgramAlreadyExistsException: If company already has a program
"""
# Check if vendor already has a program
existing = self.get_program_by_vendor(db, vendor_id)
# Check if company already has a program
existing = self.get_program_by_company(db, company_id)
if existing:
raise LoyaltyProgramAlreadyExistsException(vendor_id)
raise LoyaltyProgramAlreadyExistsException(company_id)
# Convert points_rewards to dict list for JSON storage
points_rewards_data = [r.model_dump() for r in data.points_rewards]
program = LoyaltyProgram(
vendor_id=vendor_id,
company_id=company_id,
loyalty_type=data.loyalty_type,
# Stamps
stamps_target=data.stamps_target,
@@ -136,6 +197,10 @@ class ProgramService:
# Points
points_per_euro=data.points_per_euro,
points_rewards=points_rewards_data,
points_expiration_days=data.points_expiration_days,
welcome_bonus_points=data.welcome_bonus_points,
minimum_redemption_points=data.minimum_redemption_points,
minimum_purchase_cents=data.minimum_purchase_cents,
# Anti-fraud
cooldown_minutes=data.cooldown_minutes,
max_daily_stamps=data.max_daily_stamps,
@@ -155,11 +220,19 @@ class ProgramService:
)
db.add(program)
db.flush()
# Create default company settings
settings = CompanyLoyaltySettings(
company_id=company_id,
)
db.add(settings)
db.commit()
db.refresh(program)
logger.info(
f"Created loyalty program {program.id} for vendor {vendor_id} "
f"Created loyalty program {program.id} for company {company_id} "
f"(type: {program.loyalty_type})"
)
@@ -224,12 +297,39 @@ class ProgramService:
def delete_program(self, db: Session, program_id: int) -> None:
"""Delete a loyalty program and all associated data."""
program = self.require_program(db, program_id)
vendor_id = program.vendor_id
company_id = program.company_id
# Also delete company settings
db.query(CompanyLoyaltySettings).filter(
CompanyLoyaltySettings.company_id == company_id
).delete()
db.delete(program)
db.commit()
logger.info(f"Deleted loyalty program {program_id} for vendor {vendor_id}")
logger.info(f"Deleted loyalty program {program_id} for company {company_id}")
# =========================================================================
# Company Settings
# =========================================================================
def get_company_settings(self, db: Session, company_id: int) -> CompanyLoyaltySettings | None:
"""Get company loyalty settings."""
return (
db.query(CompanyLoyaltySettings)
.filter(CompanyLoyaltySettings.company_id == company_id)
.first()
)
def get_or_create_company_settings(self, db: Session, company_id: int) -> CompanyLoyaltySettings:
"""Get or create company loyalty settings."""
settings = self.get_company_settings(db, company_id)
if not settings:
settings = CompanyLoyaltySettings(company_id=company_id)
db.add(settings)
db.commit()
db.refresh(settings)
return settings
# =========================================================================
# Statistics
@@ -374,6 +474,196 @@ class ProgramService:
"estimated_liability_cents": estimated_liability,
}
def get_company_stats(self, db: Session, company_id: int) -> dict:
"""
Get statistics for a company's loyalty program across all locations.
Returns dict with per-vendor breakdown.
"""
from datetime import UTC, datetime, timedelta
from sqlalchemy import func
from app.modules.loyalty.models import LoyaltyCard, LoyaltyTransaction
from app.modules.tenancy.models import Vendor
program = self.get_program_by_company(db, company_id)
# Base stats dict
stats = {
"company_id": company_id,
"program_id": program.id if program else None,
"total_cards": 0,
"active_cards": 0,
"total_points_issued": 0,
"total_points_redeemed": 0,
"points_issued_30d": 0,
"points_redeemed_30d": 0,
"transactions_30d": 0,
"program": None,
"locations": [],
}
if not program:
return stats
# Add program info
stats["program"] = {
"id": program.id,
"display_name": program.display_name,
"card_name": program.card_name,
"loyalty_type": program.loyalty_type.value if hasattr(program.loyalty_type, 'value') else str(program.loyalty_type),
"points_per_euro": program.points_per_euro,
"welcome_bonus_points": program.welcome_bonus_points,
"minimum_redemption_points": program.minimum_redemption_points,
"points_expiration_days": program.points_expiration_days,
"is_active": program.is_active,
}
thirty_days_ago = datetime.now(UTC) - timedelta(days=30)
# Total cards
stats["total_cards"] = (
db.query(func.count(LoyaltyCard.id))
.filter(LoyaltyCard.company_id == company_id)
.scalar()
or 0
)
# Active cards
stats["active_cards"] = (
db.query(func.count(LoyaltyCard.id))
.filter(
LoyaltyCard.company_id == company_id,
LoyaltyCard.is_active == True,
)
.scalar()
or 0
)
# Total points issued (all time)
stats["total_points_issued"] = (
db.query(func.sum(LoyaltyTransaction.points_delta))
.filter(
LoyaltyTransaction.company_id == company_id,
LoyaltyTransaction.points_delta > 0,
)
.scalar()
or 0
)
# Total points redeemed (all time)
stats["total_points_redeemed"] = (
db.query(func.sum(func.abs(LoyaltyTransaction.points_delta)))
.filter(
LoyaltyTransaction.company_id == company_id,
LoyaltyTransaction.points_delta < 0,
)
.scalar()
or 0
)
# Points issued (30 days)
stats["points_issued_30d"] = (
db.query(func.sum(LoyaltyTransaction.points_delta))
.filter(
LoyaltyTransaction.company_id == company_id,
LoyaltyTransaction.points_delta > 0,
LoyaltyTransaction.transaction_at >= thirty_days_ago,
)
.scalar()
or 0
)
# Points redeemed (30 days)
stats["points_redeemed_30d"] = (
db.query(func.sum(func.abs(LoyaltyTransaction.points_delta)))
.filter(
LoyaltyTransaction.company_id == company_id,
LoyaltyTransaction.points_delta < 0,
LoyaltyTransaction.transaction_at >= thirty_days_ago,
)
.scalar()
or 0
)
# Transactions (30 days)
stats["transactions_30d"] = (
db.query(func.count(LoyaltyTransaction.id))
.filter(
LoyaltyTransaction.company_id == company_id,
LoyaltyTransaction.transaction_at >= thirty_days_ago,
)
.scalar()
or 0
)
# Get all vendors for this company for location breakdown
vendors = db.query(Vendor).filter(Vendor.company_id == company_id).all()
location_stats = []
for vendor in vendors:
# Cards enrolled at this vendor
enrolled_count = (
db.query(func.count(LoyaltyCard.id))
.filter(
LoyaltyCard.company_id == company_id,
LoyaltyCard.enrolled_at_vendor_id == vendor.id,
)
.scalar()
or 0
)
# Points earned at this vendor
points_earned = (
db.query(func.sum(LoyaltyTransaction.points_delta))
.filter(
LoyaltyTransaction.company_id == company_id,
LoyaltyTransaction.vendor_id == vendor.id,
LoyaltyTransaction.points_delta > 0,
)
.scalar()
or 0
)
# Points redeemed at this vendor
points_redeemed = (
db.query(func.sum(func.abs(LoyaltyTransaction.points_delta)))
.filter(
LoyaltyTransaction.company_id == company_id,
LoyaltyTransaction.vendor_id == vendor.id,
LoyaltyTransaction.points_delta < 0,
)
.scalar()
or 0
)
# Transactions (30 days) at this vendor
transactions_30d = (
db.query(func.count(LoyaltyTransaction.id))
.filter(
LoyaltyTransaction.company_id == company_id,
LoyaltyTransaction.vendor_id == vendor.id,
LoyaltyTransaction.transaction_at >= thirty_days_ago,
)
.scalar()
or 0
)
location_stats.append({
"vendor_id": vendor.id,
"vendor_name": vendor.name,
"vendor_code": vendor.vendor_code,
"enrolled_count": enrolled_count,
"points_earned": points_earned,
"points_redeemed": points_redeemed,
"transactions_30d": transactions_30d,
})
stats["locations"] = location_stats
return stats
# Singleton instance
program_service = ProgramService()

View File

@@ -2,9 +2,15 @@
"""
Stamp service.
Company-based stamp operations:
- Stamps earned at any vendor count toward company total
- Stamps can be redeemed at any vendor within the company
- Supports voiding stamps for returns
Handles stamp operations including:
- Adding stamps with anti-fraud checks
- Redeeming stamps for rewards
- Voiding stamps (for returns)
- Daily limit tracking
"""
@@ -36,6 +42,7 @@ class StampService:
self,
db: Session,
*,
vendor_id: int,
card_id: int | None = None,
qr_code: str | None = None,
card_number: str | None = None,
@@ -54,6 +61,7 @@ class StampService:
Args:
db: Database session
vendor_id: Vendor ID (where stamp is being added)
card_id: Card ID
qr_code: QR code data
card_number: Card number
@@ -74,9 +82,10 @@ class StampService:
StampCooldownException: Cooldown period not elapsed
DailyStampLimitException: Daily limit reached
"""
# Look up the card
card = card_service.lookup_card(
# Look up the card (validates it belongs to vendor's company)
card = card_service.lookup_card_for_vendor(
db,
vendor_id,
card_id=card_id,
qr_code=qr_code,
card_number=card_number,
@@ -100,7 +109,7 @@ class StampService:
if program.require_staff_pin:
if not staff_pin:
raise StaffPinRequiredException()
verified_pin = pin_service.verify_pin(db, program.id, staff_pin)
verified_pin = pin_service.verify_pin(db, program.id, staff_pin, vendor_id=vendor_id)
# Check cooldown
now = datetime.now(UTC)
@@ -121,14 +130,16 @@ class StampService:
card.stamp_count += 1
card.total_stamps_earned += 1
card.last_stamp_at = now
card.last_activity_at = now
# Check if reward earned
reward_earned = card.stamp_count >= program.stamps_target
# Create transaction
transaction = LoyaltyTransaction(
company_id=card.company_id,
card_id=card.id,
vendor_id=card.vendor_id,
vendor_id=vendor_id,
staff_pin_id=verified_pin.id if verified_pin else None,
transaction_type=TransactionType.STAMP_EARNED.value,
stamps_delta=1,
@@ -147,7 +158,7 @@ class StampService:
stamps_today += 1
logger.info(
f"Added stamp to card {card.id} "
f"Added stamp to card {card.id} at vendor {vendor_id} "
f"(stamps: {card.stamp_count}/{program.stamps_target}, "
f"today: {stamps_today}/{program.max_daily_stamps})"
)
@@ -168,12 +179,14 @@ class StampService:
"next_stamp_available_at": next_stamp_at,
"stamps_today": stamps_today,
"stamps_remaining_today": max(0, program.max_daily_stamps - stamps_today),
"vendor_id": vendor_id,
}
def redeem_stamps(
self,
db: Session,
*,
vendor_id: int,
card_id: int | None = None,
qr_code: str | None = None,
card_number: str | None = None,
@@ -187,6 +200,7 @@ class StampService:
Args:
db: Database session
vendor_id: Vendor ID (where redemption is happening)
card_id: Card ID
qr_code: QR code data
card_number: Card number
@@ -203,9 +217,10 @@ class StampService:
InsufficientStampsException: Not enough stamps
StaffPinRequiredException: PIN required but not provided
"""
# Look up the card
card = card_service.lookup_card(
# Look up the card (validates it belongs to vendor's company)
card = card_service.lookup_card_for_vendor(
db,
vendor_id,
card_id=card_id,
qr_code=qr_code,
card_number=card_number,
@@ -228,7 +243,7 @@ class StampService:
if program.require_staff_pin:
if not staff_pin:
raise StaffPinRequiredException()
verified_pin = pin_service.verify_pin(db, program.id, staff_pin)
verified_pin = pin_service.verify_pin(db, program.id, staff_pin, vendor_id=vendor_id)
# Redeem stamps
now = datetime.now(UTC)
@@ -236,11 +251,13 @@ class StampService:
card.stamp_count -= stamps_redeemed
card.stamps_redeemed += 1
card.last_redemption_at = now
card.last_activity_at = now
# Create transaction
transaction = LoyaltyTransaction(
company_id=card.company_id,
card_id=card.id,
vendor_id=card.vendor_id,
vendor_id=vendor_id,
staff_pin_id=verified_pin.id if verified_pin else None,
transaction_type=TransactionType.STAMP_REDEEMED.value,
stamps_delta=-stamps_redeemed,
@@ -258,7 +275,7 @@ class StampService:
db.refresh(card)
logger.info(
f"Redeemed stamps from card {card.id} "
f"Redeemed stamps from card {card.id} at vendor {vendor_id} "
f"(reward: {program.stamps_reward_description}, "
f"total redemptions: {card.stamps_redeemed})"
)
@@ -272,6 +289,125 @@ class StampService:
"stamps_target": program.stamps_target,
"reward_description": program.stamps_reward_description,
"total_redemptions": card.stamps_redeemed,
"vendor_id": vendor_id,
}
def void_stamps(
self,
db: Session,
*,
vendor_id: int,
card_id: int | None = None,
qr_code: str | None = None,
card_number: str | None = None,
stamps_to_void: int | None = None,
original_transaction_id: int | None = None,
staff_pin: str | None = None,
ip_address: str | None = None,
user_agent: str | None = None,
notes: str | None = None,
) -> dict:
"""
Void stamps for a return.
Args:
db: Database session
vendor_id: Vendor ID
card_id: Card ID
qr_code: QR code data
card_number: Card number
stamps_to_void: Number of stamps to void (if not using original_transaction_id)
original_transaction_id: ID of original stamp transaction to void
staff_pin: Staff PIN for verification
ip_address: Request IP for audit
user_agent: Request user agent for audit
notes: Reason for voiding
Returns:
Dict with operation result
"""
# Look up the card
card = card_service.lookup_card_for_vendor(
db,
vendor_id,
card_id=card_id,
qr_code=qr_code,
card_number=card_number,
)
program = card.program
# Verify staff PIN if required
verified_pin = None
if program.require_staff_pin:
if not staff_pin:
raise StaffPinRequiredException()
verified_pin = pin_service.verify_pin(db, program.id, staff_pin, vendor_id=vendor_id)
# Determine stamps to void
original_transaction = None
if original_transaction_id:
original_transaction = (
db.query(LoyaltyTransaction)
.filter(
LoyaltyTransaction.id == original_transaction_id,
LoyaltyTransaction.card_id == card.id,
LoyaltyTransaction.transaction_type == TransactionType.STAMP_EARNED.value,
)
.first()
)
if original_transaction:
stamps_to_void = original_transaction.stamps_delta
if not stamps_to_void or stamps_to_void <= 0:
return {
"success": False,
"message": "No stamps to void",
"card_id": card.id,
"card_number": card.card_number,
"stamp_count": card.stamp_count,
}
# Void the stamps (can reduce balance below what was earned)
now = datetime.now(UTC)
actual_voided = min(stamps_to_void, card.stamp_count)
card.stamp_count = max(0, card.stamp_count - stamps_to_void)
card.last_activity_at = now
# Create void transaction
transaction = LoyaltyTransaction(
company_id=card.company_id,
card_id=card.id,
vendor_id=vendor_id,
staff_pin_id=verified_pin.id if verified_pin else None,
transaction_type=TransactionType.STAMP_VOIDED.value,
stamps_delta=-actual_voided,
stamps_balance_after=card.stamp_count,
points_balance_after=card.points_balance,
related_transaction_id=original_transaction.id if original_transaction else None,
ip_address=ip_address,
user_agent=user_agent,
notes=notes or "Stamps voided for return",
transaction_at=now,
)
db.add(transaction)
db.commit()
db.refresh(card)
logger.info(
f"Voided {actual_voided} stamps from card {card.id} at vendor {vendor_id} "
f"(balance: {card.stamp_count})"
)
return {
"success": True,
"message": "Stamps voided successfully",
"stamps_voided": actual_voided,
"card_id": card.id,
"card_number": card.card_number,
"stamp_count": card.stamp_count,
"vendor_id": vendor_id,
}

View File

@@ -0,0 +1,115 @@
// app/modules/loyalty/static/admin/js/loyalty-analytics.js
// noqa: js-006 - async init pattern is safe, loadData has try/catch
// Use centralized logger
const loyaltyAnalyticsLog = window.LogConfig.loggers.loyaltyAnalytics || window.LogConfig.createLogger('loyaltyAnalytics');
// ============================================
// LOYALTY ANALYTICS FUNCTION
// ============================================
function adminLoyaltyAnalytics() {
return {
// Inherit base layout functionality
...data(),
// Page identifier for sidebar active state
currentPage: 'loyalty-analytics',
// Stats
stats: {
total_programs: 0,
active_programs: 0,
total_cards: 0,
active_cards: 0,
transactions_30d: 0,
points_issued_30d: 0,
points_redeemed_30d: 0,
companies_with_programs: 0
},
// State
loading: false,
error: null,
// Computed: Redemption rate percentage
get redemptionRate() {
if (this.stats.points_issued_30d === 0) return 0;
return Math.round((this.stats.points_redeemed_30d / this.stats.points_issued_30d) * 100);
},
// Computed: Issued percentage for progress bar
get issuedPercentage() {
const total = this.stats.points_issued_30d + this.stats.points_redeemed_30d;
if (total === 0) return 50;
return Math.round((this.stats.points_issued_30d / total) * 100);
},
// Computed: Redeemed percentage for progress bar
get redeemedPercentage() {
return 100 - this.issuedPercentage;
},
// Initialize
async init() {
loyaltyAnalyticsLog.info('=== LOYALTY ANALYTICS PAGE INITIALIZING ===');
// Prevent multiple initializations
if (window._loyaltyAnalyticsInitialized) {
loyaltyAnalyticsLog.warn('Loyalty analytics page already initialized, skipping...');
return;
}
window._loyaltyAnalyticsInitialized = true;
loyaltyAnalyticsLog.group('Loading analytics data');
await this.loadStats();
loyaltyAnalyticsLog.groupEnd();
loyaltyAnalyticsLog.info('=== LOYALTY ANALYTICS PAGE INITIALIZATION COMPLETE ===');
},
// Load platform stats
async loadStats() {
this.loading = true;
this.error = null;
try {
loyaltyAnalyticsLog.info('Fetching loyalty analytics...');
const response = await apiClient.get('/admin/loyalty/stats');
if (response) {
this.stats = {
total_programs: response.total_programs || 0,
active_programs: response.active_programs || 0,
total_cards: response.total_cards || 0,
active_cards: response.active_cards || 0,
transactions_30d: response.transactions_30d || 0,
points_issued_30d: response.points_issued_30d || 0,
points_redeemed_30d: response.points_redeemed_30d || 0,
companies_with_programs: response.companies_with_programs || 0
};
loyaltyAnalyticsLog.info('Analytics loaded:', this.stats);
}
} catch (error) {
loyaltyAnalyticsLog.error('Failed to load analytics:', error);
this.error = error.message || 'Failed to load analytics';
} finally {
this.loading = false;
}
},
// Format number with thousands separator
formatNumber(num) {
if (num === null || num === undefined) return '0';
return new Intl.NumberFormat('en-US').format(num);
}
};
}
// Register logger for configuration
if (!window.LogConfig.loggers.loyaltyAnalytics) {
window.LogConfig.loggers.loyaltyAnalytics = window.LogConfig.createLogger('loyaltyAnalytics');
}
loyaltyAnalyticsLog.info('Loyalty analytics module loaded');

View File

@@ -0,0 +1,208 @@
// app/modules/loyalty/static/admin/js/loyalty-company-detail.js
// noqa: js-006 - async init pattern is safe, loadData has try/catch
// Use centralized logger
const loyaltyCompanyDetailLog = window.LogConfig.loggers.loyaltyCompanyDetail || window.LogConfig.createLogger('loyaltyCompanyDetail');
// ============================================
// LOYALTY COMPANY DETAIL FUNCTION
// ============================================
function adminLoyaltyCompanyDetail() {
return {
// Inherit base layout functionality
...data(),
// Page identifier for sidebar active state
currentPage: 'loyalty-programs',
// Company ID from URL
companyId: null,
// Company data
company: null,
program: null,
stats: {
total_cards: 0,
active_cards: 0,
total_points_issued: 0,
total_points_redeemed: 0,
points_issued_30d: 0,
points_redeemed_30d: 0,
transactions_30d: 0
},
settings: null,
locations: [],
// State
loading: false,
error: null,
// Initialize
async init() {
loyaltyCompanyDetailLog.info('=== LOYALTY COMPANY DETAIL PAGE INITIALIZING ===');
// Prevent multiple initializations
if (window._loyaltyCompanyDetailInitialized) {
loyaltyCompanyDetailLog.warn('Loyalty company detail page already initialized, skipping...');
return;
}
window._loyaltyCompanyDetailInitialized = true;
// Extract company ID from URL
const pathParts = window.location.pathname.split('/');
const companiesIndex = pathParts.indexOf('companies');
if (companiesIndex !== -1 && pathParts[companiesIndex + 1]) {
this.companyId = parseInt(pathParts[companiesIndex + 1]);
}
if (!this.companyId) {
this.error = 'Invalid company ID';
loyaltyCompanyDetailLog.error('Could not extract company ID from URL');
return;
}
loyaltyCompanyDetailLog.info('Company ID:', this.companyId);
loyaltyCompanyDetailLog.group('Loading company loyalty data');
await this.loadCompanyData();
loyaltyCompanyDetailLog.groupEnd();
loyaltyCompanyDetailLog.info('=== LOYALTY COMPANY DETAIL PAGE INITIALIZATION COMPLETE ===');
},
// Load all company data
async loadCompanyData() {
this.loading = true;
this.error = null;
try {
// Load company info
await this.loadCompany();
// Load loyalty-specific data in parallel
await Promise.all([
this.loadStats(),
this.loadSettings(),
this.loadLocations()
]);
} catch (error) {
loyaltyCompanyDetailLog.error('Failed to load company data:', error);
this.error = error.message || 'Failed to load company loyalty data';
} finally {
this.loading = false;
}
},
// Load company basic info
async loadCompany() {
try {
loyaltyCompanyDetailLog.info('Fetching company info...');
// Get company from tenancy API
const response = await apiClient.get(`/admin/companies/${this.companyId}`);
if (response) {
this.company = response;
loyaltyCompanyDetailLog.info('Company loaded:', this.company.name);
}
} catch (error) {
loyaltyCompanyDetailLog.error('Failed to load company:', error);
throw error;
}
},
// Load company loyalty stats
async loadStats() {
try {
loyaltyCompanyDetailLog.info('Fetching company loyalty stats...');
const response = await apiClient.get(`/admin/loyalty/companies/${this.companyId}/stats`);
if (response) {
this.stats = {
total_cards: response.total_cards || 0,
active_cards: response.active_cards || 0,
total_points_issued: response.total_points_issued || 0,
total_points_redeemed: response.total_points_redeemed || 0,
points_issued_30d: response.points_issued_30d || 0,
points_redeemed_30d: response.points_redeemed_30d || 0,
transactions_30d: response.transactions_30d || 0
};
// Also get program info from stats response
if (response.program) {
this.program = response.program;
}
// Get location breakdown
if (response.locations) {
this.locations = response.locations;
}
loyaltyCompanyDetailLog.info('Stats loaded:', this.stats);
}
} catch (error) {
loyaltyCompanyDetailLog.warn('Failed to load stats (company may not have loyalty program):', error.message);
// Don't throw - stats might fail if no program exists
}
},
// Load company loyalty settings
async loadSettings() {
try {
loyaltyCompanyDetailLog.info('Fetching company loyalty settings...');
const response = await apiClient.get(`/admin/loyalty/companies/${this.companyId}/settings`);
if (response) {
this.settings = response;
loyaltyCompanyDetailLog.info('Settings loaded:', this.settings);
}
} catch (error) {
loyaltyCompanyDetailLog.warn('Failed to load settings:', error.message);
// Don't throw - settings might not exist yet
}
},
// Load location breakdown
async loadLocations() {
try {
loyaltyCompanyDetailLog.info('Fetching location breakdown...');
// This data comes with stats, but could be a separate endpoint
// For now, stats endpoint should return locations array
} catch (error) {
loyaltyCompanyDetailLog.warn('Failed to load locations:', error.message);
}
},
// Format date for display
formatDate(dateString) {
if (!dateString) return 'N/A';
try {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
} catch (e) {
loyaltyCompanyDetailLog.error('Date parsing error:', e);
return dateString;
}
},
// Format number with thousands separator
formatNumber(num) {
if (num === null || num === undefined) return '0';
return new Intl.NumberFormat('en-US').format(num);
}
};
}
// Register logger for configuration
if (!window.LogConfig.loggers.loyaltyCompanyDetail) {
window.LogConfig.loggers.loyaltyCompanyDetail = window.LogConfig.createLogger('loyaltyCompanyDetail');
}
loyaltyCompanyDetailLog.info('Loyalty company detail module loaded');

View File

@@ -0,0 +1,173 @@
// app/modules/loyalty/static/admin/js/loyalty-company-settings.js
// noqa: js-006 - async init pattern is safe, loadData has try/catch
// Use centralized logger
const loyaltyCompanySettingsLog = window.LogConfig.loggers.loyaltyCompanySettings || window.LogConfig.createLogger('loyaltyCompanySettings');
// ============================================
// LOYALTY COMPANY SETTINGS FUNCTION
// ============================================
function adminLoyaltyCompanySettings() {
return {
// Inherit base layout functionality
...data(),
// Page identifier for sidebar active state
currentPage: 'loyalty-programs',
// Company ID from URL
companyId: null,
// Company data
company: null,
// Settings form data
settings: {
staff_pin_policy: 'optional',
staff_pin_lockout_attempts: 5,
staff_pin_lockout_minutes: 30,
allow_self_enrollment: true,
allow_void_transactions: true,
allow_cross_location_redemption: true
},
// State
loading: false,
saving: false,
error: null,
// Back URL
get backUrl() {
return `/admin/loyalty/companies/${this.companyId}`;
},
// Initialize
async init() {
loyaltyCompanySettingsLog.info('=== LOYALTY COMPANY SETTINGS PAGE INITIALIZING ===');
// Prevent multiple initializations
if (window._loyaltyCompanySettingsInitialized) {
loyaltyCompanySettingsLog.warn('Loyalty company settings page already initialized, skipping...');
return;
}
window._loyaltyCompanySettingsInitialized = true;
// Extract company ID from URL
const pathParts = window.location.pathname.split('/');
const companiesIndex = pathParts.indexOf('companies');
if (companiesIndex !== -1 && pathParts[companiesIndex + 1]) {
this.companyId = parseInt(pathParts[companiesIndex + 1]);
}
if (!this.companyId) {
this.error = 'Invalid company ID';
loyaltyCompanySettingsLog.error('Could not extract company ID from URL');
return;
}
loyaltyCompanySettingsLog.info('Company ID:', this.companyId);
loyaltyCompanySettingsLog.group('Loading company settings data');
await this.loadData();
loyaltyCompanySettingsLog.groupEnd();
loyaltyCompanySettingsLog.info('=== LOYALTY COMPANY SETTINGS PAGE INITIALIZATION COMPLETE ===');
},
// Load all data
async loadData() {
this.loading = true;
this.error = null;
try {
// Load company info and settings in parallel
await Promise.all([
this.loadCompany(),
this.loadSettings()
]);
} catch (error) {
loyaltyCompanySettingsLog.error('Failed to load data:', error);
this.error = error.message || 'Failed to load settings';
} finally {
this.loading = false;
}
},
// Load company basic info
async loadCompany() {
try {
loyaltyCompanySettingsLog.info('Fetching company info...');
const response = await apiClient.get(`/admin/companies/${this.companyId}`);
if (response) {
this.company = response;
loyaltyCompanySettingsLog.info('Company loaded:', this.company.name);
}
} catch (error) {
loyaltyCompanySettingsLog.error('Failed to load company:', error);
throw error;
}
},
// Load settings
async loadSettings() {
try {
loyaltyCompanySettingsLog.info('Fetching company loyalty settings...');
const response = await apiClient.get(`/admin/loyalty/companies/${this.companyId}/settings`);
if (response) {
// Merge with defaults to ensure all fields exist
this.settings = {
staff_pin_policy: response.staff_pin_policy || 'optional',
staff_pin_lockout_attempts: response.staff_pin_lockout_attempts || 5,
staff_pin_lockout_minutes: response.staff_pin_lockout_minutes || 30,
allow_self_enrollment: response.allow_self_enrollment !== false,
allow_void_transactions: response.allow_void_transactions !== false,
allow_cross_location_redemption: response.allow_cross_location_redemption !== false
};
loyaltyCompanySettingsLog.info('Settings loaded:', this.settings);
}
} catch (error) {
loyaltyCompanySettingsLog.warn('Failed to load settings, using defaults:', error.message);
// Keep default settings
}
},
// Save settings
async saveSettings() {
this.saving = true;
try {
loyaltyCompanySettingsLog.info('Saving company loyalty settings...');
const response = await apiClient.patch(
`/admin/loyalty/companies/${this.companyId}/settings`,
this.settings
);
if (response) {
loyaltyCompanySettingsLog.info('Settings saved successfully');
Utils.showToast('Settings saved successfully', 'success');
// Navigate back to company detail
window.location.href = this.backUrl;
}
} catch (error) {
loyaltyCompanySettingsLog.error('Failed to save settings:', error);
Utils.showToast(`Failed to save settings: ${error.message}`, 'error');
} finally {
this.saving = false;
}
}
};
}
// Register logger for configuration
if (!window.LogConfig.loggers.loyaltyCompanySettings) {
window.LogConfig.loggers.loyaltyCompanySettings = window.LogConfig.createLogger('loyaltyCompanySettings');
}
loyaltyCompanySettingsLog.info('Loyalty company settings module loaded');

View File

@@ -0,0 +1,264 @@
// app/modules/loyalty/static/admin/js/loyalty-programs.js
// noqa: js-006 - async init pattern is safe, loadData has try/catch
// Use centralized logger
const loyaltyProgramsLog = window.LogConfig.loggers.loyaltyPrograms || window.LogConfig.createLogger('loyaltyPrograms');
// ============================================
// LOYALTY PROGRAMS LIST FUNCTION
// ============================================
function adminLoyaltyPrograms() {
return {
// Inherit base layout functionality
...data(),
// Page identifier for sidebar active state
currentPage: 'loyalty-programs',
// Programs page specific state
programs: [],
stats: {
total_programs: 0,
active_programs: 0,
total_cards: 0,
transactions_30d: 0,
points_issued_30d: 0,
points_redeemed_30d: 0,
companies_with_programs: 0
},
loading: false,
error: null,
// Search and filters
filters: {
search: '',
is_active: ''
},
// Pagination state
pagination: {
page: 1,
per_page: 20,
total: 0,
pages: 0
},
// Initialize
async init() {
loyaltyProgramsLog.info('=== LOYALTY PROGRAMS PAGE INITIALIZING ===');
// Prevent multiple initializations
if (window._loyaltyProgramsInitialized) {
loyaltyProgramsLog.warn('Loyalty programs page already initialized, skipping...');
return;
}
window._loyaltyProgramsInitialized = true;
// Load platform settings for rows per page
if (window.PlatformSettings) {
this.pagination.per_page = await window.PlatformSettings.getRowsPerPage();
}
loyaltyProgramsLog.group('Loading loyalty programs data');
await Promise.all([
this.loadPrograms(),
this.loadStats()
]);
loyaltyProgramsLog.groupEnd();
loyaltyProgramsLog.info('=== LOYALTY PROGRAMS PAGE INITIALIZATION COMPLETE ===');
},
// Debounced search
debouncedSearch() {
if (this._searchTimeout) {
clearTimeout(this._searchTimeout);
}
this._searchTimeout = setTimeout(() => {
loyaltyProgramsLog.info('Search triggered:', this.filters.search);
this.pagination.page = 1;
this.loadPrograms();
}, 300);
},
// Computed: Get programs for current page (already paginated from server)
get paginatedPrograms() {
return this.programs;
},
// Computed: Total number of pages
get totalPages() {
return this.pagination.pages;
},
// Computed: Start index for pagination display
get startIndex() {
if (this.pagination.total === 0) return 0;
return (this.pagination.page - 1) * this.pagination.per_page + 1;
},
// Computed: End index for pagination display
get endIndex() {
const end = this.pagination.page * this.pagination.per_page;
return end > this.pagination.total ? this.pagination.total : end;
},
// Computed: Generate page numbers array with ellipsis
get pageNumbers() {
const pages = [];
const totalPages = this.totalPages;
const current = this.pagination.page;
if (totalPages <= 7) {
// Show all pages if 7 or fewer
for (let i = 1; i <= totalPages; i++) {
pages.push(i);
}
} else {
// Always show first page
pages.push(1);
if (current > 3) {
pages.push('...');
}
// Show pages around current page
const start = Math.max(2, current - 1);
const end = Math.min(totalPages - 1, current + 1);
for (let i = start; i <= end; i++) {
pages.push(i);
}
if (current < totalPages - 2) {
pages.push('...');
}
// Always show last page
pages.push(totalPages);
}
return pages;
},
// Load programs with search and pagination
async loadPrograms() {
this.loading = true;
this.error = null;
try {
loyaltyProgramsLog.info('Fetching loyalty programs from API...');
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.is_active !== '') {
params.append('is_active', this.filters.is_active);
}
const response = await apiClient.get(`/admin/loyalty/programs?${params}`);
if (response.programs) {
this.programs = response.programs;
this.pagination.total = response.total;
this.pagination.pages = Math.ceil(response.total / this.pagination.per_page);
loyaltyProgramsLog.info(`Loaded ${this.programs.length} programs (total: ${response.total})`);
} else {
loyaltyProgramsLog.warn('No programs in response');
this.programs = [];
}
} catch (error) {
loyaltyProgramsLog.error('Failed to load programs:', error);
this.error = error.message || 'Failed to load loyalty programs';
this.programs = [];
} finally {
this.loading = false;
}
},
// Load platform stats
async loadStats() {
try {
loyaltyProgramsLog.info('Fetching loyalty stats from API...');
const response = await apiClient.get('/admin/loyalty/stats');
if (response) {
this.stats = {
total_programs: response.total_programs || 0,
active_programs: response.active_programs || 0,
total_cards: response.total_cards || 0,
transactions_30d: response.transactions_30d || 0,
points_issued_30d: response.points_issued_30d || 0,
points_redeemed_30d: response.points_redeemed_30d || 0,
companies_with_programs: response.companies_with_programs || 0
};
loyaltyProgramsLog.info('Stats loaded:', this.stats);
}
} catch (error) {
loyaltyProgramsLog.error('Failed to load stats:', error);
// Don't set error state for stats failure
}
},
// Pagination methods
previousPage() {
if (this.pagination.page > 1) {
this.pagination.page--;
loyaltyProgramsLog.info('Previous page:', this.pagination.page);
this.loadPrograms();
}
},
nextPage() {
if (this.pagination.page < this.totalPages) {
this.pagination.page++;
loyaltyProgramsLog.info('Next page:', this.pagination.page);
this.loadPrograms();
}
},
goToPage(pageNum) {
if (pageNum !== '...' && pageNum >= 1 && pageNum <= this.totalPages) {
this.pagination.page = pageNum;
loyaltyProgramsLog.info('Go to page:', this.pagination.page);
this.loadPrograms();
}
},
// Format date for display
formatDate(dateString) {
if (!dateString) return 'N/A';
try {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
} catch (e) {
loyaltyProgramsLog.error('Date parsing error:', e);
return dateString;
}
},
// Format number with thousands separator
formatNumber(num) {
if (num === null || num === undefined) return '0';
return new Intl.NumberFormat('en-US').format(num);
}
};
}
// Register logger for configuration
if (!window.LogConfig.loggers.loyaltyPrograms) {
window.LogConfig.loggers.loyaltyPrograms = window.LogConfig.createLogger('loyaltyPrograms');
}
loyaltyProgramsLog.info('Loyalty programs module loaded');

View File

@@ -0,0 +1,87 @@
// app/modules/loyalty/static/storefront/js/loyalty-dashboard.js
// Customer loyalty dashboard
function customerLoyaltyDashboard() {
return {
...data(),
// Data
card: null,
program: null,
rewards: [],
transactions: [],
locations: [],
// UI state
loading: false,
showBarcode: false,
async init() {
console.log('Customer loyalty dashboard initializing...');
await this.loadData();
},
async loadData() {
this.loading = true;
try {
await Promise.all([
this.loadCard(),
this.loadTransactions()
]);
} catch (error) {
console.error('Failed to load loyalty data:', error);
} finally {
this.loading = false;
}
},
async loadCard() {
try {
const response = await apiClient.get('/storefront/loyalty/card');
if (response) {
this.card = response.card;
this.program = response.program;
this.rewards = response.program?.points_rewards || [];
this.locations = response.locations || [];
console.log('Loyalty card loaded:', this.card?.card_number);
}
} catch (error) {
if (error.status === 404) {
console.log('No loyalty card found');
this.card = null;
} else {
throw error;
}
}
},
async loadTransactions() {
try {
const response = await apiClient.get('/storefront/loyalty/transactions?limit=10');
if (response && response.transactions) {
this.transactions = response.transactions;
}
} catch (error) {
console.warn('Failed to load transactions:', error.message);
}
},
formatNumber(num) {
if (num == null) return '0';
return new Intl.NumberFormat('en-US').format(num);
},
formatDate(dateString) {
if (!dateString) return '-';
try {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
} catch (e) {
return dateString;
}
}
};
}

View File

@@ -0,0 +1,94 @@
// app/modules/loyalty/static/storefront/js/loyalty-enroll.js
// Self-service loyalty enrollment
function customerLoyaltyEnroll() {
return {
...data(),
// Program info
program: null,
// Form data
form: {
email: '',
first_name: '',
last_name: '',
phone: '',
birthday: '',
terms_accepted: false,
marketing_consent: false
},
// State
loading: false,
enrolling: false,
enrolled: false,
enrolledCard: null,
error: null,
async init() {
console.log('Customer loyalty enroll initializing...');
await this.loadProgram();
},
async loadProgram() {
this.loading = true;
try {
const response = await apiClient.get('/storefront/loyalty/program');
if (response) {
this.program = response;
console.log('Program loaded:', this.program.display_name);
}
} catch (error) {
if (error.status === 404) {
console.log('No loyalty program available');
this.program = null;
} else {
console.error('Failed to load program:', error);
this.error = 'Failed to load program information';
}
} finally {
this.loading = false;
}
},
async submitEnrollment() {
if (!this.form.email || !this.form.first_name || !this.form.terms_accepted) {
return;
}
this.enrolling = true;
this.error = null;
try {
const response = await apiClient.post('/storefront/loyalty/enroll', {
customer_email: this.form.email,
customer_name: [this.form.first_name, this.form.last_name].filter(Boolean).join(' '),
customer_phone: this.form.phone || null,
customer_birthday: this.form.birthday || null,
marketing_email_consent: this.form.marketing_consent,
marketing_sms_consent: this.form.marketing_consent
});
if (response) {
console.log('Enrollment successful:', response.card_number);
// Redirect to success page - extract base path from current URL
// Current page is at /storefront/loyalty/join, redirect to /storefront/loyalty/join/success
const currentPath = window.location.pathname;
const successUrl = currentPath.replace(/\/join\/?$/, '/join/success') +
'?card=' + encodeURIComponent(response.card_number);
window.location.href = successUrl;
}
} catch (error) {
console.error('Enrollment failed:', error);
if (error.message?.includes('already')) {
this.error = 'This email is already registered in our loyalty program.';
} else {
this.error = error.message || 'Enrollment failed. Please try again.';
}
} finally {
this.enrolling = false;
}
}
};
}

View File

@@ -0,0 +1,119 @@
// app/modules/loyalty/static/storefront/js/loyalty-history.js
// Customer loyalty transaction history
function customerLoyaltyHistory() {
return {
...data(),
// Data
card: null,
transactions: [],
// Pagination
pagination: {
page: 1,
per_page: 20,
total: 0,
pages: 0
},
// State
loading: false,
async init() {
console.log('Customer loyalty history initializing...');
await this.loadData();
},
async loadData() {
this.loading = true;
try {
await Promise.all([
this.loadCard(),
this.loadTransactions()
]);
} catch (error) {
console.error('Failed to load history:', error);
} finally {
this.loading = false;
}
},
async loadCard() {
try {
const response = await apiClient.get('/storefront/loyalty/card');
if (response) {
this.card = response.card;
}
} catch (error) {
console.warn('Failed to load card:', error.message);
}
},
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);
const response = await apiClient.get(`/storefront/loyalty/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);
console.log(`Loaded ${this.transactions.length} transactions`);
}
} catch (error) {
console.error('Failed to load transactions:', error);
}
},
previousPage() {
if (this.pagination.page > 1) {
this.pagination.page--;
this.loadTransactions();
}
},
nextPage() {
if (this.pagination.page < this.pagination.pages) {
this.pagination.page++;
this.loadTransactions();
}
},
getTransactionLabel(tx) {
const type = tx.transaction_type || '';
const labels = {
'points_earned': 'Points Earned',
'points_redeemed': 'Reward Redeemed',
'points_voided': 'Points Voided',
'welcome_bonus': 'Welcome Bonus',
'points_expired': 'Points Expired',
'stamp_earned': 'Stamp Earned',
'stamp_redeemed': 'Stamp Redeemed'
};
return labels[type] || type.replace(/_/g, ' ');
},
formatNumber(num) {
if (num == null) return '0';
return new Intl.NumberFormat('en-US').format(num);
},
formatDateTime(dateString) {
if (!dateString) return '-';
try {
return new Date(dateString).toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
} catch (e) {
return dateString;
}
}
};
}

View File

@@ -0,0 +1,104 @@
// app/modules/loyalty/static/vendor/js/loyalty-card-detail.js
// noqa: js-006 - async init pattern is safe, loadData has try/catch
const loyaltyCardDetailLog = window.LogConfig.loggers.loyaltyCardDetail || window.LogConfig.createLogger('loyaltyCardDetail');
function vendorLoyaltyCardDetail() {
return {
...data(),
currentPage: 'loyalty-card-detail',
cardId: null,
card: null,
transactions: [],
loading: false,
error: null,
async init() {
loyaltyCardDetailLog.info('=== LOYALTY CARD DETAIL PAGE INITIALIZING ===');
if (window._loyaltyCardDetailInitialized) return;
window._loyaltyCardDetailInitialized = true;
// Extract card ID from URL
const pathParts = window.location.pathname.split('/');
const cardsIndex = pathParts.indexOf('cards');
if (cardsIndex !== -1 && pathParts[cardsIndex + 1]) {
this.cardId = parseInt(pathParts[cardsIndex + 1]);
}
if (!this.cardId) {
this.error = 'Invalid card ID';
return;
}
await this.loadData();
loyaltyCardDetailLog.info('=== LOYALTY CARD DETAIL PAGE INITIALIZATION COMPLETE ===');
},
async loadData() {
this.loading = true;
this.error = null;
try {
await Promise.all([
this.loadCard(),
this.loadTransactions()
]);
} catch (error) {
loyaltyCardDetailLog.error('Failed to load data:', error);
this.error = error.message;
} finally {
this.loading = false;
}
},
async loadCard() {
const response = await apiClient.get(`/vendor/loyalty/cards/${this.cardId}`);
if (response) {
this.card = response;
loyaltyCardDetailLog.info('Card loaded:', this.card.card_number);
}
},
async loadTransactions() {
try {
const response = await apiClient.get(`/vendor/loyalty/cards/${this.cardId}/transactions?limit=50`);
if (response && response.transactions) {
this.transactions = response.transactions;
loyaltyCardDetailLog.info(`Loaded ${this.transactions.length} transactions`);
}
} catch (error) {
loyaltyCardDetailLog.warn('Failed to load transactions:', error.message);
}
},
formatNumber(num) {
return num == null ? '0' : new Intl.NumberFormat('en-US').format(num);
},
formatDate(dateString) {
if (!dateString) return '-';
try {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric', month: 'short', day: 'numeric'
});
} catch (e) { return dateString; }
},
formatDateTime(dateString) {
if (!dateString) return '-';
try {
return new Date(dateString).toLocaleString('en-US', {
year: 'numeric', month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit'
});
} catch (e) { return dateString; }
}
};
}
if (!window.LogConfig.loggers.loyaltyCardDetail) {
window.LogConfig.loggers.loyaltyCardDetail = window.LogConfig.createLogger('loyaltyCardDetail');
}
loyaltyCardDetailLog.info('Loyalty card detail module loaded');

View File

@@ -0,0 +1,160 @@
// app/modules/loyalty/static/vendor/js/loyalty-cards.js
// noqa: js-006 - async init pattern is safe, loadData has try/catch
const loyaltyCardsLog = window.LogConfig.loggers.loyaltyCards || window.LogConfig.createLogger('loyaltyCards');
function vendorLoyaltyCards() {
return {
...data(),
currentPage: 'loyalty-cards',
// Data
cards: [],
program: null,
stats: {
total_cards: 0,
active_cards: 0,
new_this_month: 0,
total_points_balance: 0
},
// Filters
filters: {
search: '',
status: ''
},
// Pagination
pagination: {
page: 1,
per_page: 20,
total: 0,
pages: 0
},
// State
loading: false,
error: null,
async init() {
loyaltyCardsLog.info('=== LOYALTY CARDS PAGE INITIALIZING ===');
if (window._loyaltyCardsInitialized) return;
window._loyaltyCardsInitialized = true;
if (window.PlatformSettings) {
this.pagination.per_page = await window.PlatformSettings.getRowsPerPage();
}
await this.loadData();
loyaltyCardsLog.info('=== LOYALTY CARDS PAGE INITIALIZATION COMPLETE ===');
},
async loadData() {
this.loading = true;
this.error = null;
try {
await Promise.all([
this.loadProgram(),
this.loadCards(),
this.loadStats()
]);
} catch (error) {
loyaltyCardsLog.error('Failed to load data:', error);
this.error = error.message;
} finally {
this.loading = false;
}
},
async loadProgram() {
try {
const response = await apiClient.get('/vendor/loyalty/program');
if (response) this.program = response;
} catch (error) {
if (error.status !== 404) throw error;
}
},
async loadCards() {
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');
const response = await apiClient.get(`/vendor/loyalty/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);
}
},
async loadStats() {
try {
const response = await apiClient.get('/vendor/loyalty/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) {
loyaltyCardsLog.warn('Failed to load stats:', 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();
},
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(); } },
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.loyaltyCards) {
window.LogConfig.loggers.loyaltyCards = window.LogConfig.createLogger('loyaltyCards');
}
loyaltyCardsLog.info('Loyalty cards module loaded');

View File

@@ -0,0 +1,101 @@
// app/modules/loyalty/static/vendor/js/loyalty-enroll.js
// noqa: js-006 - async init pattern is safe, loadData has try/catch
const loyaltyEnrollLog = window.LogConfig.loggers.loyaltyEnroll || window.LogConfig.createLogger('loyaltyEnroll');
function vendorLoyaltyEnroll() {
return {
...data(),
currentPage: 'loyalty-enroll',
program: null,
form: {
first_name: '',
last_name: '',
email: '',
phone: '',
birthday: '',
marketing_email: false,
marketing_sms: false
},
enrolling: false,
enrolledCard: null,
loading: false,
error: null,
async init() {
loyaltyEnrollLog.info('=== LOYALTY ENROLL PAGE INITIALIZING ===');
if (window._loyaltyEnrollInitialized) return;
window._loyaltyEnrollInitialized = true;
await this.loadProgram();
loyaltyEnrollLog.info('=== LOYALTY ENROLL PAGE INITIALIZATION COMPLETE ===');
},
async loadProgram() {
this.loading = true;
try {
const response = await apiClient.get('/vendor/loyalty/program');
if (response) {
this.program = response;
loyaltyEnrollLog.info('Program loaded:', this.program.display_name);
}
} catch (error) {
if (error.status === 404) {
loyaltyEnrollLog.warn('No program configured');
} else {
this.error = error.message;
}
} finally {
this.loading = false;
}
},
async enrollCustomer() {
if (!this.form.first_name || !this.form.email) return;
this.enrolling = true;
try {
loyaltyEnrollLog.info('Enrolling customer:', this.form.email);
const response = await apiClient.post('/vendor/loyalty/cards/enroll', {
customer_email: this.form.email,
customer_phone: this.form.phone || null,
customer_name: [this.form.first_name, this.form.last_name].filter(Boolean).join(' '),
customer_birthday: this.form.birthday || null,
marketing_email_consent: this.form.marketing_email,
marketing_sms_consent: this.form.marketing_sms
});
if (response) {
this.enrolledCard = response;
loyaltyEnrollLog.info('Customer enrolled successfully:', response.card_number);
}
} catch (error) {
Utils.showToast(`Enrollment failed: ${error.message}`, 'error');
loyaltyEnrollLog.error('Enrollment failed:', error);
} finally {
this.enrolling = false;
}
},
resetForm() {
this.form = {
first_name: '',
last_name: '',
email: '',
phone: '',
birthday: '',
marketing_email: false,
marketing_sms: false
};
}
};
}
if (!window.LogConfig.loggers.loyaltyEnroll) {
window.LogConfig.loggers.loyaltyEnroll = window.LogConfig.createLogger('loyaltyEnroll');
}
loyaltyEnrollLog.info('Loyalty enroll module loaded');

View File

@@ -0,0 +1,118 @@
// app/modules/loyalty/static/vendor/js/loyalty-settings.js
// noqa: js-006 - async init pattern is safe, loadData has try/catch
const loyaltySettingsLog = window.LogConfig.loggers.loyaltySettings || window.LogConfig.createLogger('loyaltySettings');
function vendorLoyaltySettings() {
return {
...data(),
currentPage: 'loyalty-settings',
settings: {
loyalty_type: 'points',
points_per_euro: 1,
welcome_bonus_points: 0,
minimum_redemption_points: 100,
points_expiration_days: null,
points_rewards: [],
card_name: '',
card_color: '#4F46E5',
is_active: true
},
loading: false,
saving: false,
error: null,
isNewProgram: false,
async init() {
loyaltySettingsLog.info('=== LOYALTY SETTINGS PAGE INITIALIZING ===');
if (window._loyaltySettingsInitialized) return;
window._loyaltySettingsInitialized = true;
await this.loadSettings();
loyaltySettingsLog.info('=== LOYALTY SETTINGS PAGE INITIALIZATION COMPLETE ===');
},
async loadSettings() {
this.loading = true;
this.error = null;
try {
const response = await apiClient.get('/vendor/loyalty/program');
if (response) {
this.settings = {
loyalty_type: response.loyalty_type || 'points',
points_per_euro: response.points_per_euro || 1,
welcome_bonus_points: response.welcome_bonus_points || 0,
minimum_redemption_points: response.minimum_redemption_points || 100,
points_expiration_days: response.points_expiration_days || null,
points_rewards: response.points_rewards || [],
card_name: response.card_name || '',
card_color: response.card_color || '#4F46E5',
is_active: response.is_active !== false
};
this.isNewProgram = false;
loyaltySettingsLog.info('Settings loaded');
}
} catch (error) {
if (error.status === 404) {
loyaltySettingsLog.info('No program found, creating new');
this.isNewProgram = true;
} else {
throw error;
}
} finally {
this.loading = false;
}
},
async saveSettings() {
this.saving = true;
try {
// Ensure rewards have IDs
this.settings.points_rewards = this.settings.points_rewards.map((r, i) => ({
...r,
id: r.id || `reward_${i + 1}`,
is_active: r.is_active !== false
}));
let response;
if (this.isNewProgram) {
response = await apiClient.post('/vendor/loyalty/program', this.settings);
this.isNewProgram = false;
} else {
response = await apiClient.patch('/vendor/loyalty/program', this.settings);
}
Utils.showToast('Settings saved successfully', 'success');
loyaltySettingsLog.info('Settings saved');
} catch (error) {
Utils.showToast(`Failed to save: ${error.message}`, 'error');
loyaltySettingsLog.error('Save failed:', error);
} finally {
this.saving = false;
}
},
addReward() {
this.settings.points_rewards.push({
id: `reward_${Date.now()}`,
name: '',
points_required: 100,
description: '',
is_active: true
});
},
removeReward(index) {
this.settings.points_rewards.splice(index, 1);
}
};
}
if (!window.LogConfig.loggers.loyaltySettings) {
window.LogConfig.loggers.loyaltySettings = window.LogConfig.createLogger('loyaltySettings');
}
loyaltySettingsLog.info('Loyalty settings module loaded');

View File

@@ -0,0 +1,74 @@
// app/modules/loyalty/static/vendor/js/loyalty-stats.js
// noqa: js-006 - async init pattern is safe, loadData has try/catch
const loyaltyStatsLog = window.LogConfig.loggers.loyaltyStats || window.LogConfig.createLogger('loyaltyStats');
function vendorLoyaltyStats() {
return {
...data(),
currentPage: 'loyalty-stats',
stats: {
total_cards: 0,
active_cards: 0,
new_this_month: 0,
total_points_issued: 0,
total_points_redeemed: 0,
total_points_balance: 0,
points_issued_30d: 0,
points_redeemed_30d: 0,
transactions_30d: 0,
avg_points_per_member: 0
},
loading: false,
error: null,
async init() {
loyaltyStatsLog.info('=== LOYALTY STATS PAGE INITIALIZING ===');
if (window._loyaltyStatsInitialized) return;
window._loyaltyStatsInitialized = true;
await this.loadStats();
loyaltyStatsLog.info('=== LOYALTY STATS PAGE INITIALIZATION COMPLETE ===');
},
async loadStats() {
this.loading = true;
this.error = null;
try {
const response = await apiClient.get('/vendor/loyalty/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_issued: response.total_points_issued || 0,
total_points_redeemed: response.total_points_redeemed || 0,
total_points_balance: response.total_points_balance || 0,
points_issued_30d: response.points_issued_30d || 0,
points_redeemed_30d: response.points_redeemed_30d || 0,
transactions_30d: response.transactions_30d || 0,
avg_points_per_member: response.avg_points_per_member || 0
};
loyaltyStatsLog.info('Stats loaded');
}
} catch (error) {
loyaltyStatsLog.error('Failed to load stats:', error);
this.error = error.message;
} finally {
this.loading = false;
}
},
formatNumber(num) {
return num == null ? '0' : new Intl.NumberFormat('en-US').format(num);
}
};
}
if (!window.LogConfig.loggers.loyaltyStats) {
window.LogConfig.loggers.loyaltyStats = window.LogConfig.createLogger('loyaltyStats');
}
loyaltyStatsLog.info('Loyalty stats module loaded');

View File

@@ -0,0 +1,286 @@
// app/modules/loyalty/static/vendor/js/loyalty-terminal.js
// noqa: js-006 - async init pattern is safe, loadData has try/catch
// Use centralized logger
const loyaltyTerminalLog = window.LogConfig.loggers.loyaltyTerminal || window.LogConfig.createLogger('loyaltyTerminal');
// ============================================
// VENDOR LOYALTY TERMINAL FUNCTION
// ============================================
function vendorLoyaltyTerminal() {
return {
// Inherit base layout functionality
...data(),
// Page identifier
currentPage: 'loyalty-terminal',
// Program state
program: null,
availableRewards: [],
// Customer lookup
searchQuery: '',
lookingUp: false,
selectedCard: null,
// Transaction inputs
earnAmount: null,
selectedReward: '',
// PIN entry
showPinEntry: false,
pinDigits: '',
pendingAction: null, // 'earn' or 'redeem'
processing: false,
// Recent transactions
recentTransactions: [],
// State
loading: false,
error: null,
// Initialize
async init() {
loyaltyTerminalLog.info('=== LOYALTY TERMINAL INITIALIZING ===');
// Prevent multiple initializations
if (window._loyaltyTerminalInitialized) {
loyaltyTerminalLog.warn('Loyalty terminal already initialized, skipping...');
return;
}
window._loyaltyTerminalInitialized = true;
await this.loadData();
loyaltyTerminalLog.info('=== LOYALTY TERMINAL INITIALIZATION COMPLETE ===');
},
// Load initial data
async loadData() {
this.loading = true;
this.error = null;
try {
await Promise.all([
this.loadProgram(),
this.loadRecentTransactions()
]);
} catch (error) {
loyaltyTerminalLog.error('Failed to load data:', error);
this.error = error.message || 'Failed to load terminal';
} finally {
this.loading = false;
}
},
// Load program info
async loadProgram() {
try {
loyaltyTerminalLog.info('Loading program info...');
const response = await apiClient.get('/vendor/loyalty/program');
if (response) {
this.program = response;
this.availableRewards = response.points_rewards || [];
loyaltyTerminalLog.info('Program loaded:', this.program.display_name);
}
} catch (error) {
if (error.status === 404) {
loyaltyTerminalLog.info('No program configured');
this.program = null;
} else {
throw error;
}
}
},
// Load recent transactions
async loadRecentTransactions() {
try {
loyaltyTerminalLog.info('Loading recent transactions...');
const response = await apiClient.get('/vendor/loyalty/transactions?limit=10');
if (response && response.transactions) {
this.recentTransactions = response.transactions;
loyaltyTerminalLog.info(`Loaded ${this.recentTransactions.length} transactions`);
}
} catch (error) {
loyaltyTerminalLog.warn('Failed to load transactions:', error.message);
// Don't throw - transactions are optional
}
},
// Look up customer
async lookupCustomer() {
if (!this.searchQuery) return;
this.lookingUp = true;
this.selectedCard = null;
try {
loyaltyTerminalLog.info('Looking up customer:', this.searchQuery);
const response = await apiClient.get(`/vendor/loyalty/cards/lookup?q=${encodeURIComponent(this.searchQuery)}`);
if (response) {
this.selectedCard = response;
loyaltyTerminalLog.info('Customer found:', this.selectedCard.customer_name);
this.searchQuery = '';
}
} catch (error) {
if (error.status === 404) {
Utils.showToast('Customer not found. You can enroll them as a new member.', 'warning');
} else {
Utils.showToast(`Error looking up customer: ${error.message}`, 'error');
}
loyaltyTerminalLog.error('Lookup failed:', error);
} finally {
this.lookingUp = false;
}
},
// Clear selected customer
clearCustomer() {
this.selectedCard = null;
this.earnAmount = null;
this.selectedReward = '';
},
// Get selected reward points
getSelectedRewardPoints() {
if (!this.selectedReward) return 0;
const reward = this.availableRewards.find(r => r.id === this.selectedReward);
return reward ? reward.points_required : 0;
},
// Show PIN modal
showPinModal(action) {
this.pendingAction = action;
this.pinDigits = '';
this.showPinEntry = true;
},
// PIN entry methods
addPinDigit(digit) {
if (this.pinDigits.length < 4) {
this.pinDigits += digit.toString();
}
},
removePinDigit() {
this.pinDigits = this.pinDigits.slice(0, -1);
},
cancelPinEntry() {
this.showPinEntry = false;
this.pinDigits = '';
this.pendingAction = null;
},
// Submit transaction
async submitTransaction() {
if (this.pinDigits.length !== 4) return;
this.processing = true;
try {
if (this.pendingAction === 'earn') {
await this.earnPoints();
} else if (this.pendingAction === 'redeem') {
await this.redeemReward();
}
// Close modal and refresh
this.showPinEntry = false;
this.pinDigits = '';
this.pendingAction = null;
// Refresh customer card and transactions
if (this.selectedCard) {
await this.refreshCard();
}
await this.loadRecentTransactions();
} catch (error) {
Utils.showToast(`Transaction failed: ${error.message}`, 'error');
loyaltyTerminalLog.error('Transaction failed:', error);
} finally {
this.processing = false;
}
},
// Earn points
async earnPoints() {
loyaltyTerminalLog.info('Earning points...', { amount: this.earnAmount });
const response = await apiClient.post('/vendor/loyalty/points/earn', {
card_id: this.selectedCard.id,
purchase_amount_cents: Math.round(this.earnAmount * 100),
staff_pin: this.pinDigits
});
const pointsEarned = response.points_earned || Math.floor(this.earnAmount * (this.program?.points_per_euro || 1));
Utils.showToast(`${pointsEarned} points awarded!`, 'success');
this.earnAmount = null;
},
// Redeem reward
async redeemReward() {
const reward = this.availableRewards.find(r => r.id === this.selectedReward);
if (!reward) return;
loyaltyTerminalLog.info('Redeeming reward...', { reward: reward.name });
await apiClient.post('/vendor/loyalty/points/redeem', {
card_id: this.selectedCard.id,
reward_id: this.selectedReward,
staff_pin: this.pinDigits
});
Utils.showToast(`Reward redeemed: ${reward.name}`, 'success');
this.selectedReward = '';
},
// Refresh card data
async refreshCard() {
try {
const response = await apiClient.get(`/vendor/loyalty/cards/${this.selectedCard.id}`);
if (response) {
this.selectedCard = response;
}
} catch (error) {
loyaltyTerminalLog.warn('Failed to refresh card:', error.message);
}
},
// Format number
formatNumber(num) {
if (num === null || num === undefined) return '0';
return new Intl.NumberFormat('en-US').format(num);
},
// Format time
formatTime(dateString) {
if (!dateString) return '-';
try {
const date = new Date(dateString);
return date.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit'
});
} catch (e) {
return dateString;
}
}
};
}
// Register logger
if (!window.LogConfig.loggers.loyaltyTerminal) {
window.LogConfig.loggers.loyaltyTerminal = window.LogConfig.createLogger('loyaltyTerminal');
}
loyaltyTerminalLog.info('Loyalty terminal module loaded');

View File

@@ -3,8 +3,17 @@
Loyalty module Celery tasks.
Background tasks for:
- Point expiration
- Wallet synchronization
- Point expiration (daily at 02:00)
- Wallet synchronization (hourly)
Task registration is handled by the module definition in definition.py
which specifies the task paths and schedules.
"""
__all__: list[str] = []
from app.modules.loyalty.tasks.point_expiration import expire_points
from app.modules.loyalty.tasks.wallet_sync import sync_wallet_passes
__all__ = [
"expire_points",
"sync_wallet_passes",
]

View File

@@ -3,12 +3,20 @@
Point expiration task.
Handles expiring points that are older than the configured
expiration period (future enhancement).
expiration period based on card inactivity.
Runs daily at 02:00 via the scheduled task configuration in definition.py.
"""
import logging
from datetime import UTC, datetime, timedelta
from celery import shared_task
from sqlalchemy.orm import Session
from app.core.database import SessionLocal
from app.modules.loyalty.models import LoyaltyCard, LoyaltyProgram, LoyaltyTransaction
from app.modules.loyalty.models.loyalty_transaction import TransactionType
logger = logging.getLogger(__name__)
@@ -16,26 +24,175 @@ logger = logging.getLogger(__name__)
@shared_task(name="loyalty.expire_points")
def expire_points() -> dict:
"""
Expire points that are past their expiration date.
Expire points that are past their expiration date based on card inactivity.
This is a placeholder for future functionality where points
can be configured to expire after a certain period.
For each program with points_expiration_days configured:
1. Find cards that haven't had activity in the expiration period
2. Expire all points on those cards
3. Create POINTS_EXPIRED transaction records
4. Update card balances
Returns:
Summary of expired points
"""
# Future implementation:
# 1. Find programs with point expiration enabled
# 2. Find cards with points earned before expiration threshold
# 3. Calculate points to expire
# 4. Create adjustment transactions
# 5. Update card balances
# 6. Notify customers (optional)
logger.info("Starting point expiration task...")
logger.info("Point expiration task running (no-op for now)")
db: Session = SessionLocal()
try:
result = _process_point_expiration(db)
db.commit()
logger.info(
f"Point expiration complete: {result['cards_processed']} cards, "
f"{result['points_expired']} points expired"
)
return result
except Exception as e:
db.rollback()
logger.error(f"Point expiration task failed: {e}", exc_info=True)
return {
"status": "error",
"error": str(e),
"cards_processed": 0,
"points_expired": 0,
}
finally:
db.close()
def _process_point_expiration(db: Session) -> dict:
"""
Process point expiration for all programs.
Args:
db: Database session
Returns:
Summary of expired points
"""
total_cards_processed = 0
total_points_expired = 0
programs_processed = 0
# Find all active programs with point expiration configured
programs = (
db.query(LoyaltyProgram)
.filter(
LoyaltyProgram.is_active == True,
LoyaltyProgram.points_expiration_days.isnot(None),
LoyaltyProgram.points_expiration_days > 0,
)
.all()
)
logger.info(f"Found {len(programs)} programs with point expiration configured")
for program in programs:
cards_count, points_count = _expire_points_for_program(db, program)
total_cards_processed += cards_count
total_points_expired += points_count
programs_processed += 1
logger.debug(
f"Program {program.id} (company {program.company_id}): "
f"{cards_count} cards, {points_count} points expired"
)
return {
"status": "success",
"cards_processed": 0,
"points_expired": 0,
"programs_processed": programs_processed,
"cards_processed": total_cards_processed,
"points_expired": total_points_expired,
}
def _expire_points_for_program(db: Session, program: LoyaltyProgram) -> tuple[int, int]:
"""
Expire points for a specific loyalty program.
Args:
db: Database session
program: Loyalty program to process
Returns:
Tuple of (cards_processed, points_expired)
"""
if not program.points_expiration_days:
return 0, 0
# Calculate expiration threshold
expiration_threshold = datetime.now(UTC) - timedelta(days=program.points_expiration_days)
logger.debug(
f"Processing program {program.id}: expiration after {program.points_expiration_days} days "
f"(threshold: {expiration_threshold})"
)
# Find cards with:
# - Points balance > 0
# - Last activity before expiration threshold
# - Belonging to this program's company
cards_to_expire = (
db.query(LoyaltyCard)
.filter(
LoyaltyCard.company_id == program.company_id,
LoyaltyCard.points_balance > 0,
LoyaltyCard.last_activity_at < expiration_threshold,
LoyaltyCard.is_active == True,
)
.all()
)
if not cards_to_expire:
logger.debug(f"No cards to expire for program {program.id}")
return 0, 0
logger.info(f"Found {len(cards_to_expire)} cards to expire for program {program.id}")
cards_processed = 0
points_expired = 0
for card in cards_to_expire:
if card.points_balance <= 0:
continue
expired_points = card.points_balance
# Create expiration transaction
transaction = LoyaltyTransaction(
card_id=card.id,
company_id=program.company_id,
vendor_id=None, # System action, no vendor
transaction_type=TransactionType.POINTS_EXPIRED.value,
points_delta=-expired_points,
balance_after=0,
stamps_delta=0,
stamps_balance_after=card.stamps_balance,
notes=f"Points expired after {program.points_expiration_days} days of inactivity",
transaction_at=datetime.now(UTC),
)
db.add(transaction)
# Update card balance
card.points_balance = 0
card.total_points_voided = (card.total_points_voided or 0) + expired_points
# Note: We don't update last_activity_at for expiration
cards_processed += 1
points_expired += expired_points
logger.debug(
f"Expired {expired_points} points from card {card.id} "
f"(last activity: {card.last_activity_at})"
)
return cards_processed, points_expired
# Allow running directly for testing
if __name__ == "__main__":
import sys
logging.basicConfig(level=logging.DEBUG)
result = expire_points()
print(f"Result: {result}")
sys.exit(0 if result["status"] == "success" else 1)

View File

@@ -0,0 +1,162 @@
{# app/modules/loyalty/templates/loyalty/admin/analytics.html #}
{% extends "admin/base.html" %}
{% from 'shared/macros/headers.html' import page_header %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% block title %}Loyalty Analytics{% endblock %}
{% block alpine_data %}adminLoyaltyAnalytics(){% endblock %}
{% block content %}
{{ page_header('Loyalty Analytics') }}
{{ loading_state('Loading analytics...') }}
{{ error_state('Error loading analytics') }}
<!-- Analytics Dashboard -->
<div x-show="!loading">
<!-- Summary Stats -->
<div class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-4">
<!-- Total Programs -->
<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('gift', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Total Programs
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="formatNumber(stats.total_programs)">
0
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
<span x-text="stats.active_programs"></span> active
</p>
</div>
</div>
<!-- Total Members -->
<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('users', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Total Members
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="formatNumber(stats.total_cards)">
0
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
<span x-text="formatNumber(stats.active_cards)"></span> active
</p>
</div>
</div>
<!-- Points Issued (30d) -->
<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">
Points Issued (30d)
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="formatNumber(stats.points_issued_30d)">
0
</p>
</div>
</div>
<!-- Points Redeemed (30d) -->
<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('shopping-cart', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Points Redeemed (30d)
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="formatNumber(stats.points_redeemed_30d)">
0
</p>
</div>
</div>
</div>
<!-- Activity Metrics -->
<div class="grid gap-6 mb-8 md:grid-cols-2">
<!-- Transactions Overview -->
<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('chart-bar', 'inline w-5 h-5 mr-2')"></span>
Transaction Activity (30 Days)
</h3>
<div class="space-y-4">
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600 dark:text-gray-400">Total Transactions</span>
<span class="font-semibold text-gray-700 dark:text-gray-200" x-text="formatNumber(stats.transactions_30d)">0</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600 dark:text-gray-400">Companies with Programs</span>
<span class="font-semibold text-gray-700 dark:text-gray-200" x-text="formatNumber(stats.companies_with_programs)">0</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600 dark:text-gray-400">Redemption Rate</span>
<span class="font-semibold text-gray-700 dark:text-gray-200" x-text="redemptionRate + '%'">0%</span>
</div>
</div>
</div>
<!-- Points Balance Overview -->
<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('currency-dollar', 'inline w-5 h-5 mr-2')"></span>
Points Overview
</h3>
<div class="space-y-4">
<div>
<div class="flex items-center justify-between mb-1">
<span class="text-sm text-gray-600 dark:text-gray-400">Points Issued vs Redeemed (30d)</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-4 dark:bg-gray-700">
<div class="h-4 rounded-full flex">
<div class="bg-green-500 rounded-l-full" :style="'width: ' + issuedPercentage + '%'"></div>
<div class="bg-orange-500 rounded-r-full" :style="'width: ' + redeemedPercentage + '%'"></div>
</div>
</div>
<div class="flex justify-between mt-2 text-xs text-gray-500 dark:text-gray-400">
<span><span class="inline-block w-3 h-3 bg-green-500 rounded-full mr-1"></span>Issued: <span x-text="formatNumber(stats.points_issued_30d)"></span></span>
<span><span class="inline-block w-3 h-3 bg-orange-500 rounded-full mr-1"></span>Redeemed: <span x-text="formatNumber(stats.points_redeemed_30d)"></span></span>
</div>
</div>
</div>
</div>
</div>
<!-- Quick Links -->
<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('link', 'inline w-5 h-5 mr-2')"></span>
Quick Links
</h3>
<div class="flex flex-wrap gap-3">
<a href="/admin/loyalty/programs"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-purple-600 bg-purple-100 rounded-lg hover:bg-purple-200 dark:text-purple-300 dark:bg-purple-900/30 dark:hover:bg-purple-900/50">
<span x-html="$icon('gift', 'w-4 h-4 mr-2')"></span>
View All Programs
</a>
<a href="/admin/companies"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-blue-600 bg-blue-100 rounded-lg hover:bg-blue-200 dark:text-blue-300 dark:bg-blue-900/30 dark:hover:bg-blue-900/50">
<span x-html="$icon('building-office', 'w-4 h-4 mr-2')"></span>
Manage Companies
</a>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('loyalty_static', path='admin/js/loyalty-analytics.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,238 @@
{# app/modules/loyalty/templates/loyalty/admin/company-detail.html #}
{% extends "admin/base.html" %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% from 'shared/macros/headers.html' import detail_page_header %}
{% from 'shared/macros/tables.html' import table_wrapper, table_header %}
{% block title %}Company Loyalty Details{% endblock %}
{% block alpine_data %}adminLoyaltyCompanyDetail(){% endblock %}
{% block content %}
{% call detail_page_header("company?.name || 'Company Loyalty'", '/admin/loyalty/programs', subtitle_show='company') %}
<span x-text="program ? 'Loyalty Program Active' : 'No Loyalty Program'"></span>
{% endcall %}
{{ loading_state('Loading company loyalty details...') }}
{{ error_state('Error loading company loyalty') }}
<!-- Company Details -->
<div x-show="!loading && company">
<!-- Quick Actions Card -->
<div class="px-4 py-3 mb-6 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">
Quick Actions
</h3>
<div class="flex flex-wrap items-center gap-3">
<a
:href="`/admin/loyalty/companies/${companyId}/settings`"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple">
<span x-html="$icon('cog', 'w-4 h-4 mr-2')"></span>
Loyalty Settings
</a>
<a
:href="`/admin/companies/${company?.id}`"
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('building-office', 'w-4 h-4 mr-2')"></span>
View Company
</a>
</div>
</div>
<!-- Stats Cards -->
<div class="grid gap-6 mb-8 md:grid-cols-4">
<!-- Total Members -->
<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('users', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
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>
<!-- Active Members -->
<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">
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>
<!-- Points Issued (30d) -->
<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('trending-up', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Points Issued (30d)
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="formatNumber(stats.points_issued_30d)">
0
</p>
</div>
</div>
<!-- Points Redeemed (30d) -->
<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">
Points Redeemed (30d)
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="formatNumber(stats.points_redeemed_30d)">
0
</p>
</div>
</div>
</div>
<!-- Program Configuration -->
<div x-show="program" class="px-4 py-3 mb-8 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('cog', 'inline w-5 h-5 mr-2')"></span>
Program Configuration
</h3>
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase mb-2">Program Name</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="program?.display_name || program?.card_name || '-'">-</p>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase mb-2">Points Per Euro</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="program?.points_per_euro || 1">-</p>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase mb-2">Welcome Bonus</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="program?.welcome_bonus_points ? program.welcome_bonus_points + ' points' : 'None'">-</p>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase mb-2">Minimum Redemption</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="program?.minimum_redemption_points ? program.minimum_redemption_points + ' points' : 'None'">-</p>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase mb-2">Points Expiration</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="program?.points_expiration_days ? program.points_expiration_days + ' days of inactivity' : 'Never'">-</p>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase mb-2">Status</p>
<span class="inline-flex items-center px-2 py-1 text-xs font-semibold leading-tight rounded-full"
:class="program?.is_active ? 'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100' : 'text-gray-700 bg-gray-100 dark:text-gray-100 dark:bg-gray-700'">
<span x-text="program?.is_active ? 'Active' : 'Inactive'"></span>
</span>
</div>
</div>
</div>
<!-- No Program Notice -->
<div x-show="!program" class="px-4 py-3 mb-8 bg-yellow-50 border border-yellow-200 rounded-lg dark:bg-yellow-900/20 dark:border-yellow-800">
<div class="flex items-center">
<span x-html="$icon('exclamation-triangle', 'w-5 h-5 text-yellow-500 mr-3')"></span>
<div>
<h3 class="text-sm font-semibold text-yellow-800 dark:text-yellow-200">No Loyalty Program</h3>
<p class="text-sm text-yellow-700 dark:text-yellow-300">This company has not set up a loyalty program yet. Vendors can set up the program from their dashboard.</p>
</div>
</div>
</div>
<!-- Location Breakdown -->
<div x-show="locations.length > 0" class="px-4 py-3 mb-8 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('map-pin', 'inline w-5 h-5 mr-2')"></span>
Location Breakdown (<span x-text="locations.length"></span>)
</h3>
{% call table_wrapper() %}
{{ table_header(['Location', 'Enrolled', 'Points Earned', 'Points Redeemed', 'Transactions (30d)']) }}
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<template x-for="location in locations" :key="location.vendor_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>
<p class="font-semibold" x-text="location.vendor_name"></p>
<p class="text-xs text-gray-600 dark:text-gray-400" x-text="location.vendor_code"></p>
</div>
</div>
</td>
<td class="px-4 py-3 text-sm" x-text="formatNumber(location.enrolled_count)">0</td>
<td class="px-4 py-3 text-sm" x-text="formatNumber(location.points_earned)">0</td>
<td class="px-4 py-3 text-sm" x-text="formatNumber(location.points_redeemed)">0</td>
<td class="px-4 py-3 text-sm" x-text="formatNumber(location.transactions_30d)">0</td>
</tr>
</template>
<!-- Totals Row -->
<tr class="text-gray-900 dark:text-gray-100 font-semibold bg-gray-50 dark:bg-gray-700">
<td class="px-4 py-3 text-sm">TOTAL</td>
<td class="px-4 py-3 text-sm" x-text="formatNumber(stats.total_cards)">0</td>
<td class="px-4 py-3 text-sm" x-text="formatNumber(stats.total_points_issued)">0</td>
<td class="px-4 py-3 text-sm" x-text="formatNumber(stats.total_points_redeemed)">0</td>
<td class="px-4 py-3 text-sm" x-text="formatNumber(stats.transactions_30d)">0</td>
</tr>
</tbody>
{% endcall %}
</div>
<!-- Company Settings (Admin-controlled) -->
<div class="px-4 py-3 mb-8 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>
Admin Settings
</h3>
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase mb-2">Staff PIN Policy</p>
<span class="inline-flex items-center px-2 py-1 text-xs font-semibold leading-tight rounded-full"
:class="{
'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100': settings?.staff_pin_policy === 'required',
'text-yellow-700 bg-yellow-100 dark:bg-yellow-700 dark:text-yellow-100': settings?.staff_pin_policy === 'optional',
'text-gray-700 bg-gray-100 dark:bg-gray-700 dark:text-gray-100': settings?.staff_pin_policy === 'disabled'
}">
<span x-text="settings?.staff_pin_policy ? settings.staff_pin_policy.charAt(0).toUpperCase() + settings.staff_pin_policy.slice(1) : 'Optional'"></span>
</span>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase mb-2">Self Enrollment</p>
<span class="inline-flex items-center px-2 py-1 text-xs font-semibold leading-tight 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'">
<span x-text="settings?.allow_self_enrollment ? 'Allowed' : 'Disabled'"></span>
</span>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase mb-2">Cross-Location Redemption</p>
<span class="inline-flex items-center px-2 py-1 text-xs font-semibold leading-tight rounded-full"
:class="settings?.allow_cross_location_redemption !== false ? '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'">
<span x-text="settings?.allow_cross_location_redemption !== false ? 'Allowed' : 'Disabled'"></span>
</span>
</div>
</div>
<div class="mt-4">
<a
:href="`/admin/loyalty/companies/${companyId}/settings`"
class="text-sm text-purple-600 hover:text-purple-700 dark:text-purple-400">
<span x-html="$icon('cog', 'inline w-4 h-4 mr-1')"></span>
Modify admin settings
</a>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('loyalty_static', path='admin/js/loyalty-company-detail.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,180 @@
{# app/modules/loyalty/templates/loyalty/admin/company-settings.html #}
{% extends "admin/base.html" %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% from 'shared/macros/headers.html' import detail_page_header %}
{% from 'shared/macros/forms.html' import form_section, form_actions %}
{% block title %}Company Loyalty Settings{% endblock %}
{% block alpine_data %}adminLoyaltyCompanySettings(){% endblock %}
{% block content %}
{% call detail_page_header("'Loyalty Settings: ' + (company?.name || '')", backUrl, subtitle_show='company') %}
Admin-controlled settings for this company's loyalty program
{% endcall %}
{{ loading_state('Loading settings...') }}
{{ error_state('Error loading settings') }}
<!-- Settings Form -->
<div x-show="!loading">
<form @submit.prevent="saveSettings">
<!-- Staff PIN Policy -->
<div class="px-4 py-5 mb-6 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>
Staff PIN Policy
</h3>
<p class="mb-4 text-sm text-gray-600 dark:text-gray-400">
Control whether staff members at this company's locations must enter a PIN to process loyalty transactions.
</p>
<div class="space-y-4">
<label class="flex items-start p-4 border rounded-lg cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700"
:class="settings.staff_pin_policy === 'required' ? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20' : 'border-gray-200 dark:border-gray-600'">
<input type="radio" name="staff_pin_policy" value="required"
x-model="settings.staff_pin_policy"
class="mt-1 text-purple-600 form-radio">
<div class="ml-3">
<p class="font-medium text-gray-700 dark:text-gray-300">Required</p>
<p class="text-sm text-gray-500 dark:text-gray-400">Staff must enter their PIN for every transaction. Recommended for security.</p>
</div>
</label>
<label class="flex items-start p-4 border rounded-lg cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700"
:class="settings.staff_pin_policy === 'optional' ? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20' : 'border-gray-200 dark:border-gray-600'">
<input type="radio" name="staff_pin_policy" value="optional"
x-model="settings.staff_pin_policy"
class="mt-1 text-purple-600 form-radio">
<div class="ml-3">
<p class="font-medium text-gray-700 dark:text-gray-300">Optional</p>
<p class="text-sm text-gray-500 dark:text-gray-400">Vendors can choose whether to require PINs at their locations.</p>
</div>
</label>
<label class="flex items-start p-4 border rounded-lg cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700"
:class="settings.staff_pin_policy === 'disabled' ? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20' : 'border-gray-200 dark:border-gray-600'">
<input type="radio" name="staff_pin_policy" value="disabled"
x-model="settings.staff_pin_policy"
class="mt-1 text-purple-600 form-radio">
<div class="ml-3">
<p class="font-medium text-gray-700 dark:text-gray-300">Disabled</p>
<p class="text-sm text-gray-500 dark:text-gray-400">Staff PINs are not used. Any staff member can process transactions.</p>
</div>
</label>
</div>
<!-- PIN Lockout Settings -->
<div x-show="settings.staff_pin_policy !== 'disabled'" class="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700">
<h4 class="mb-4 text-md font-medium text-gray-700 dark:text-gray-300">
PIN Lockout Settings
</h4>
<div class="grid gap-6 md:grid-cols-2">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Max Failed Attempts
</label>
<input type="number" min="3" max="10"
x-model.number="settings.staff_pin_lockout_attempts"
class="w-full 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">
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Number of wrong attempts before lockout (3-10)</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Lockout Duration (minutes)
</label>
<input type="number" min="5" max="120"
x-model.number="settings.staff_pin_lockout_minutes"
class="w-full 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">
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">How long to lock out after failed attempts (5-120 minutes)</p>
</div>
</div>
</div>
</div>
<!-- Enrollment Settings -->
<div class="px-4 py-5 mb-6 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-plus', 'inline w-5 h-5 mr-2')"></span>
Enrollment Settings
</h3>
<div class="space-y-4">
<label class="flex items-center justify-between p-4 border border-gray-200 dark:border-gray-600 rounded-lg">
<div>
<p class="font-medium text-gray-700 dark:text-gray-300">Allow Self-Service Enrollment</p>
<p class="text-sm text-gray-500 dark:text-gray-400">Customers can sign up via QR code without staff assistance</p>
</div>
<div class="relative">
<input type="checkbox" x-model="settings.allow_self_enrollment"
class="sr-only peer">
<div @click="settings.allow_self_enrollment = !settings.allow_self_enrollment"
class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-purple-300 dark:peer-focus:ring-purple-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 cursor-pointer"
:class="settings.allow_self_enrollment ? 'bg-purple-600' : ''">
</div>
</div>
</label>
</div>
</div>
<!-- Transaction Settings -->
<div class="px-4 py-5 mb-6 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('arrows-right-left', 'inline w-5 h-5 mr-2')"></span>
Transaction Settings
</h3>
<div class="space-y-4">
<label class="flex items-center justify-between p-4 border border-gray-200 dark:border-gray-600 rounded-lg">
<div>
<p class="font-medium text-gray-700 dark:text-gray-300">Allow Cross-Location Redemption</p>
<p class="text-sm text-gray-500 dark:text-gray-400">Customers can redeem points at any company location</p>
</div>
<div class="relative">
<input type="checkbox" x-model="settings.allow_cross_location_redemption"
class="sr-only peer">
<div @click="settings.allow_cross_location_redemption = !settings.allow_cross_location_redemption"
class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-purple-300 dark:peer-focus:ring-purple-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 cursor-pointer"
:class="settings.allow_cross_location_redemption ? 'bg-purple-600' : ''">
</div>
</div>
</label>
<label class="flex items-center justify-between p-4 border border-gray-200 dark:border-gray-600 rounded-lg">
<div>
<p class="font-medium text-gray-700 dark:text-gray-300">Allow Void Transactions</p>
<p class="text-sm text-gray-500 dark:text-gray-400">Staff can void points/stamps for returns</p>
</div>
<div class="relative">
<input type="checkbox" x-model="settings.allow_void_transactions"
class="sr-only peer">
<div @click="settings.allow_void_transactions = !settings.allow_void_transactions"
class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-purple-300 dark:peer-focus:ring-purple-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 cursor-pointer"
:class="settings.allow_void_transactions ? 'bg-purple-600' : ''">
</div>
</div>
</label>
</div>
</div>
<!-- Form Actions -->
<div class="flex items-center justify-end gap-4">
<a :href="backUrl"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-700">
Cancel
</a>
<button type="submit"
:disabled="saving"
class="flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50">
<span x-show="saving" x-html="$icon('spinner', 'w-4 h-4 mr-2 animate-spin')"></span>
<span x-text="saving ? 'Saving...' : 'Save Settings'"></span>
</button>
</div>
</form>
</div>
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('loyalty_static', path='admin/js/loyalty-company-settings.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,241 @@
{# app/modules/loyalty/templates/loyalty/admin/programs.html #}
{% extends "admin/base.html" %}
{% from 'shared/macros/pagination.html' import pagination %}
{% from 'shared/macros/headers.html' import 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 Programs{% endblock %}
{% block alpine_data %}adminLoyaltyPrograms(){% endblock %}
{% block content %}
{{ page_header('Loyalty Programs') }}
{{ loading_state('Loading loyalty programs...') }}
{{ error_state('Error loading loyalty programs') }}
<!-- Stats Cards -->
<div x-show="!loading" class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-4">
<!-- Card: Total Programs -->
<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('gift', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Total Programs
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.total_programs || 0">
0
</p>
</div>
</div>
<!-- Card: Active Programs -->
<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">
Active
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.active_programs || 0">
0
</p>
</div>
</div>
<!-- Card: Total Members -->
<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('users', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Total Members
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="formatNumber(stats.total_cards) || 0">
0
</p>
</div>
</div>
<!-- Card: Transactions (30d) -->
<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('chart-bar', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Transactions (30d)
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="formatNumber(stats.transactions_30d) || 0">
0
</p>
</div>
</div>
</div>
<!-- Search and Filters Bar -->
<div x-show="!loading" class="mb-6 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<!-- Search Input -->
<div class="flex-1 max-w-md">
<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="Search by company name..."
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>
<!-- Filters -->
<div class="flex flex-wrap gap-3">
<!-- Status Filter -->
<select
x-model="filters.is_active"
@change="pagination.page = 1; loadPrograms()"
class="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none"
>
<option value="">All Status</option>
<option value="true">Active</option>
<option value="false">Inactive</option>
</select>
<!-- Refresh Button -->
<button
@click="loadPrograms(); loadStats()"
class="flex items-center px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none transition-colors"
title="Refresh"
>
<span x-html="$icon('refresh', 'w-4 h-4 mr-2')"></span>
Refresh
</button>
</div>
</div>
</div>
<!-- Programs Table -->
<div x-show="!loading">
{% call table_wrapper() %}
{{ table_header(['Company', 'Program Type', 'Members', 'Points Issued', 'Status', 'Created', 'Actions']) }}
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<!-- Empty State -->
<template x-if="programs.length === 0">
<tr>
<td colspan="7" 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('gift', 'w-12 h-12 mb-2 text-gray-300')"></span>
<p class="font-medium">No loyalty programs found</p>
<p class="text-xs mt-1" x-text="filters.search || filters.is_active ? 'Try adjusting your search or filters' : 'No companies have set up loyalty programs yet'"></p>
</div>
</td>
</tr>
</template>
<!-- Program Rows -->
<template x-for="program in programs" :key="program.id">
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<!-- Company Info -->
<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">
<div class="absolute inset-0 rounded-full flex items-center justify-center"
:style="'background-color: ' + (program.card_color || '#4F46E5') + '20'">
<span class="text-xs font-semibold"
:style="'color: ' + (program.card_color || '#4F46E5')"
x-text="program.company_name?.charAt(0).toUpperCase() || '?'"></span>
</div>
</div>
<div>
<p class="font-semibold" x-text="program.company_name || 'Unknown Company'"></p>
<p class="text-xs text-gray-600 dark:text-gray-400" x-text="program.display_name || program.card_name || 'Loyalty Program'"></p>
</div>
</div>
</td>
<!-- Program Type -->
<td class="px-4 py-3 text-sm">
<span class="inline-flex items-center px-2 py-1 font-semibold leading-tight rounded-full"
:class="{
'text-purple-700 bg-purple-100 dark:bg-purple-700 dark:text-purple-100': program.loyalty_type === 'points',
'text-blue-700 bg-blue-100 dark:bg-blue-700 dark:text-blue-100': program.loyalty_type === 'stamps',
'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100': program.loyalty_type === 'hybrid'
}">
<span x-text="program.loyalty_type?.charAt(0).toUpperCase() + program.loyalty_type?.slice(1) || 'Unknown'"></span>
</span>
<p class="text-xs text-gray-500 mt-1" x-show="program.is_points_enabled">
<span x-text="program.points_per_euro"></span> pt/EUR
</p>
</td>
<!-- Members -->
<td class="px-4 py-3 text-sm">
<span class="font-semibold" x-text="formatNumber(program.total_cards) || 0"></span>
<span class="text-xs text-gray-500" x-show="program.active_cards">
(<span x-text="formatNumber(program.active_cards)"></span> active)
</span>
</td>
<!-- Points Issued -->
<td class="px-4 py-3 text-sm">
<span x-text="formatNumber(program.total_points_issued) || 0"></span>
<p class="text-xs text-gray-500" x-show="program.total_points_redeemed">
<span x-text="formatNumber(program.total_points_redeemed)"></span> redeemed
</p>
</td>
<!-- Status -->
<td class="px-4 py-3 text-xs">
<span class="inline-flex items-center px-2 py-1 font-semibold leading-tight rounded-full"
:class="program.is_active ? 'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100' : 'text-gray-700 bg-gray-100 dark:text-gray-100 dark:bg-gray-700'">
<span x-text="program.is_active ? 'Active' : 'Inactive'"></span>
</span>
</td>
<!-- Created Date -->
<td class="px-4 py-3 text-sm" x-text="formatDate(program.created_at)"></td>
<!-- Actions -->
<td class="px-4 py-3">
<div class="flex items-center space-x-2 text-sm">
<!-- View Button -->
<a
:href="'/admin/loyalty/companies/' + program.company_id"
class="flex items-center justify-center p-2 text-blue-600 rounded-lg hover:bg-blue-50 dark:text-blue-400 dark:hover:bg-gray-700 focus:outline-none transition-colors"
title="View company loyalty details"
>
<span x-html="$icon('eye', 'w-5 h-5')"></span>
</a>
<!-- Settings Button -->
<a
:href="'/admin/loyalty/companies/' + program.company_id + '/settings'"
class="flex items-center justify-center p-2 text-purple-600 rounded-lg hover:bg-purple-50 dark:text-purple-400 dark:hover:bg-gray-700 focus:outline-none transition-colors"
title="Company loyalty settings"
>
<span x-html="$icon('cog', 'w-5 h-5')"></span>
</a>
</div>
</td>
</tr>
</template>
</tbody>
{% endcall %}
{{ pagination() }}
</div>
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('loyalty_static', path='admin/js/loyalty-programs.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,226 @@
{# app/modules/loyalty/templates/loyalty/storefront/dashboard.html #}
{% extends "storefront/base.html" %}
{% block title %}My Loyalty - {{ vendor.name }}{% endblock %}
{% block alpine_data %}customerLoyaltyDashboard(){% endblock %}
{% block content %}
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Page Header -->
<div class="mb-8">
<a href="{{ base_url }}shop/account/dashboard" class="inline-flex items-center text-sm text-gray-600 dark:text-gray-400 hover:text-primary mb-4">
<span x-html="$icon('arrow-left', 'w-4 h-4 mr-2')"></span>
Back to Account
</a>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">My Loyalty</h1>
</div>
<!-- Loading State -->
<div x-show="loading" class="flex justify-center py-12">
<span x-html="$icon('spinner', 'w-8 h-8 animate-spin text-primary')" style="color: var(--color-primary)"></span>
</div>
<!-- No Card State -->
<div x-show="!loading && !card" class="text-center py-12">
<span x-html="$icon('gift', 'w-16 h-16 mx-auto text-gray-300 dark:text-gray-600')"></span>
<h2 class="mt-4 text-xl font-semibold text-gray-900 dark:text-white">Join Our Rewards Program!</h2>
<p class="mt-2 text-gray-600 dark:text-gray-400">Earn points on every purchase and redeem for rewards.</p>
<a href="{{ base_url }}shop/loyalty/join"
class="mt-6 inline-flex items-center px-6 py-3 text-sm font-medium text-white rounded-lg"
style="background-color: var(--color-primary)">
<span x-html="$icon('plus', 'w-5 h-5 mr-2')"></span>
Join Now
</a>
</div>
<!-- Loyalty Card Content -->
<div x-show="!loading && card">
<!-- Loyalty Card Display -->
<div class="mb-8 rounded-2xl overflow-hidden shadow-lg"
:style="'background: linear-gradient(135deg, ' + (program?.card_color || '#4F46E5') + ' 0%, ' + (program?.card_secondary_color || program?.card_color || '#4F46E5') + 'cc 100%)'">
<div class="p-6 text-white">
<div class="flex justify-between items-start mb-6">
<div>
<p class="text-sm opacity-80" x-text="program?.display_name || 'Loyalty Program'"></p>
<p class="text-lg font-semibold" x-text="card?.customer_name"></p>
</div>
<template x-if="program?.logo_url">
<img :src="program.logo_url" alt="Logo" class="h-10 w-auto">
</template>
</div>
<div class="text-center py-4">
<p class="text-sm opacity-80">Points Balance</p>
<p class="text-5xl font-bold" x-text="formatNumber(card?.points_balance || 0)"></p>
</div>
<div class="flex justify-between items-end mt-6">
<div>
<p class="text-xs opacity-70">Card Number</p>
<p class="font-mono" x-text="card?.card_number"></p>
</div>
<button @click="showBarcode = true"
class="px-4 py-2 bg-white/20 hover:bg-white/30 rounded-lg text-sm font-medium transition-colors">
<span x-html="$icon('qr-code', 'w-5 h-5 inline mr-1')"></span>
Show Card
</button>
</div>
</div>
</div>
<!-- Quick Stats -->
<div class="grid grid-cols-2 gap-4 mb-8">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border border-gray-200 dark:border-gray-700">
<p class="text-sm text-gray-600 dark:text-gray-400">Total Earned</p>
<p class="text-2xl font-bold text-gray-900 dark:text-white" x-text="formatNumber(card?.total_points_earned || 0)"></p>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border border-gray-200 dark:border-gray-700">
<p class="text-sm text-gray-600 dark:text-gray-400">Total Redeemed</p>
<p class="text-2xl font-bold text-gray-900 dark:text-white" x-text="formatNumber(card?.total_points_redeemed || 0)"></p>
</div>
</div>
<!-- Available Rewards -->
<div class="mb-8">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Available Rewards</h2>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<template x-if="rewards.length === 0">
<div class="col-span-full text-center py-8 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
<p class="text-gray-500 dark:text-gray-400">No rewards available yet</p>
</div>
</template>
<template x-for="reward in rewards" :key="reward.id">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-4">
<div class="flex items-center justify-between mb-3">
<span x-html="$icon('gift', 'w-6 h-6')" :style="'color: ' + (program?.card_color || 'var(--color-primary)')"></span>
<span class="text-sm font-semibold" :style="'color: ' + (program?.card_color || 'var(--color-primary)')"
x-text="reward.points_required + ' pts'"></span>
</div>
<h3 class="font-semibold text-gray-900 dark:text-white" x-text="reward.name"></h3>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1" x-text="reward.description || ''"></p>
<div class="mt-3">
<template x-if="(card?.points_balance || 0) >= reward.points_required">
<span class="inline-flex items-center text-sm font-medium text-green-600">
<span x-html="$icon('check-circle', 'w-4 h-4 mr-1')"></span>
Ready to redeem
</span>
</template>
<template x-if="(card?.points_balance || 0) < reward.points_required">
<span class="text-sm text-gray-500">
<span x-text="reward.points_required - (card?.points_balance || 0)"></span> more to go
</span>
</template>
</div>
</div>
</template>
</div>
<p class="mt-4 text-sm text-gray-500 dark:text-gray-400">
<span x-html="$icon('information-circle', 'w-4 h-4 inline mr-1')"></span>
Show your card to staff to redeem rewards in-store.
</p>
</div>
<!-- Recent Activity -->
<div>
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Recent Activity</h2>
<a href="{{ base_url }}shop/account/loyalty/history"
class="text-sm font-medium hover:underline" style="color: var(--color-primary)">
View All
</a>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 overflow-hidden">
<template x-if="transactions.length === 0">
<div class="p-8 text-center text-gray-500 dark:text-gray-400">
No transactions yet. Make a purchase to start earning points!
</div>
</template>
<template x-if="transactions.length > 0">
<div class="divide-y divide-gray-200 dark:divide-gray-700">
<template x-for="tx in transactions.slice(0, 5)" :key="tx.id">
<div class="flex items-center justify-between p-4">
<div class="flex items-center">
<div class="w-10 h-10 rounded-full flex items-center justify-center"
:class="tx.points_delta > 0 ? 'bg-green-100 dark:bg-green-900/30' : 'bg-orange-100 dark:bg-orange-900/30'">
<span x-html="$icon(tx.points_delta > 0 ? 'plus' : 'gift', 'w-5 h-5')"
:class="tx.points_delta > 0 ? 'text-green-600' : 'text-orange-600'"></span>
</div>
<div class="ml-4">
<p class="font-medium text-gray-900 dark:text-white"
x-text="tx.points_delta > 0 ? 'Points Earned' : 'Reward Redeemed'"></p>
<p class="text-sm text-gray-500 dark:text-gray-400" x-text="formatDate(tx.transaction_at)"></p>
</div>
</div>
<p class="font-semibold"
:class="tx.points_delta > 0 ? 'text-green-600' : 'text-orange-600'"
x-text="(tx.points_delta > 0 ? '+' : '') + formatNumber(tx.points_delta)"></p>
</div>
</template>
</div>
</template>
</div>
</div>
<!-- Locations -->
<div class="mt-8" x-show="locations.length > 0">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">
<span x-html="$icon('map-pin', 'w-5 h-5 inline mr-2')"></span>
Earn & Redeem Locations
</h2>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-4">
<ul class="space-y-2">
<template x-for="loc in locations" :key="loc.id">
<li class="flex items-center text-gray-700 dark:text-gray-300">
<span x-html="$icon('check', 'w-4 h-4 mr-2 text-green-500')"></span>
<span x-text="loc.name"></span>
</li>
</template>
</ul>
</div>
</div>
</div>
</div>
<!-- Barcode Modal -->
<div x-show="showBarcode" x-cloak
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
@click.self="showBarcode = false">
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-xl max-w-sm w-full p-6 text-center">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Your Loyalty Card</h3>
<!-- Barcode Placeholder -->
<div class="bg-white p-4 rounded-lg mb-4">
<div class="h-20 flex items-center justify-center border-2 border-gray-200 rounded">
<span class="font-mono text-2xl tracking-wider" x-text="card?.card_number"></span>
</div>
<p class="text-xs text-gray-500 mt-2" x-text="card?.card_number"></p>
</div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-6">
Show this to staff when making a purchase or redeeming rewards.
</p>
<!-- Wallet Buttons -->
<div class="space-y-2 mb-4">
<button class="w-full flex items-center justify-center px-4 py-3 bg-black text-white rounded-lg hover:bg-gray-800 transition-colors">
<span x-html="$icon('device-mobile', 'w-5 h-5 mr-2')"></span>
Add to Apple Wallet
</button>
<button class="w-full flex items-center justify-center px-4 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
<span x-html="$icon('device-mobile', 'w-5 h-5 mr-2')"></span>
Add to Google Wallet
</button>
</div>
<button @click="showBarcode = false"
class="w-full px-4 py-2 text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200">
Close
</button>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('loyalty_static', path='storefront/js/loyalty-dashboard.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,87 @@
{# app/modules/loyalty/templates/loyalty/storefront/enroll-success.html #}
{% extends "storefront/base.html" %}
{% block title %}Welcome to Rewards! - {{ vendor.name }}{% endblock %}
{% block alpine_data %}customerLoyaltyEnrollSuccess(){% endblock %}
{% block content %}
<div class="min-h-screen flex items-center justify-center px-4 py-12 bg-gray-50 dark:bg-gray-900">
<div class="max-w-md w-full text-center">
<!-- Success Icon -->
<div class="w-20 h-20 mx-auto mb-6 rounded-full flex items-center justify-center"
style="background-color: var(--color-primary); opacity: 0.1">
<div class="w-20 h-20 rounded-full flex items-center justify-center" style="background-color: var(--color-primary)">
<span x-html="$icon('check', 'w-10 h-10 text-white')"></span>
</div>
</div>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-2">Welcome!</h1>
<p class="text-gray-600 dark:text-gray-400 mb-8">You're now a member of our rewards program.</p>
<!-- Card Number Display -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 mb-8">
<p class="text-sm text-gray-500 dark:text-gray-400 mb-2">Your Card Number</p>
<p class="text-2xl font-mono font-bold text-gray-900 dark:text-white">{{ enrolled_card_number or 'Loading...' }}</p>
<div class="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700">
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
Save your card to your phone for easy access:
</p>
<div class="space-y-2">
<button class="w-full flex items-center justify-center px-4 py-3 bg-black text-white rounded-lg hover:bg-gray-800 transition-colors">
<span x-html="$icon('device-mobile', 'w-5 h-5 mr-2')"></span>
Add to Apple Wallet
</button>
<button class="w-full flex items-center justify-center px-4 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
<span x-html="$icon('device-mobile', 'w-5 h-5 mr-2')"></span>
Add to Google Wallet
</button>
</div>
</div>
</div>
<!-- Next Steps -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6 text-left mb-8">
<h2 class="font-semibold text-gray-900 dark:text-white mb-4">What's Next?</h2>
<ul class="space-y-3 text-sm text-gray-600 dark:text-gray-400">
<li class="flex items-start">
<span x-html="$icon('check-circle', 'w-5 h-5 mr-2 text-green-500 flex-shrink-0')"></span>
<span>Show your card number when making purchases to earn points</span>
</li>
<li class="flex items-start">
<span x-html="$icon('check-circle', 'w-5 h-5 mr-2 text-green-500 flex-shrink-0')"></span>
<span>Check your balance online or in the app</span>
</li>
<li class="flex items-start">
<span x-html="$icon('check-circle', 'w-5 h-5 mr-2 text-green-500 flex-shrink-0')"></span>
<span>Redeem points for rewards at any of our locations</span>
</li>
</ul>
</div>
<!-- Actions -->
<div class="space-y-3">
<a href="{{ base_url }}shop/account/loyalty"
class="block w-full py-3 px-4 text-white font-semibold rounded-lg transition-colors text-center"
style="background-color: var(--color-primary)">
View My Loyalty Dashboard
</a>
<a href="{{ base_url }}shop"
class="block w-full py-3 px-4 text-gray-700 dark:text-gray-300 font-medium rounded-lg border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors text-center">
Continue Shopping
</a>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
function customerLoyaltyEnrollSuccess() {
return {
...data()
};
}
</script>
{% endblock %}

View File

@@ -0,0 +1,135 @@
{# app/modules/loyalty/templates/loyalty/storefront/enroll.html #}
{% extends "storefront/base.html" %}
{% block title %}Join Loyalty Program - {{ vendor.name }}{% endblock %}
{% block alpine_data %}customerLoyaltyEnroll(){% endblock %}
{% block content %}
<div class="min-h-screen flex items-center justify-center px-4 py-12 bg-gray-50 dark:bg-gray-900">
<div class="max-w-md w-full">
<!-- Logo/Brand -->
<div class="text-center mb-8">
{% if vendor.logo_url %}
<img src="{{ vendor.logo_url }}" alt="{{ vendor.name }}" class="h-16 w-auto mx-auto mb-4">
{% endif %}
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Join Our Rewards Program!</h1>
<p class="mt-2 text-gray-600 dark:text-gray-400" x-text="'Earn ' + (program?.points_per_euro || 1) + ' point for every EUR you spend'"></p>
</div>
<!-- Loading -->
<div x-show="loading" class="text-center py-8">
<span x-html="$icon('spinner', 'w-8 h-8 animate-spin mx-auto')" style="color: var(--color-primary)"></span>
</div>
<!-- No Program Available -->
<div x-show="!loading && !program" class="bg-white dark:bg-gray-800 rounded-lg shadow p-8 text-center">
<span x-html="$icon('exclamation-circle', 'w-12 h-12 mx-auto text-yellow-500')"></span>
<h2 class="mt-4 text-xl font-semibold text-gray-900 dark:text-white">Program Not Available</h2>
<p class="mt-2 text-gray-600 dark:text-gray-400">This store doesn't have a loyalty program set up yet.</p>
</div>
<!-- Enrollment Form -->
<div x-show="!loading && program && !enrolled" class="bg-white dark:bg-gray-800 rounded-lg shadow-lg overflow-hidden">
<!-- Welcome Bonus Banner -->
<div x-show="program?.welcome_bonus_points > 0"
class="p-4 text-center text-white"
:style="'background-color: ' + (program?.card_color || 'var(--color-primary)')">
<span x-html="$icon('gift', 'w-6 h-6 inline mr-2')"></span>
<span class="font-semibold">Get <span x-text="program?.welcome_bonus_points"></span> bonus points when you join!</span>
</div>
<form @submit.prevent="submitEnrollment" class="p-6 space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Email <span class="text-red-500">*</span>
</label>
<input type="email" x-model="form.email" required
class="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white"
style="--tw-ring-color: var(--color-primary)"
placeholder="your@email.com">
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
First Name <span class="text-red-500">*</span>
</label>
<input type="text" x-model="form.first_name" required
class="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white"
placeholder="John">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Last Name
</label>
<input type="text" x-model="form.last_name"
class="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white"
placeholder="Doe">
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Phone (optional)
</label>
<input type="tel" x-model="form.phone"
class="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white"
placeholder="+352 123 456 789">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Birthday (optional)
</label>
<input type="date" x-model="form.birthday"
class="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
<p class="mt-1 text-xs text-gray-500">For special birthday rewards</p>
</div>
<div class="space-y-3 pt-2">
<label class="flex items-start">
<input type="checkbox" x-model="form.terms_accepted" required
class="mt-1 w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
style="color: var(--color-primary)">
<span class="ml-2 text-sm text-gray-600 dark:text-gray-400">
I agree to the <a href="#" class="underline" style="color: var(--color-primary)">Terms & Conditions</a>
</span>
</label>
<label class="flex items-start">
<input type="checkbox" x-model="form.marketing_consent"
class="mt-1 w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
style="color: var(--color-primary)">
<span class="ml-2 text-sm text-gray-600 dark:text-gray-400">
Send me news and special offers
</span>
</label>
</div>
<button type="submit"
:disabled="enrolling || !form.email || !form.first_name || !form.terms_accepted"
class="w-full py-3 px-4 text-white font-semibold rounded-lg transition-colors disabled:opacity-50"
:style="'background-color: ' + (program?.card_color || 'var(--color-primary)')">
<span x-show="enrolling" x-html="$icon('spinner', 'w-5 h-5 inline animate-spin mr-2')"></span>
<span x-text="enrolling ? 'Joining...' : 'Join & Get ' + (program?.welcome_bonus_points || 0) + ' Points'"></span>
</button>
</form>
<div class="px-6 pb-6 text-center">
<p class="text-xs text-gray-500 dark:text-gray-400">
Already a member? Your points are linked to your email.
</p>
</div>
</div>
<!-- Error Message -->
<div x-show="error" class="mt-4 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<p class="text-sm text-red-600 dark:text-red-400" x-text="error"></p>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('loyalty_static', path='storefront/js/loyalty-enroll.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,107 @@
{# app/modules/loyalty/templates/loyalty/storefront/history.html #}
{% extends "storefront/base.html" %}
{% block title %}Loyalty History - {{ vendor.name }}{% endblock %}
{% block alpine_data %}customerLoyaltyHistory(){% endblock %}
{% block content %}
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Page Header -->
<div class="mb-8">
<a href="{{ base_url }}storefront/account/loyalty" class="inline-flex items-center text-sm text-gray-600 dark:text-gray-400 hover:text-primary mb-4">
<span x-html="$icon('arrow-left', 'w-4 h-4 mr-2')"></span>
Back to Loyalty
</a>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Transaction History</h1>
<p class="mt-2 text-gray-600 dark:text-gray-400">View all your loyalty point transactions</p>
</div>
<!-- Loading State -->
<div x-show="loading" class="flex justify-center py-12">
<span x-html="$icon('spinner', 'w-8 h-8 animate-spin')" style="color: var(--color-primary)"></span>
</div>
<!-- Summary Card -->
<div x-show="!loading && card" class="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-8 border border-gray-200 dark:border-gray-700">
<div class="grid grid-cols-3 gap-4 text-center">
<div>
<p class="text-sm text-gray-500 dark:text-gray-400">Current Balance</p>
<p class="text-2xl font-bold" style="color: var(--color-primary)" x-text="formatNumber(card?.points_balance || 0)"></p>
</div>
<div>
<p class="text-sm text-gray-500 dark:text-gray-400">Total Earned</p>
<p class="text-2xl font-bold text-green-600" x-text="formatNumber(card?.total_points_earned || 0)"></p>
</div>
<div>
<p class="text-sm text-gray-500 dark:text-gray-400">Total Redeemed</p>
<p class="text-2xl font-bold text-orange-600" x-text="formatNumber(card?.total_points_redeemed || 0)"></p>
</div>
</div>
</div>
<!-- Transactions List -->
<div x-show="!loading" class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 overflow-hidden">
<template x-if="transactions.length === 0">
<div class="p-12 text-center">
<span x-html="$icon('receipt-refund', 'w-12 h-12 mx-auto text-gray-300 dark:text-gray-600')"></span>
<p class="mt-4 text-gray-500 dark:text-gray-400">No transactions yet</p>
</div>
</template>
<template x-if="transactions.length > 0">
<div class="divide-y divide-gray-200 dark:divide-gray-700">
<template x-for="tx in transactions" :key="tx.id">
<div class="flex items-center justify-between p-4 hover:bg-gray-50 dark:hover:bg-gray-700/50">
<div class="flex items-center">
<div class="w-12 h-12 rounded-full flex items-center justify-center"
:class="tx.points_delta > 0 ? 'bg-green-100 dark:bg-green-900/30' : 'bg-orange-100 dark:bg-orange-900/30'">
<span x-html="$icon(tx.points_delta > 0 ? 'plus' : 'gift', 'w-6 h-6')"
:class="tx.points_delta > 0 ? 'text-green-600' : 'text-orange-600'"></span>
</div>
<div class="ml-4">
<p class="font-medium text-gray-900 dark:text-white"
x-text="getTransactionLabel(tx)"></p>
<p class="text-sm text-gray-500 dark:text-gray-400">
<span x-text="formatDateTime(tx.transaction_at)"></span>
<span x-show="tx.vendor_name" class="ml-2">
at <span x-text="tx.vendor_name"></span>
</span>
</p>
<p x-show="tx.notes" class="text-xs text-gray-400 mt-1" x-text="tx.notes"></p>
</div>
</div>
<div class="text-right">
<p class="text-lg font-semibold"
:class="tx.points_delta > 0 ? 'text-green-600' : 'text-orange-600'"
x-text="(tx.points_delta > 0 ? '+' : '') + formatNumber(tx.points_delta)"></p>
<p class="text-xs text-gray-500 dark:text-gray-400">
Balance: <span x-text="formatNumber(tx.balance_after)"></span>
</p>
</div>
</div>
</template>
</div>
</template>
<!-- Pagination -->
<div x-show="pagination.pages > 1" class="px-4 py-3 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between">
<button @click="previousPage()" :disabled="pagination.page <= 1"
class="px-3 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50">
Previous
</button>
<span class="text-sm text-gray-500 dark:text-gray-400">
Page <span x-text="pagination.page"></span> of <span x-text="pagination.pages"></span>
</span>
<button @click="nextPage()" :disabled="pagination.page >= pagination.pages"
class="px-3 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50">
Next
</button>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('loyalty_static', path='storefront/js/loyalty-history.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,158 @@
{# app/modules/loyalty/templates/loyalty/vendor/card-detail.html #}
{% extends "vendor/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 %}Member Details{% endblock %}
{% block alpine_data %}vendorLoyaltyCardDetail(){% endblock %}
{% block content %}
{% call detail_page_header("card?.customer_name || 'Member Details'", '/vendor/' + vendor_code + '/loyalty/cards', subtitle_show='card') %}
Card: <span x-text="card?.card_number"></span>
{% endcall %}
{{ loading_state('Loading member details...') }}
{{ error_state('Error loading member') }}
<div x-show="!loading && card">
<!-- 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">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">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">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">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>
<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>
Customer Information
</h3>
<div class="space-y-3">
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">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">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">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">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>
Card Details
</h3>
<div class="space-y-3">
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">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">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 ? 'Active' : 'Inactive'"></span>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">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">Enrolled At</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="card?.enrolled_at_vendor_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>
Transaction History
</h3>
{% call table_wrapper() %}
{{ table_header(['Date', 'Type', 'Points', 'Location', '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">
No transactions yet
</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.vendor_name || '-'"></td>
<td class="px-4 py-3 text-sm text-gray-500" x-text="tx.notes || '-'"></td>
</tr>
</template>
</tbody>
{% endcall %}
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('loyalty_static', path='vendor/js/loyalty-card-detail.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,150 @@
{# app/modules/loyalty/templates/loyalty/vendor/cards.html #}
{% extends "vendor/base.html" %}
{% 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 %}
{% block title %}Loyalty Members{% endblock %}
{% block alpine_data %}vendorLoyaltyCards(){% endblock %}
{% block content %}
<!-- Page Header -->
{% call page_header_flex(title='Loyalty Members', subtitle='View and manage your loyalty program members') %}
<div class="flex items-center gap-3">
{{ refresh_button(loading_var='loading', onclick='loadCards()', variant='secondary') }}
<a href="/vendor/{{ vendor_code }}/loyalty/enroll"
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('user-plus', 'w-4 h-4 mr-2')"></span>
Enroll New
</a>
</div>
{% endcall %}
{{ loading_state('Loading members...') }}
{{ error_state('Error loading members') }}
<!-- Stats Cards -->
<div x-show="!loading" 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">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">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">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">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" 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="Search by name, email, phone, or card..."
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="">All Status</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
</div>
</div>
<!-- Cards Table -->
<div x-show="!loading">
{% call table_wrapper() %}
{{ table_header(['Member', 'Card Number', 'Points Balance', 'Last Activity', 'Status', '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">No members found</p>
<p class="text-xs mt-1" x-text="filters.search ? 'Try adjusting your search' : 'Enroll your first customer to get started'"></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 ? 'Active' : 'Inactive'"></span>
</td>
<td class="px-4 py-3">
<a :href="'/vendor/{{ vendor_code }}/loyalty/cards/' + card.id"
class="text-purple-600 hover:text-purple-700 dark:text-purple-400">
View
</a>
</td>
</tr>
</template>
</tbody>
{% endcall %}
{{ pagination() }}
</div>
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('loyalty_static', path='vendor/js/loyalty-cards.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,146 @@
{# app/modules/loyalty/templates/loyalty/vendor/enroll.html #}
{% extends "vendor/base.html" %}
{% from 'shared/macros/headers.html' import detail_page_header %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% block title %}Enroll Customer{% endblock %}
{% block alpine_data %}vendorLoyaltyEnroll(){% endblock %}
{% block content %}
{% call detail_page_header("'Enroll New Customer'", '/vendor/' + vendor_code + '/loyalty/terminal') %}
Add a new member to your loyalty program
{% endcall %}
{{ loading_state('Loading...') }}
{{ error_state('Error loading enrollment form') }}
<div x-show="!loading" class="max-w-2xl">
<form @submit.prevent="enrollCustomer">
<!-- Customer Information -->
<div class="px-4 py-5 mb-6 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>
Customer Information
</h3>
<div class="space-y-4">
<div class="grid gap-4 md:grid-cols-2">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
First Name <span class="text-red-500">*</span>
</label>
<input type="text" x-model="form.first_name" required
class="w-full 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">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Last Name</label>
<input type="text" x-model="form.last_name"
class="w-full 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">
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Email <span class="text-red-500">*</span>
</label>
<input type="email" x-model="form.email" required
class="w-full 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">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Phone</label>
<input type="tel" x-model="form.phone"
class="w-full 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">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Birthday</label>
<input type="date" x-model="form.birthday"
class="w-full 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">
<p class="mt-1 text-xs text-gray-500">For birthday rewards (optional)</p>
</div>
</div>
</div>
<!-- Marketing Consent -->
<div class="px-4 py-5 mb-6 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('bell', 'inline w-5 h-5 mr-2')"></span>
Communication Preferences
</h3>
<div class="space-y-3">
<label class="flex items-center">
<input type="checkbox" x-model="form.marketing_email"
class="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500">
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Send promotional emails</span>
</label>
<label class="flex items-center">
<input type="checkbox" x-model="form.marketing_sms"
class="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500">
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Send promotional SMS</span>
</label>
</div>
</div>
<!-- Welcome Bonus Info -->
<div x-show="program?.welcome_bonus_points > 0" class="px-4 py-4 mb-6 bg-green-50 border border-green-200 rounded-lg dark:bg-green-900/20 dark:border-green-800">
<div class="flex items-center">
<span x-html="$icon('gift', 'w-5 h-5 text-green-500 mr-3')"></span>
<div>
<p class="text-sm font-medium text-green-800 dark:text-green-200">Welcome Bonus</p>
<p class="text-sm text-green-700 dark:text-green-300">
Customer will receive <span class="font-bold" x-text="program?.welcome_bonus_points"></span> bonus points!
</p>
</div>
</div>
</div>
<!-- Actions -->
<div class="flex items-center gap-4">
<a href="/vendor/{{ vendor_code }}/loyalty/terminal"
class="px-6 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600">
Cancel
</a>
<button type="submit" :disabled="enrolling || !form.first_name || !form.email"
class="flex items-center px-6 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50">
<span x-show="enrolling" x-html="$icon('spinner', 'w-4 h-4 mr-2 animate-spin')"></span>
<span x-text="enrolling ? 'Enrolling...' : 'Enroll Customer'"></span>
</button>
</div>
</form>
<!-- Success Modal -->
<div x-show="enrolledCard" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full mx-4 p-6">
<div class="text-center">
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-green-100 flex items-center justify-center">
<span x-html="$icon('check', 'w-8 h-8 text-green-500')"></span>
</div>
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200 mb-2">Customer Enrolled!</h3>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">
Card Number: <span class="font-mono font-semibold" x-text="enrolledCard?.card_number"></span>
</p>
<p class="text-sm text-gray-600 dark:text-gray-300 mb-6">
Starting Balance: <span class="font-bold text-purple-600" x-text="enrolledCard?.points_balance"></span> points
</p>
<div class="flex gap-3 justify-center">
<a href="/vendor/{{ vendor_code }}/loyalty/terminal"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300">
Back to Terminal
</a>
<button @click="enrolledCard = null; resetForm()"
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700">
Enroll Another
</button>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('loyalty_static', path='vendor/js/loyalty-enroll.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,158 @@
{# app/modules/loyalty/templates/loyalty/vendor/settings.html #}
{% extends "vendor/base.html" %}
{% from 'shared/macros/headers.html' import page_header_flex %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% block title %}Loyalty Settings{% endblock %}
{% block alpine_data %}vendorLoyaltySettings(){% endblock %}
{% block content %}
{% call page_header_flex(title='Loyalty Program Settings', subtitle='Configure your loyalty program') %}{% endcall %}
{{ loading_state('Loading settings...') }}
{{ error_state('Error loading settings') }}
<div x-show="!loading">
<form @submit.prevent="saveSettings">
<!-- Points Configuration -->
<div class="px-4 py-5 mb-6 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('currency-dollar', 'inline w-5 h-5 mr-2')"></span>
Points Configuration
</h3>
<div class="grid gap-6 md:grid-cols-2">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Points per EUR spent</label>
<input type="number" min="1" max="100" x-model.number="settings.points_per_euro"
class="w-full 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">
<p class="mt-1 text-xs text-gray-500">1 EUR = <span x-text="settings.points_per_euro || 1"></span> point(s)</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Welcome Bonus Points</label>
<input type="number" min="0" x-model.number="settings.welcome_bonus_points"
class="w-full 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">
<p class="mt-1 text-xs text-gray-500">Bonus points awarded on enrollment</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Minimum Redemption Points</label>
<input type="number" min="1" x-model.number="settings.minimum_redemption_points"
class="w-full 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">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Points Expiration (days)</label>
<input type="number" min="0" x-model.number="settings.points_expiration_days"
placeholder="0 = never expire"
class="w-full 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">
<p class="mt-1 text-xs text-gray-500">Days of inactivity before points expire (0 = never)</p>
</div>
</div>
</div>
<!-- Rewards Configuration -->
<div class="px-4 py-5 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">
<span x-html="$icon('gift', 'inline w-5 h-5 mr-2')"></span>
Redemption Rewards
</h3>
<button type="button" @click="addReward()"
class="flex items-center px-3 py-1 text-sm text-purple-600 hover:text-purple-700">
<span x-html="$icon('plus', 'w-4 h-4 mr-1')"></span>
Add Reward
</button>
</div>
<div class="space-y-4">
<template x-if="settings.points_rewards.length === 0">
<p class="text-gray-500 dark:text-gray-400 text-sm">No rewards configured. Add a reward to allow customers to redeem points.</p>
</template>
<template x-for="(reward, index) in settings.points_rewards" :key="index">
<div class="flex items-start gap-4 p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
<div class="flex-1 grid gap-4 md:grid-cols-3">
<div>
<label class="block text-xs text-gray-500 mb-1">Reward Name</label>
<input type="text" x-model="reward.name" placeholder="e.g., EUR5 off"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 dark:text-gray-300">
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Points Required</label>
<input type="number" min="1" x-model.number="reward.points_required"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 dark:text-gray-300">
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Description</label>
<input type="text" x-model="reward.description" placeholder="Optional description"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 dark:text-gray-300">
</div>
</div>
<button type="button" @click="removeReward(index)"
class="text-red-500 hover:text-red-700 p-2">
<span x-html="$icon('trash', 'w-5 h-5')"></span>
</button>
</div>
</template>
</div>
</div>
<!-- Branding -->
<div class="px-4 py-5 mb-6 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('paint-brush', 'inline w-5 h-5 mr-2')"></span>
Branding
</h3>
<div class="grid gap-6 md:grid-cols-2">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Card Name</label>
<input type="text" x-model="settings.card_name" placeholder="e.g., VIP Rewards"
class="w-full 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">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Primary Color</label>
<div class="flex items-center gap-3">
<input type="color" x-model="settings.card_color"
class="w-12 h-10 rounded cursor-pointer">
<input type="text" x-model="settings.card_color" pattern="^#[0-9A-Fa-f]{6}$"
class="flex-1 px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 dark:text-gray-300">
</div>
</div>
</div>
</div>
<!-- Status -->
<div class="px-4 py-5 mb-6 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('power', 'inline w-5 h-5 mr-2')"></span>
Program Status
</h3>
<label class="flex items-center justify-between p-4 border border-gray-200 dark:border-gray-600 rounded-lg">
<div>
<p class="font-medium text-gray-700 dark:text-gray-300">Program Active</p>
<p class="text-sm text-gray-500">When disabled, customers cannot earn or redeem points</p>
</div>
<div class="relative">
<input type="checkbox" x-model="settings.is_active" class="sr-only peer">
<div @click="settings.is_active = !settings.is_active"
class="w-11 h-6 bg-gray-200 rounded-full cursor-pointer peer-checked:bg-purple-600 dark:bg-gray-700"
:class="settings.is_active ? 'bg-purple-600' : ''">
<div class="absolute top-[2px] left-[2px] bg-white w-5 h-5 rounded-full transition-transform"
:class="settings.is_active ? 'translate-x-5' : ''"></div>
</div>
</div>
</label>
</div>
<!-- Actions -->
<div class="flex items-center justify-end gap-4">
<button type="submit" :disabled="saving"
class="flex items-center px-6 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50">
<span x-show="saving" x-html="$icon('spinner', 'w-4 h-4 mr-2 animate-spin')"></span>
<span x-text="saving ? 'Saving...' : 'Save Settings'"></span>
</button>
</div>
</form>
</div>
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('loyalty_static', path='vendor/js/loyalty-settings.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,134 @@
{# app/modules/loyalty/templates/loyalty/vendor/stats.html #}
{% extends "vendor/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 Stats{% endblock %}
{% block alpine_data %}vendorLoyaltyStats(){% endblock %}
{% block content %}
{% call page_header_flex(title='Loyalty Statistics', subtitle='Track your loyalty program performance') %}
<div class="flex items-center gap-3">
{{ refresh_button(loading_var='loading', onclick='loadStats()', variant='secondary') }}
</div>
{% endcall %}
{{ loading_state('Loading statistics...') }}
{{ error_state('Error loading statistics') }}
<div x-show="!loading">
<!-- Summary Stats -->
<div class="grid gap-6 mb-8 md:grid-cols-2 xl: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">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('trending-up', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Points Issued (30d)</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="formatNumber(stats.points_issued_30d)">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">Points Redeemed (30d)</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="formatNumber(stats.points_redeemed_30d)">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('chart-bar', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Transactions (30d)</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="formatNumber(stats.transactions_30d)">0</p>
</div>
</div>
</div>
<!-- Detailed Metrics -->
<div class="grid gap-6 mb-8 md:grid-cols-2">
<!-- Points Overview -->
<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('currency-dollar', 'inline w-5 h-5 mr-2')"></span>
Points Overview
</h3>
<div class="space-y-4">
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Total Points Issued (All Time)</span>
<span class="font-semibold" x-text="formatNumber(stats.total_points_issued)">0</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Total Points Redeemed</span>
<span class="font-semibold" x-text="formatNumber(stats.total_points_redeemed)">0</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Outstanding Balance</span>
<span class="font-semibold text-purple-600" x-text="formatNumber(stats.total_points_balance)">0</span>
</div>
</div>
</div>
<!-- Member Activity -->
<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('users', 'inline w-5 h-5 mr-2')"></span>
Member Activity
</h3>
<div class="space-y-4">
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Active Members (30d)</span>
<span class="font-semibold" x-text="formatNumber(stats.active_cards)">0</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">New This Month</span>
<span class="font-semibold" x-text="formatNumber(stats.new_this_month)">0</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Avg Points Per Member</span>
<span class="font-semibold" x-text="formatNumber(stats.avg_points_per_member)">0</span>
</div>
</div>
</div>
</div>
<!-- Quick Links -->
<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">Quick Actions</h3>
<div class="flex flex-wrap gap-3">
<a href="/vendor/{{ vendor_code }}/loyalty/terminal"
class="flex items-center px-4 py-2 text-sm font-medium text-purple-600 bg-purple-50 rounded-lg hover:bg-purple-100 dark:bg-purple-900/20 dark:text-purple-400">
<span x-html="$icon('device-tablet', 'w-4 h-4 mr-2')"></span>
Open Terminal
</a>
<a href="/vendor/{{ vendor_code }}/loyalty/cards"
class="flex items-center px-4 py-2 text-sm font-medium text-blue-600 bg-blue-50 rounded-lg hover:bg-blue-100 dark:bg-blue-900/20 dark:text-blue-400">
<span x-html="$icon('users', 'w-4 h-4 mr-2')"></span>
View Members
</a>
<a href="/vendor/{{ vendor_code }}/loyalty/settings"
class="flex items-center px-4 py-2 text-sm font-medium text-gray-600 bg-gray-50 rounded-lg hover:bg-gray-100 dark:bg-gray-700 dark:text-gray-400">
<span x-html="$icon('cog', 'w-4 h-4 mr-2')"></span>
Settings
</a>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('loyalty_static', path='vendor/js/loyalty-stats.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,309 @@
{# app/modules/loyalty/templates/loyalty/vendor/terminal.html #}
{% extends "vendor/base.html" %}
{% from 'shared/macros/headers.html' import page_header_flex %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% from 'shared/macros/modals.html' import modal_simple %}
{% block title %}Loyalty Terminal{% endblock %}
{% block alpine_data %}vendorLoyaltyTerminal(){% endblock %}
{% block content %}
<!-- Page Header -->
{% call page_header_flex(title='Loyalty Terminal', subtitle='Process loyalty transactions') %}
<div class="flex items-center gap-3">
<a href="/vendor/{{ vendor_code }}/loyalty/cards"
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:bg-gray-800 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-700">
<span x-html="$icon('users', 'w-4 h-4 mr-2')"></span>
Members
</a>
<a href="/vendor/{{ vendor_code }}/loyalty/stats"
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:bg-gray-800 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-700">
<span x-html="$icon('chart-bar', 'w-4 h-4 mr-2')"></span>
Stats
</a>
</div>
{% endcall %}
{{ loading_state('Loading loyalty terminal...') }}
{{ error_state('Error loading terminal') }}
<!-- No Program Setup Notice -->
<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 Program Not Set Up</h3>
<p class="mt-1 text-sm text-yellow-700 dark:text-yellow-300">Your company doesn't have a loyalty program configured yet.</p>
<a href="/vendor/{{ vendor_code }}/loyalty/settings"
class="mt-3 inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-yellow-600 rounded-lg hover:bg-yellow-700">
<span x-html="$icon('cog', 'w-4 h-4 mr-2')"></span>
Set Up Loyalty Program
</a>
</div>
</div>
</div>
<!-- Main Terminal -->
<div x-show="!loading && program">
<div class="grid gap-6 lg:grid-cols-2">
<!-- Left: Customer Lookup -->
<div class="bg-white rounded-lg shadow-md dark:bg-gray-800">
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">
<span x-html="$icon('search', 'inline w-5 h-5 mr-2')"></span>
Find Customer
</h3>
</div>
<div class="p-4">
<!-- Search Input -->
<div class="relative mb-4">
<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="searchQuery"
@keyup.enter="lookupCustomer()"
placeholder="Email, phone, or card number..."
class="w-full pl-10 pr-4 py-3 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>
<button
@click="lookupCustomer()"
:disabled="!searchQuery || lookingUp"
class="w-full flex items-center justify-center px-4 py-3 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 focus:outline-none disabled:opacity-50"
>
<span x-show="lookingUp" x-html="$icon('spinner', 'w-5 h-5 mr-2 animate-spin')"></span>
<span x-text="lookingUp ? 'Looking up...' : 'Look Up Customer'"></span>
</button>
<!-- Divider -->
<div class="relative my-6">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-gray-200 dark:border-gray-700"></div>
</div>
<div class="relative flex justify-center text-sm">
<span class="px-3 bg-white text-gray-500 dark:bg-gray-800 dark:text-gray-400">or</span>
</div>
</div>
<!-- Enroll New Customer -->
<a href="/vendor/{{ vendor_code }}/loyalty/enroll"
class="w-full flex items-center justify-center px-4 py-3 text-sm font-medium text-purple-600 bg-purple-50 border border-purple-200 rounded-lg hover:bg-purple-100 dark:bg-purple-900/20 dark:text-purple-400 dark:border-purple-800">
<span x-html="$icon('user-plus', 'w-5 h-5 mr-2')"></span>
Enroll New Customer
</a>
</div>
</div>
<!-- Right: Customer Card (shown when found) -->
<div x-show="selectedCard" class="bg-white rounded-lg shadow-md dark:bg-gray-800">
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">
<span x-html="$icon('user', 'inline w-5 h-5 mr-2')"></span>
Customer Found
</h3>
</div>
<div class="p-4">
<!-- Customer Info -->
<div class="flex items-start mb-4">
<div class="flex-shrink-0 w-12 h-12 rounded-full flex items-center justify-center"
:style="'background-color: ' + (program?.card_color || '#4F46E5') + '20'">
<span class="text-lg font-semibold"
:style="'color: ' + (program?.card_color || '#4F46E5')"
x-text="selectedCard?.customer_name?.charAt(0).toUpperCase() || '?'"></span>
</div>
<div class="ml-4 flex-1">
<p class="font-semibold text-gray-700 dark:text-gray-200" x-text="selectedCard?.customer_name || 'Unknown'"></p>
<p class="text-sm text-gray-500 dark:text-gray-400" x-text="selectedCard?.customer_email"></p>
<p class="text-xs text-gray-400 dark:text-gray-500" x-text="'Card: ' + selectedCard?.card_number"></p>
</div>
<button @click="clearCustomer()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<span x-html="$icon('x', 'w-5 h-5')"></span>
</button>
</div>
<!-- Points Balance -->
<div class="mb-6 p-4 rounded-lg text-center"
:style="'background-color: ' + (program?.card_color || '#4F46E5') + '10'">
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Points Balance</p>
<p class="text-3xl font-bold"
:style="'color: ' + (program?.card_color || '#4F46E5')"
x-text="formatNumber(selectedCard?.points_balance || 0)"></p>
</div>
<!-- Action Buttons -->
<div class="grid gap-4 md:grid-cols-2">
<!-- Earn Points -->
<div class="p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
<h4 class="font-medium text-gray-700 dark:text-gray-300 mb-3">
<span x-html="$icon('plus-circle', 'inline w-4 h-4 mr-1 text-green-500')"></span>
Earn Points
</h4>
<div class="mb-3">
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Purchase Amount</label>
<div class="relative">
<span class="absolute inset-y-0 left-0 flex items-center pl-3 text-gray-500">EUR</span>
<input type="number" step="0.01" min="0"
x-model.number="earnAmount"
class="w-full pl-12 pr-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-green-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
</div>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
Points to award: <span class="font-semibold text-green-600" x-text="Math.floor((earnAmount || 0) * (program?.points_per_euro || 1))"></span>
</p>
<button @click="showPinModal('earn')"
:disabled="!earnAmount || earnAmount <= 0"
class="w-full px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700 disabled:opacity-50">
Award Points
</button>
</div>
<!-- Redeem Points -->
<div class="p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
<h4 class="font-medium text-gray-700 dark:text-gray-300 mb-3">
<span x-html="$icon('gift', 'inline w-4 h-4 mr-1 text-orange-500')"></span>
Redeem Reward
</h4>
<div class="mb-3">
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Select Reward</label>
<select x-model="selectedReward"
class="w-full px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-orange-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
<option value="">Select reward...</option>
<template x-for="reward in availableRewards" :key="reward.id">
<option :value="reward.id" :disabled="(selectedCard?.points_balance || 0) < reward.points_required"
x-text="reward.name + ' (' + reward.points_required + ' pts)'"></option>
</template>
</select>
</div>
<template x-if="selectedReward">
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
Points after: <span class="font-semibold text-orange-600" x-text="formatNumber((selectedCard?.points_balance || 0) - (getSelectedRewardPoints() || 0))"></span>
</p>
</template>
<button @click="showPinModal('redeem')"
:disabled="!selectedReward"
class="w-full px-4 py-2 text-sm font-medium text-white bg-orange-600 rounded-lg hover:bg-orange-700 disabled:opacity-50">
Redeem Reward
</button>
</div>
</div>
</div>
</div>
<!-- Empty State (when no customer selected) -->
<div x-show="!selectedCard" class="bg-white rounded-lg shadow-md dark:bg-gray-800">
<div class="p-8 text-center">
<span x-html="$icon('user-circle', 'w-16 h-16 mx-auto text-gray-300 dark:text-gray-600')"></span>
<p class="mt-4 text-gray-500 dark:text-gray-400">Search for a customer to process a transaction</p>
</div>
</div>
</div>
<!-- Recent Transactions -->
<div class="mt-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">
<span x-html="$icon('clock', 'inline w-5 h-5 mr-2')"></span>
Recent Transactions at This Location
</h3>
</div>
<div class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-700">
<th class="px-4 py-3">Time</th>
<th class="px-4 py-3">Customer</th>
<th class="px-4 py-3">Type</th>
<th class="px-4 py-3 text-right">Points</th>
<th class="px-4 py-3">Notes</th>
</tr>
</thead>
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<template x-if="recentTransactions.length === 0">
<tr>
<td colspan="5" class="px-4 py-6 text-center text-gray-500 dark:text-gray-400">
No recent transactions
</td>
</tr>
</template>
<template x-for="tx in recentTransactions" :key="tx.id">
<tr class="text-gray-700 dark:text-gray-400">
<td class="px-4 py-3 text-sm" x-text="formatTime(tx.transaction_at)"></td>
<td class="px-4 py-3 text-sm" x-text="tx.customer_name || 'Unknown'"></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.points_delta > 0 ? 'Earned' : 'Redeemed'"></span>
</td>
<td class="px-4 py-3 text-sm text-right 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 text-gray-500" x-text="tx.notes || '-'"></td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</div>
<!-- Staff PIN Modal -->
{% call modal_simple(id='pinModal', title='Enter Staff PIN', show_var='showPinEntry') %}
<div class="p-6">
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
Enter your staff PIN to authorize this transaction.
</p>
<div class="flex justify-center mb-4">
<div class="flex gap-2">
<template x-for="i in 4">
<div class="w-12 h-12 border-2 rounded-lg flex items-center justify-center text-2xl font-bold"
:class="pinDigits.length >= i ? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20' : 'border-gray-300 dark:border-gray-600'"
x-text="pinDigits.length >= i ? '*' : ''"></div>
</template>
</div>
</div>
<div class="grid grid-cols-3 gap-2 max-w-xs mx-auto">
<template x-for="digit in [1, 2, 3, 4, 5, 6, 7, 8, 9]">
<button @click="addPinDigit(digit)"
class="h-14 text-xl font-semibold rounded-lg bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600">
<span x-text="digit"></span>
</button>
</template>
<button @click="pinDigits = ''"
class="h-14 text-sm font-medium rounded-lg bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600">
Clear
</button>
<button @click="addPinDigit(0)"
class="h-14 text-xl font-semibold rounded-lg bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600">
0
</button>
<button @click="removePinDigit()"
class="h-14 text-sm font-medium rounded-lg bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600">
<span x-html="$icon('backspace', 'w-6 h-6 mx-auto')"></span>
</button>
</div>
<div class="mt-4 flex justify-end gap-3">
<button @click="cancelPinEntry()"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600">
Cancel
</button>
<button @click="submitTransaction()"
:disabled="pinDigits.length !== 4 || processing"
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50">
<span x-show="processing" x-html="$icon('spinner', 'w-4 h-4 mr-2 inline animate-spin')"></span>
<span x-text="processing ? 'Processing...' : 'Confirm'"></span>
</button>
</div>
</div>
{% endcall %}
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('loyalty_static', path='vendor/js/loyalty-terminal.js') }}"></script>
{% endblock %}