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, LoyaltyTransaction,
StaffPin, StaffPin,
AppleDeviceRegistration, AppleDeviceRegistration,
CompanyLoyaltySettings,
LoyaltyType, LoyaltyType,
TransactionType, TransactionType,
StaffPinPolicy,
) )
""" """
@@ -41,15 +43,23 @@ from app.modules.loyalty.models.apple_device import (
# Model # Model
AppleDeviceRegistration, AppleDeviceRegistration,
) )
from app.modules.loyalty.models.company_settings import (
# Enums
StaffPinPolicy,
# Model
CompanyLoyaltySettings,
)
__all__ = [ __all__ = [
# Enums # Enums
"LoyaltyType", "LoyaltyType",
"TransactionType", "TransactionType",
"StaffPinPolicy",
# Models # Models
"LoyaltyProgram", "LoyaltyProgram",
"LoyaltyCard", "LoyaltyCard",
"LoyaltyTransaction", "LoyaltyTransaction",
"StaffPin", "StaffPin",
"AppleDeviceRegistration", "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. 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: Represents a customer's loyalty card (PassObject) that tracks:
- Stamp count and history - Stamp count and history
- Points balance and history - Points balance and history
- Wallet integration (Google/Apple pass IDs) - Wallet integration (Google/Apple pass IDs)
- QR code for scanning - QR code/barcode for scanning
""" """
import secrets import secrets
@@ -28,8 +33,9 @@ from models.database.base import TimestampMixin
def generate_card_number() -> str: def generate_card_number() -> str:
"""Generate a unique 12-digit card number.""" """Generate a unique 12-digit card number formatted as XXXX-XXXX-XXXX."""
return "".join([str(secrets.randbelow(10)) for _ in range(12)]) 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: def generate_qr_code_data() -> str:
@@ -46,7 +52,10 @@ class LoyaltyCard(Base, TimestampMixin):
""" """
Customer's loyalty card (PassObject). 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 - Stamps and points balances
- Wallet pass integration - Wallet pass integration
- Activity timestamps - Activity timestamps
@@ -56,7 +65,16 @@ class LoyaltyCard(Base, TimestampMixin):
id = Column(Integer, primary_key=True, index=True) 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( customer_id = Column(
Integer, Integer,
ForeignKey("customers.id", ondelete="CASCADE"), ForeignKey("customers.id", ondelete="CASCADE"),
@@ -69,12 +87,14 @@ class LoyaltyCard(Base, TimestampMixin):
nullable=False, nullable=False,
index=True, index=True,
) )
vendor_id = Column(
# Track where customer enrolled (for analytics)
enrolled_at_vendor_id = Column(
Integer, Integer,
ForeignKey("vendors.id", ondelete="CASCADE"), ForeignKey("vendors.id", ondelete="SET NULL"),
nullable=False, nullable=True,
index=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, nullable=False,
default=generate_card_number, default=generate_card_number,
index=True, index=True,
comment="Human-readable card number", comment="Human-readable card number (XXXX-XXXX-XXXX)",
) )
qr_code_data = Column( qr_code_data = Column(
String(50), String(50),
@@ -183,13 +203,18 @@ class LoyaltyCard(Base, TimestampMixin):
last_points_at = Column( last_points_at = Column(
DateTime(timezone=True), DateTime(timezone=True),
nullable=True, nullable=True,
comment="Last points earned", comment="Last points earned (for expiration tracking)",
) )
last_redemption_at = Column( last_redemption_at = Column(
DateTime(timezone=True), DateTime(timezone=True),
nullable=True, nullable=True,
comment="Last reward redemption", comment="Last reward redemption",
) )
last_activity_at = Column(
DateTime(timezone=True),
nullable=True,
comment="Any activity (for expiration calculation)",
)
# ========================================================================= # =========================================================================
# Status # Status
@@ -204,9 +229,13 @@ class LoyaltyCard(Base, TimestampMixin):
# ========================================================================= # =========================================================================
# Relationships # Relationships
# ========================================================================= # =========================================================================
company = relationship("Company", backref="loyalty_cards")
customer = relationship("Customer", backref="loyalty_cards") customer = relationship("Customer", backref="loyalty_cards")
program = relationship("LoyaltyProgram", back_populates="cards") program = relationship("LoyaltyProgram", back_populates="cards")
vendor = relationship("Vendor", backref="loyalty_cards") enrolled_at_vendor = relationship(
"Vendor",
backref="enrolled_loyalty_cards",
)
transactions = relationship( transactions = relationship(
"LoyaltyTransaction", "LoyaltyTransaction",
back_populates="card", back_populates="card",
@@ -219,14 +248,15 @@ class LoyaltyCard(Base, TimestampMixin):
cascade="all, delete-orphan", cascade="all, delete-orphan",
) )
# Indexes # Indexes - one card per customer per company
__table_args__ = ( __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_customer_program", "customer_id", "program_id", unique=True),
Index("idx_loyalty_card_vendor_active", "vendor_id", "is_active"),
) )
def __repr__(self) -> str: 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 # Stamp Operations
@@ -241,6 +271,7 @@ class LoyaltyCard(Base, TimestampMixin):
self.stamp_count += 1 self.stamp_count += 1
self.total_stamps_earned += 1 self.total_stamps_earned += 1
self.last_stamp_at = datetime.now(UTC) 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) # Check if reward cycle is complete (handled by caller with program.stamps_target)
return False # Caller checks against 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.stamp_count -= stamps_target
self.stamps_redeemed += 1 self.stamps_redeemed += 1
self.last_redemption_at = datetime.now(UTC) self.last_redemption_at = datetime.now(UTC)
self.last_activity_at = datetime.now(UTC)
return True return True
return False return False
@@ -270,6 +302,7 @@ class LoyaltyCard(Base, TimestampMixin):
self.points_balance += points self.points_balance += points
self.total_points_earned += points self.total_points_earned += points
self.last_points_at = datetime.now(UTC) self.last_points_at = datetime.now(UTC)
self.last_activity_at = datetime.now(UTC)
def redeem_points(self, points: int) -> bool: def redeem_points(self, points: int) -> bool:
""" """
@@ -281,9 +314,29 @@ class LoyaltyCard(Base, TimestampMixin):
self.points_balance -= points self.points_balance -= points
self.points_redeemed += points self.points_redeemed += points
self.last_redemption_at = datetime.now(UTC) self.last_redemption_at = datetime.now(UTC)
self.last_activity_at = datetime.now(UTC)
return True return True
return False 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 # Properties
# ========================================================================= # =========================================================================

View File

@@ -2,7 +2,12 @@
""" """
Loyalty program database model. 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) - Program type (stamps, points, hybrid)
- Stamp configuration (target, reward description) - Stamp configuration (target, reward description)
- Points configuration (per euro rate, rewards catalog) - Points configuration (per euro rate, rewards catalog)
@@ -41,9 +46,13 @@ class LoyaltyType(str, enum.Enum):
class LoyaltyProgram(Base, TimestampMixin): 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 - Program type and mechanics
- Stamp or points configuration - Stamp or points configuration
- Anti-fraud rules - Anti-fraud rules
@@ -54,19 +63,20 @@ class LoyaltyProgram(Base, TimestampMixin):
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
# Vendor association (one program per vendor) # Company association (one program per company)
vendor_id = Column( company_id = Column(
Integer, Integer,
ForeignKey("vendors.id", ondelete="CASCADE"), ForeignKey("companies.id", ondelete="CASCADE"),
unique=True, unique=True,
nullable=False, nullable=False,
index=True, index=True,
comment="Company that owns this program (chain-wide)",
) )
# Program type # Program type
loyalty_type = Column( loyalty_type = Column(
String(20), String(20),
default=LoyaltyType.STAMPS.value, default=LoyaltyType.POINTS.value,
nullable=False, nullable=False,
) )
@@ -96,9 +106,9 @@ class LoyaltyProgram(Base, TimestampMixin):
# ========================================================================= # =========================================================================
points_per_euro = Column( points_per_euro = Column(
Integer, Integer,
default=10, default=1,
nullable=False, nullable=False,
comment="Points earned per euro spent", comment="Points earned per euro spent (1 euro = X points)",
) )
points_rewards = Column( points_rewards = Column(
JSON, JSON,
@@ -107,6 +117,38 @@ class LoyaltyProgram(Base, TimestampMixin):
comment="List of point rewards: [{id, name, points_required, description}]", 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 # Anti-Fraud Settings
# ========================================================================= # =========================================================================
@@ -151,7 +193,7 @@ class LoyaltyProgram(Base, TimestampMixin):
logo_url = Column( logo_url = Column(
String(500), String(500),
nullable=True, nullable=True,
comment="URL to vendor logo for card", comment="URL to company logo for card",
) )
hero_image_url = Column( hero_image_url = Column(
String(500), String(500),
@@ -210,7 +252,7 @@ class LoyaltyProgram(Base, TimestampMixin):
# ========================================================================= # =========================================================================
# Relationships # Relationships
# ========================================================================= # =========================================================================
vendor = relationship("Vendor", back_populates="loyalty_program") company = relationship("Company", backref="loyalty_program")
cards = relationship( cards = relationship(
"LoyaltyCard", "LoyaltyCard",
back_populates="program", back_populates="program",
@@ -224,11 +266,11 @@ class LoyaltyProgram(Base, TimestampMixin):
# Indexes # Indexes
__table_args__ = ( __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: 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 # Properties

View File

@@ -2,9 +2,15 @@
""" """
Loyalty transaction database model. 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: Records all loyalty events including:
- Stamps earned and redeemed - Stamps earned and redeemed
- Points earned and redeemed - Points earned and redeemed
- Welcome bonuses and expirations
- Associated metadata (staff PIN, purchase amount, IP, etc.) - Associated metadata (staff PIN, purchase amount, IP, etc.)
""" """
@@ -31,10 +37,12 @@ class TransactionType(str, enum.Enum):
# Stamps # Stamps
STAMP_EARNED = "stamp_earned" STAMP_EARNED = "stamp_earned"
STAMP_REDEEMED = "stamp_redeemed" STAMP_REDEEMED = "stamp_redeemed"
STAMP_VOIDED = "stamp_voided" # Stamps voided due to return
# Points # Points
POINTS_EARNED = "points_earned" POINTS_EARNED = "points_earned"
POINTS_REDEEMED = "points_redeemed" POINTS_REDEEMED = "points_redeemed"
POINTS_VOIDED = "points_voided" # Points voided due to return
# Adjustments (manual corrections by staff) # Adjustments (manual corrections by staff)
STAMP_ADJUSTMENT = "stamp_adjustment" STAMP_ADJUSTMENT = "stamp_adjustment"
@@ -44,6 +52,10 @@ class TransactionType(str, enum.Enum):
CARD_CREATED = "card_created" CARD_CREATED = "card_created"
CARD_DEACTIVATED = "card_deactivated" 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): class LoyaltyTransaction(Base, TimestampMixin):
""" """
@@ -51,12 +63,25 @@ class LoyaltyTransaction(Base, TimestampMixin):
Immutable audit log of all loyalty operations for fraud Immutable audit log of all loyalty operations for fraud
detection, analytics, and customer support. 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" __tablename__ = "loyalty_transactions"
id = Column(Integer, primary_key=True, index=True) 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 # Core relationships
card_id = Column( card_id = Column(
Integer, Integer,
@@ -66,10 +91,10 @@ class LoyaltyTransaction(Base, TimestampMixin):
) )
vendor_id = Column( vendor_id = Column(
Integer, Integer,
ForeignKey("vendors.id", ondelete="CASCADE"), ForeignKey("vendors.id", ondelete="SET NULL"),
nullable=False, nullable=True,
index=True, index=True,
comment="Denormalized for query performance", comment="Vendor (location) that processed this transaction",
) )
staff_pin_id = Column( staff_pin_id = Column(
Integer, Integer,
@@ -79,6 +104,15 @@ class LoyaltyTransaction(Base, TimestampMixin):
comment="Staff PIN used for this operation", 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 # Transaction Details
# ========================================================================= # =========================================================================
@@ -175,15 +209,23 @@ class LoyaltyTransaction(Base, TimestampMixin):
# ========================================================================= # =========================================================================
# Relationships # Relationships
# ========================================================================= # =========================================================================
company = relationship("Company", backref="loyalty_transactions")
card = relationship("LoyaltyCard", back_populates="transactions") card = relationship("LoyaltyCard", back_populates="transactions")
vendor = relationship("Vendor", backref="loyalty_transactions") vendor = relationship("Vendor", backref="loyalty_transactions")
staff_pin = relationship("StaffPin", backref="transactions") staff_pin = relationship("StaffPin", backref="transactions")
related_transaction = relationship(
"LoyaltyTransaction",
remote_side=[id],
backref="voiding_transactions",
)
# Indexes # Indexes
__table_args__ = ( __table_args__ = (
Index("idx_loyalty_tx_card_type", "card_id", "transaction_type"), Index("idx_loyalty_tx_card_type", "card_id", "transaction_type"),
Index("idx_loyalty_tx_vendor_date", "vendor_id", "transaction_at"), Index("idx_loyalty_tx_vendor_date", "vendor_id", "transaction_at"),
Index("idx_loyalty_tx_type_date", "transaction_type", "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: def __repr__(self) -> str:
@@ -202,6 +244,7 @@ class LoyaltyTransaction(Base, TimestampMixin):
return self.transaction_type in ( return self.transaction_type in (
TransactionType.STAMP_EARNED.value, TransactionType.STAMP_EARNED.value,
TransactionType.STAMP_REDEEMED.value, TransactionType.STAMP_REDEEMED.value,
TransactionType.STAMP_VOIDED.value,
TransactionType.STAMP_ADJUSTMENT.value, TransactionType.STAMP_ADJUSTMENT.value,
) )
@@ -212,6 +255,9 @@ class LoyaltyTransaction(Base, TimestampMixin):
TransactionType.POINTS_EARNED.value, TransactionType.POINTS_EARNED.value,
TransactionType.POINTS_REDEEMED.value, TransactionType.POINTS_REDEEMED.value,
TransactionType.POINTS_ADJUSTMENT.value, TransactionType.POINTS_ADJUSTMENT.value,
TransactionType.POINTS_VOIDED.value,
TransactionType.WELCOME_BONUS.value,
TransactionType.POINTS_EXPIRED.value,
) )
@property @property
@@ -220,6 +266,7 @@ class LoyaltyTransaction(Base, TimestampMixin):
return self.transaction_type in ( return self.transaction_type in (
TransactionType.STAMP_EARNED.value, TransactionType.STAMP_EARNED.value,
TransactionType.POINTS_EARNED.value, TransactionType.POINTS_EARNED.value,
TransactionType.WELCOME_BONUS.value,
) )
@property @property
@@ -230,9 +277,24 @@ class LoyaltyTransaction(Base, TimestampMixin):
TransactionType.POINTS_REDEEMED.value, 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 @property
def staff_name(self) -> str | None: def staff_name(self) -> str | None:
"""Get the name of the staff member who performed this transaction.""" """Get the name of the staff member who performed this transaction."""
if self.staff_pin: if self.staff_pin:
return self.staff_pin.name return self.staff_pin.name
return None 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. 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 Provides fraud prevention by requiring staff to authenticate
before performing stamp/points operations. Includes: before performing stamp/points operations. Includes:
- Secure PIN hashing with bcrypt - Secure PIN hashing with bcrypt
@@ -34,13 +39,25 @@ class StaffPin(Base, TimestampMixin):
Each staff member can have their own PIN to authenticate Each staff member can have their own PIN to authenticate
stamp/points operations. PINs are hashed with bcrypt and stamp/points operations. PINs are hashed with bcrypt and
include lockout protection. include lockout protection.
PINs are scoped to a specific vendor (location) within the
company's loyalty program.
""" """
__tablename__ = "staff_pins" __tablename__ = "staff_pins"
id = Column(Integer, primary_key=True, index=True) 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( program_id = Column(
Integer, Integer,
ForeignKey("loyalty_programs.id", ondelete="CASCADE"), ForeignKey("loyalty_programs.id", ondelete="CASCADE"),
@@ -52,7 +69,7 @@ class StaffPin(Base, TimestampMixin):
ForeignKey("vendors.id", ondelete="CASCADE"), ForeignKey("vendors.id", ondelete="CASCADE"),
nullable=False, nullable=False,
index=True, index=True,
comment="Denormalized for query performance", comment="Vendor (location) where this staff member works",
) )
# Staff identity # Staff identity
@@ -104,17 +121,19 @@ class StaffPin(Base, TimestampMixin):
# ========================================================================= # =========================================================================
# Relationships # Relationships
# ========================================================================= # =========================================================================
company = relationship("Company", backref="staff_pins")
program = relationship("LoyaltyProgram", back_populates="staff_pins") program = relationship("LoyaltyProgram", back_populates="staff_pins")
vendor = relationship("Vendor", backref="staff_pins") vendor = relationship("Vendor", backref="staff_pins")
# Indexes # Indexes
__table_args__ = ( __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_vendor_active", "vendor_id", "is_active"),
Index("idx_staff_pin_program_active", "program_id", "is_active"), Index("idx_staff_pin_program_active", "program_id", "is_active"),
) )
def __repr__(self) -> str: 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 # PIN Operations

View File

@@ -3,13 +3,14 @@
Loyalty module admin routes. Loyalty module admin routes.
Platform admin endpoints for: Platform admin endpoints for:
- Viewing all loyalty programs - Viewing all loyalty programs (company-based)
- Company loyalty settings management
- Platform-wide analytics - Platform-wide analytics
""" """
import logging import logging
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, HTTPException, Path, Query
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api, require_module_access from app.api.deps import get_current_admin_api, require_module_access
@@ -19,6 +20,9 @@ from app.modules.loyalty.schemas import (
ProgramListResponse, ProgramListResponse,
ProgramResponse, ProgramResponse,
ProgramStatsResponse, ProgramStatsResponse,
CompanyStatsResponse,
CompanySettingsResponse,
CompanySettingsUpdate,
) )
from app.modules.loyalty.services import program_service from app.modules.loyalty.services import program_service
from app.modules.tenancy.models import User from app.modules.tenancy.models import User
@@ -42,15 +46,22 @@ def list_programs(
skip: int = Query(0, ge=0), skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100), limit: int = Query(50, ge=1, le=100),
is_active: bool | None = Query(None), is_active: bool | None = Query(None),
search: str | None = Query(None, description="Search by company name"),
current_user: User = Depends(get_current_admin_api), current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""List all loyalty programs (platform admin).""" """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( programs, total = program_service.list_programs(
db, db,
skip=skip, skip=skip,
limit=limit, limit=limit,
is_active=is_active, is_active=is_active,
search=search,
) )
program_responses = [] program_responses = []
@@ -59,6 +70,47 @@ def list_programs(
response.is_stamps_enabled = program.is_stamps_enabled response.is_stamps_enabled = program.is_stamps_enabled
response.is_points_enabled = program.is_points_enabled response.is_points_enabled = program.is_points_enabled
response.display_name = program.display_name 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) program_responses.append(response)
return ProgramListResponse(programs=program_responses, total=total) return ProgramListResponse(programs=program_responses, total=total)
@@ -92,6 +144,60 @@ def get_program_stats(
return ProgramStatsResponse(**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 # Platform Stats
# ============================================================================= # =============================================================================
@@ -136,10 +242,39 @@ def get_platform_stats(
or 0 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 { return {
"total_programs": total_programs, "total_programs": total_programs,
"active_programs": active_programs, "active_programs": active_programs,
"companies_with_programs": companies_with_programs,
"total_cards": total_cards, "total_cards": total_cards,
"active_cards": active_cards, "active_cards": active_cards,
"transactions_30d": transactions_30d, "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. Loyalty module vendor routes.
Store/vendor endpoints for: Company-based vendor endpoints for:
- Program management - Program management (company-wide, managed by vendor)
- Staff PINs - Staff PINs (per-vendor)
- Card operations (stamps, points, redemptions) - Card operations (stamps, points, redemptions, voids)
- Customer cards lookup - Customer cards lookup
- Dashboard stats - Dashboard stats
All operations are scoped to the vendor's company.
Cards can be used at any vendor within the same company.
""" """
import logging import logging
@@ -36,14 +39,23 @@ from app.modules.loyalty.schemas import (
PointsEarnResponse, PointsEarnResponse,
PointsRedeemRequest, PointsRedeemRequest,
PointsRedeemResponse, PointsRedeemResponse,
PointsVoidRequest,
PointsVoidResponse,
PointsAdjustRequest,
PointsAdjustResponse,
ProgramCreate, ProgramCreate,
ProgramResponse, ProgramResponse,
ProgramStatsResponse, ProgramStatsResponse,
ProgramUpdate, ProgramUpdate,
CompanyStatsResponse,
StampRedeemRequest, StampRedeemRequest,
StampRedeemResponse, StampRedeemResponse,
StampRequest, StampRequest,
StampResponse, StampResponse,
StampVoidRequest,
StampVoidResponse,
TransactionListResponse,
TransactionResponse,
) )
from app.modules.loyalty.services import ( from app.modules.loyalty.services import (
card_service, card_service,
@@ -54,7 +66,7 @@ from app.modules.loyalty.services import (
wallet_service, wallet_service,
) )
from app.modules.enums import FrontendType 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__) logger = logging.getLogger(__name__)
@@ -72,6 +84,14 @@ def get_client_info(request: Request) -> tuple[str | None, str | None]:
return ip, user_agent 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 # Program Management
# ============================================================================= # =============================================================================
@@ -82,7 +102,7 @@ def get_program(
current_user: User = Depends(get_current_vendor_api), current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Get the vendor's loyalty program.""" """Get the company's loyalty program."""
vendor_id = current_user.token_vendor_id vendor_id = current_user.token_vendor_id
program = program_service.get_program_by_vendor(db, 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), current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db), 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 vendor_id = current_user.token_vendor_id
company_id = get_vendor_company_id(db, vendor_id)
try: try:
program = program_service.create_program(db, vendor_id, data) program = program_service.create_program(db, company_id, data)
except LoyaltyException as e: except LoyaltyException as e:
raise HTTPException(status_code=e.status_code, detail=e.message) 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), current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Update the vendor's loyalty program.""" """Update the company's loyalty program."""
vendor_id = current_user.token_vendor_id vendor_id = current_user.token_vendor_id
program = program_service.get_program_by_vendor(db, vendor_id) program = program_service.get_program_by_vendor(db, vendor_id)
@@ -158,6 +179,22 @@ def get_stats(
return ProgramStatsResponse(**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 # Staff PINs
# ============================================================================= # =============================================================================
@@ -168,14 +205,15 @@ def list_pins(
current_user: User = Depends(get_current_vendor_api), current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db), 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 vendor_id = current_user.token_vendor_id
program = program_service.get_program_by_vendor(db, vendor_id) program = program_service.get_program_by_vendor(db, vendor_id)
if not program: if not program:
raise HTTPException(status_code=404, detail="No loyalty program configured") 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( return PinListResponse(
pins=[PinResponse.model_validate(pin) for pin in pins], pins=[PinResponse.model_validate(pin) for pin in pins],
@@ -189,7 +227,7 @@ def create_pin(
current_user: User = Depends(get_current_vendor_api), current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db), 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 vendor_id = current_user.token_vendor_id
program = program_service.get_program_by_vendor(db, 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), limit: int = Query(50, ge=1, le=100),
is_active: bool | None = Query(None), is_active: bool | None = Query(None),
search: str | None = Query(None, max_length=100), 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), current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db), 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 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) program = program_service.get_program_by_vendor(db, vendor_id)
if not program: if not program:
raise HTTPException(status_code=404, detail="No loyalty program configured") 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( cards, total = card_service.list_cards(
db, db,
vendor_id, company_id,
vendor_id=filter_vendor_id,
skip=skip, skip=skip,
limit=limit, limit=limit,
is_active=is_active, is_active=is_active,
@@ -269,8 +318,9 @@ def list_cards(
id=card.id, id=card.id,
card_number=card.card_number, card_number=card.card_number,
customer_id=card.customer_id, customer_id=card.customer_id,
vendor_id=card.vendor_id, company_id=card.company_id,
program_id=card.program_id, program_id=card.program_id,
enrolled_at_vendor_id=card.enrolled_at_vendor_id,
stamp_count=card.stamp_count, stamp_count=card.stamp_count,
stamps_target=program.stamps_target, stamps_target=program.stamps_target,
stamps_until_reward=max(0, program.stamps_target - card.stamp_count), 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), current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db), 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 vendor_id = current_user.token_vendor_id
try: try:
card = card_service.lookup_card( # Uses lookup_card_for_vendor which validates company membership
card = card_service.lookup_card_for_vendor(
db, db,
vendor_id,
card_id=card_id, card_id=card_id,
qr_code=qr_code, qr_code=qr_code,
card_number=card_number, card_number=card_number,
@@ -311,10 +367,6 @@ def lookup_card(
except LoyaltyCardNotFoundException: except LoyaltyCardNotFoundException:
raise HTTPException(status_code=404, detail="Card not found") 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 program = card.program
# Check cooldown # Check cooldown
@@ -328,18 +380,27 @@ def lookup_card(
# Get stamps today # Get stamps today
stamps_today = card_service.get_stamps_today(db, card.id) 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( return CardLookupResponse(
card_id=card.id, card_id=card.id,
card_number=card.card_number, card_number=card.card_number,
customer_id=card.customer_id, customer_id=card.customer_id,
customer_name=card.customer.full_name if card.customer else None, customer_name=card.customer.full_name if card.customer else None,
customer_email=card.customer.email if card.customer else "", 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, stamp_count=card.stamp_count,
stamps_target=program.stamps_target, stamps_target=program.stamps_target,
stamps_until_reward=max(0, program.stamps_target - card.stamp_count), stamps_until_reward=max(0, program.stamps_target - card.stamp_count),
points_balance=card.points_balance, points_balance=card.points_balance,
can_redeem_stamps=card.stamp_count >= program.stamps_target, can_redeem_stamps=card.stamp_count >= program.stamps_target,
stamp_reward_description=program.stamps_reward_description, stamp_reward_description=program.stamps_reward_description,
available_rewards=available_rewards,
can_stamp=can_stamp, can_stamp=can_stamp,
cooldown_ends_at=cooldown_ends, cooldown_ends_at=cooldown_ends,
stamps_today=stamps_today, stamps_today=stamps_today,
@@ -354,14 +415,19 @@ def enroll_customer(
current_user: User = Depends(get_current_vendor_api), current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db), 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 vendor_id = current_user.token_vendor_id
if not data.customer_id: if not data.customer_id:
raise HTTPException(status_code=400, detail="customer_id is required") raise HTTPException(status_code=400, detail="customer_id is required")
try: 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: except LoyaltyException as e:
raise HTTPException(status_code=e.status_code, detail=e.message) raise HTTPException(status_code=e.status_code, detail=e.message)
@@ -371,11 +437,12 @@ def enroll_customer(
id=card.id, id=card.id,
card_number=card.card_number, card_number=card.card_number,
customer_id=card.customer_id, customer_id=card.customer_id,
vendor_id=card.vendor_id, company_id=card.company_id,
program_id=card.program_id, program_id=card.program_id,
enrolled_at_vendor_id=card.enrolled_at_vendor_id,
stamp_count=card.stamp_count, stamp_count=card.stamp_count,
stamps_target=program.stamps_target, 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, total_stamps_earned=card.total_stamps_earned,
stamps_redeemed=card.stamps_redeemed, stamps_redeemed=card.stamps_redeemed,
points_balance=card.points_balance, 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 # Stamp Operations
# ============================================================================= # =============================================================================
@@ -399,11 +493,13 @@ def add_stamp(
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Add a stamp to a loyalty card.""" """Add a stamp to a loyalty card."""
vendor_id = current_user.token_vendor_id
ip, user_agent = get_client_info(request) ip, user_agent = get_client_info(request)
try: try:
result = stamp_service.add_stamp( result = stamp_service.add_stamp(
db, db,
vendor_id=vendor_id,
card_id=data.card_id, card_id=data.card_id,
qr_code=data.qr_code, qr_code=data.qr_code,
card_number=data.card_number, card_number=data.card_number,
@@ -426,11 +522,13 @@ def redeem_stamps(
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Redeem stamps for a reward.""" """Redeem stamps for a reward."""
vendor_id = current_user.token_vendor_id
ip, user_agent = get_client_info(request) ip, user_agent = get_client_info(request)
try: try:
result = stamp_service.redeem_stamps( result = stamp_service.redeem_stamps(
db, db,
vendor_id=vendor_id,
card_id=data.card_id, card_id=data.card_id,
qr_code=data.qr_code, qr_code=data.qr_code,
card_number=data.card_number, card_number=data.card_number,
@@ -445,6 +543,37 @@ def redeem_stamps(
return StampRedeemResponse(**result) 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 # Points Operations
# ============================================================================= # =============================================================================
@@ -458,11 +587,13 @@ def earn_points(
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Earn points from a purchase.""" """Earn points from a purchase."""
vendor_id = current_user.token_vendor_id
ip, user_agent = get_client_info(request) ip, user_agent = get_client_info(request)
try: try:
result = points_service.earn_points( result = points_service.earn_points(
db, db,
vendor_id=vendor_id,
card_id=data.card_id, card_id=data.card_id,
qr_code=data.qr_code, qr_code=data.qr_code,
card_number=data.card_number, card_number=data.card_number,
@@ -487,11 +618,13 @@ def redeem_points(
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Redeem points for a reward.""" """Redeem points for a reward."""
vendor_id = current_user.token_vendor_id
ip, user_agent = get_client_info(request) ip, user_agent = get_client_info(request)
try: try:
result = points_service.redeem_points( result = points_service.redeem_points(
db, db,
vendor_id=vendor_id,
card_id=data.card_id, card_id=data.card_id,
qr_code=data.qr_code, qr_code=data.qr_code,
card_number=data.card_number, card_number=data.card_number,
@@ -505,3 +638,64 @@ def redeem_points(
raise HTTPException(status_code=e.status_code, detail=e.message) raise HTTPException(status_code=e.status_code, detail=e.message)
return PointsRedeemResponse(**result) 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 # 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, ProgramListResponse,
# Points rewards # Points rewards
PointsRewardConfig, PointsRewardConfig,
TierConfig,
# Stats # Stats
ProgramStatsResponse, ProgramStatsResponse,
CompanyStatsResponse,
# Company settings
CompanySettingsResponse,
CompanySettingsUpdate,
) )
from app.modules.loyalty.schemas.card import ( from app.modules.loyalty.schemas.card import (
@@ -44,6 +49,9 @@ from app.modules.loyalty.schemas.card import (
CardDetailResponse, CardDetailResponse,
CardListResponse, CardListResponse,
CardLookupResponse, CardLookupResponse,
# Transactions
TransactionResponse,
TransactionListResponse,
) )
from app.modules.loyalty.schemas.stamp import ( from app.modules.loyalty.schemas.stamp import (
@@ -52,6 +60,8 @@ from app.modules.loyalty.schemas.stamp import (
StampResponse, StampResponse,
StampRedeemRequest, StampRedeemRequest,
StampRedeemResponse, StampRedeemResponse,
StampVoidRequest,
StampVoidResponse,
) )
from app.modules.loyalty.schemas.points import ( from app.modules.loyalty.schemas.points import (
@@ -60,6 +70,10 @@ from app.modules.loyalty.schemas.points import (
PointsEarnResponse, PointsEarnResponse,
PointsRedeemRequest, PointsRedeemRequest,
PointsRedeemResponse, PointsRedeemResponse,
PointsVoidRequest,
PointsVoidResponse,
PointsAdjustRequest,
PointsAdjustResponse,
) )
from app.modules.loyalty.schemas.pin import ( from app.modules.loyalty.schemas.pin import (
@@ -79,23 +93,35 @@ __all__ = [
"ProgramResponse", "ProgramResponse",
"ProgramListResponse", "ProgramListResponse",
"PointsRewardConfig", "PointsRewardConfig",
"TierConfig",
"ProgramStatsResponse", "ProgramStatsResponse",
"CompanyStatsResponse",
"CompanySettingsResponse",
"CompanySettingsUpdate",
# Card # Card
"CardEnrollRequest", "CardEnrollRequest",
"CardResponse", "CardResponse",
"CardDetailResponse", "CardDetailResponse",
"CardListResponse", "CardListResponse",
"CardLookupResponse", "CardLookupResponse",
"TransactionResponse",
"TransactionListResponse",
# Stamp # Stamp
"StampRequest", "StampRequest",
"StampResponse", "StampResponse",
"StampRedeemRequest", "StampRedeemRequest",
"StampRedeemResponse", "StampRedeemResponse",
"StampVoidRequest",
"StampVoidResponse",
# Points # Points
"PointsEarnRequest", "PointsEarnRequest",
"PointsEarnResponse", "PointsEarnResponse",
"PointsRedeemRequest", "PointsRedeemRequest",
"PointsRedeemResponse", "PointsRedeemResponse",
"PointsVoidRequest",
"PointsVoidResponse",
"PointsAdjustRequest",
"PointsAdjustResponse",
# PIN # PIN
"PinCreate", "PinCreate",
"PinUpdate", "PinUpdate",

View File

@@ -1,6 +1,11 @@
# app/modules/loyalty/schemas/card.py # app/modules/loyalty/schemas/card.py
""" """
Pydantic schemas for loyalty card operations. 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 from datetime import datetime
@@ -29,8 +34,9 @@ class CardResponse(BaseModel):
id: int id: int
card_number: str card_number: str
customer_id: int customer_id: int
vendor_id: int company_id: int
program_id: int program_id: int
enrolled_at_vendor_id: int | None = None
# Stamps # Stamps
stamp_count: int stamp_count: int
@@ -64,6 +70,9 @@ class CardDetailResponse(CardResponse):
customer_name: str | None = None customer_name: str | None = None
customer_email: str | None = None customer_email: str | None = None
# Company info
company_name: str | None = None
# Program info # Program info
program_name: str program_name: str
program_type: str program_type: str
@@ -73,6 +82,7 @@ class CardDetailResponse(CardResponse):
last_stamp_at: datetime | None = None last_stamp_at: datetime | None = None
last_points_at: datetime | None = None last_points_at: datetime | None = None
last_redemption_at: datetime | None = None last_redemption_at: datetime | None = None
last_activity_at: datetime | None = None
# Wallet URLs # Wallet URLs
google_wallet_url: str | None = None google_wallet_url: str | None = None
@@ -98,6 +108,10 @@ class CardLookupResponse(BaseModel):
customer_name: str | None = None customer_name: str | None = None
customer_email: str customer_email: str
# Company context
company_id: int
company_name: str | None = None
# Current balances # Current balances
stamp_count: int stamp_count: int
stamps_target: int stamps_target: int
@@ -108,6 +122,9 @@ class CardLookupResponse(BaseModel):
can_redeem_stamps: bool = False can_redeem_stamps: bool = False
stamp_reward_description: str | None = None stamp_reward_description: str | None = None
# Available points rewards
available_rewards: list[dict] = []
# Cooldown status # Cooldown status
can_stamp: bool = True can_stamp: bool = True
cooldown_ends_at: datetime | None = None cooldown_ends_at: datetime | None = None
@@ -116,3 +133,44 @@ class CardLookupResponse(BaseModel):
stamps_today: int = 0 stamps_today: int = 0
max_daily_stamps: int = 5 max_daily_stamps: int = 5
can_earn_more_stamps: bool = True 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 # app/modules/loyalty/schemas/points.py
""" """
Pydantic schemas for points operations. 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 from pydantic import BaseModel, Field
@@ -67,6 +72,9 @@ class PointsEarnResponse(BaseModel):
points_balance: int points_balance: int
total_points_earned: int total_points_earned: int
# Location
vendor_id: int | None = None
class PointsRedeemRequest(BaseModel): class PointsRedeemRequest(BaseModel):
"""Schema for redeeming points for a reward.""" """Schema for redeeming points for a reward."""
@@ -122,3 +130,108 @@ class PointsRedeemResponse(BaseModel):
card_number: str card_number: str
points_balance: int points_balance: int
total_points_redeemed: 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 # app/modules/loyalty/schemas/program.py
""" """
Pydantic schemas for loyalty program operations. 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 from datetime import datetime
@@ -18,12 +23,22 @@ class PointsRewardConfig(BaseModel):
is_active: bool = Field(True, description="Whether reward is currently available") 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): class ProgramCreate(BaseModel):
"""Schema for creating a loyalty program.""" """Schema for creating a loyalty program."""
# Program type # Program type
loyalty_type: str = Field( loyalty_type: str = Field(
"stamps", "points",
pattern="^(stamps|points|hybrid)$", pattern="^(stamps|points|hybrid)$",
description="Program type: stamps, points, or hybrid", description="Program type: stamps, points, or hybrid",
) )
@@ -42,11 +57,37 @@ class ProgramCreate(BaseModel):
) )
# Points configuration # 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( points_rewards: list[PointsRewardConfig] = Field(
default_factory=list, default_factory=list,
description="Available point rewards", 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 # Anti-fraud
cooldown_minutes: int = Field(15, ge=0, le=1440, description="Minutes between stamps") 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) stamps_reward_value_cents: int | None = Field(None, ge=0)
# Points configuration # 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_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 # Anti-fraud
cooldown_minutes: int | None = Field(None, ge=0, le=1440) cooldown_minutes: int | None = Field(None, ge=0, le=1440)
@@ -123,7 +171,8 @@ class ProgramResponse(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
id: int id: int
vendor_id: int company_id: int
company_name: str | None = None # Populated by API from Company join
loyalty_type: str loyalty_type: str
# Stamps # Stamps
@@ -134,6 +183,10 @@ class ProgramResponse(BaseModel):
# Points # Points
points_per_euro: int points_per_euro: int
points_rewards: list[PointsRewardConfig] = [] 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 # Anti-fraud
cooldown_minutes: int cooldown_minutes: int
@@ -167,6 +220,12 @@ class ProgramResponse(BaseModel):
is_points_enabled: bool = False is_points_enabled: bool = False
display_name: str = "Loyalty Card" 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): class ProgramListResponse(BaseModel):
"""Schema for listing loyalty programs (admin).""" """Schema for listing loyalty programs (admin)."""
@@ -201,3 +260,61 @@ class ProgramStatsResponse(BaseModel):
# Value # Value
estimated_liability_cents: int = 0 # Unredeemed stamps/points 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 # app/modules/loyalty/schemas/stamp.py
""" """
Pydantic schemas for stamp operations. 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 from datetime import datetime
@@ -64,6 +69,9 @@ class StampResponse(BaseModel):
stamps_today: int stamps_today: int
stamps_remaining_today: int stamps_remaining_today: int
# Location
vendor_id: int | None = None
class StampRedeemRequest(BaseModel): class StampRedeemRequest(BaseModel):
"""Schema for redeeming stamps for a reward.""" """Schema for redeeming stamps for a reward."""
@@ -112,3 +120,67 @@ class StampRedeemResponse(BaseModel):
# Reward info # Reward info
reward_description: str reward_description: str
total_redemptions: int # Lifetime redemptions for this card 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. 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: Handles card operations including:
- Customer enrollment - Customer enrollment (with welcome bonus)
- Card lookup (by ID, QR code, card number) - Card lookup (by ID, QR code, card number, email, phone)
- Card management (activation, deactivation) - Card management (activation, deactivation)
""" """
@@ -19,7 +24,12 @@ from app.modules.loyalty.exceptions import (
LoyaltyProgramInactiveException, LoyaltyProgramInactiveException,
LoyaltyProgramNotFoundException, 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__) logger = logging.getLogger(__name__)
@@ -51,10 +61,31 @@ class CardService:
def get_card_by_number(self, db: Session, card_number: str) -> LoyaltyCard | None: def get_card_by_number(self, db: Session, card_number: str) -> LoyaltyCard | None:
"""Get a loyalty card by card number.""" """Get a loyalty card by card number."""
# Normalize card number (remove dashes)
normalized = card_number.replace("-", "").replace(" ", "")
return ( return (
db.query(LoyaltyCard) db.query(LoyaltyCard)
.options(joinedload(LoyaltyCard.program)) .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() .first()
) )
@@ -89,6 +120,7 @@ class CardService:
card_id: int | None = None, card_id: int | None = None,
qr_code: str | None = None, qr_code: str | None = None,
card_number: str | None = None, card_number: str | None = None,
company_id: int | None = None,
) -> LoyaltyCard: ) -> LoyaltyCard:
""" """
Look up a card by any identifier. Look up a card by any identifier.
@@ -97,7 +129,8 @@ class CardService:
db: Database session db: Database session
card_id: Card ID card_id: Card ID
qr_code: QR code data qr_code: QR code data
card_number: Card number card_number: Card number (with or without dashes)
company_id: Optional company filter
Returns: Returns:
Found card Found card
@@ -118,28 +151,73 @@ class CardService:
identifier = card_id or qr_code or card_number or "unknown" identifier = card_id or qr_code or card_number or "unknown"
raise LoyaltyCardNotFoundException(str(identifier)) 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 return card
def list_cards( def lookup_card_for_vendor(
self, self,
db: Session, db: Session,
vendor_id: int, 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, skip: int = 0,
limit: int = 50, limit: int = 50,
is_active: bool | None = None, is_active: bool | None = None,
search: str | None = None, search: str | None = None,
) -> tuple[list[LoyaltyCard], int]: ) -> tuple[list[LoyaltyCard], int]:
""" """
List loyalty cards for a vendor. List loyalty cards for a company.
Args: Args:
db: Database session db: Database session
vendor_id: Vendor ID company_id: Company ID
vendor_id: Optional filter by enrolled vendor
skip: Pagination offset skip: Pagination offset
limit: Pagination limit limit: Pagination limit
is_active: Filter by active status is_active: Filter by active status
search: Search by card number or customer email search: Search by card number, email, or name
Returns: Returns:
(cards, total_count) (cards, total_count)
@@ -149,18 +227,24 @@ class CardService:
query = ( query = (
db.query(LoyaltyCard) db.query(LoyaltyCard)
.options(joinedload(LoyaltyCard.customer)) .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: if is_active is not None:
query = query.filter(LoyaltyCard.is_active == is_active) query = query.filter(LoyaltyCard.is_active == is_active)
if search: if search:
# Normalize search term for card number matching
search_normalized = search.replace("-", "").replace(" ", "")
query = query.join(Customer).filter( 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.email.ilike(f"%{search}%"))
| (Customer.first_name.ilike(f"%{search}%")) | (Customer.first_name.ilike(f"%{search}%"))
| (Customer.last_name.ilike(f"%{search}%")) | (Customer.last_name.ilike(f"%{search}%"))
| (Customer.phone.ilike(f"%{search}%"))
) )
total = query.count() total = query.count()
@@ -181,7 +265,7 @@ class CardService:
"""List all loyalty cards for a customer.""" """List all loyalty cards for a customer."""
return ( return (
db.query(LoyaltyCard) db.query(LoyaltyCard)
.options(joinedload(LoyaltyCard.program)) .options(joinedload(LoyaltyCard.program), joinedload(LoyaltyCard.company))
.filter(LoyaltyCard.customer_id == customer_id) .filter(LoyaltyCard.customer_id == customer_id)
.all() .all()
) )
@@ -194,18 +278,18 @@ class CardService:
self, self,
db: Session, db: Session,
customer_id: int, customer_id: int,
vendor_id: int, company_id: int,
*, *,
program_id: int | None = None, enrolled_at_vendor_id: int | None = None,
) -> LoyaltyCard: ) -> LoyaltyCard:
""" """
Enroll a customer in a loyalty program. Enroll a customer in a company's loyalty program.
Args: Args:
db: Database session db: Database session
customer_id: Customer ID customer_id: Customer ID
vendor_id: Vendor ID company_id: Company ID
program_id: Optional program ID (defaults to vendor's program) enrolled_at_vendor_id: Vendor where customer enrolled (for analytics)
Returns: Returns:
Created loyalty card Created loyalty card
@@ -216,35 +300,29 @@ class CardService:
LoyaltyCardAlreadyExistsException: If customer already enrolled LoyaltyCardAlreadyExistsException: If customer already enrolled
""" """
# Get the program # Get the program
if program_id: program = (
program = ( db.query(LoyaltyProgram)
db.query(LoyaltyProgram) .filter(LoyaltyProgram.company_id == company_id)
.filter(LoyaltyProgram.id == program_id) .first()
.first() )
)
else:
program = (
db.query(LoyaltyProgram)
.filter(LoyaltyProgram.vendor_id == vendor_id)
.first()
)
if not program: if not program:
raise LoyaltyProgramNotFoundException(f"vendor:{vendor_id}") raise LoyaltyProgramNotFoundException(f"company:{company_id}")
if not program.is_active: if not program.is_active:
raise LoyaltyProgramInactiveException(program.id) raise LoyaltyProgramInactiveException(program.id)
# Check if customer already has a card # 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: if existing:
raise LoyaltyCardAlreadyExistsException(customer_id, program.id) raise LoyaltyCardAlreadyExistsException(customer_id, program.id)
# Create the card # Create the card
card = LoyaltyCard( card = LoyaltyCard(
company_id=company_id,
customer_id=customer_id, customer_id=customer_id,
program_id=program.id, program_id=program.id,
vendor_id=vendor_id, enrolled_at_vendor_id=enrolled_at_vendor_id,
) )
db.add(card) db.add(card)
@@ -252,32 +330,88 @@ class CardService:
# Create enrollment transaction # Create enrollment transaction
transaction = LoyaltyTransaction( transaction = LoyaltyTransaction(
company_id=company_id,
card_id=card.id, card_id=card.id,
vendor_id=vendor_id, vendor_id=enrolled_at_vendor_id,
transaction_type=TransactionType.CARD_CREATED.value, transaction_type=TransactionType.CARD_CREATED.value,
transaction_at=datetime.now(UTC), transaction_at=datetime.now(UTC),
) )
db.add(transaction) 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.commit()
db.refresh(card) db.refresh(card)
logger.info( logger.info(
f"Enrolled customer {customer_id} in loyalty program {program.id} " f"Enrolled customer {customer_id} in company {company_id} loyalty program "
f"(card: {card.card_number})" f"(card: {card.card_number}, bonus: {program.welcome_bonus_points} pts)"
) )
return card 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.""" """Deactivate a loyalty card."""
card = self.require_card(db, card_id) card = self.require_card(db, card_id)
card.is_active = False card.is_active = False
# Create deactivation transaction # Create deactivation transaction
transaction = LoyaltyTransaction( transaction = LoyaltyTransaction(
company_id=card.company_id,
card_id=card.id, card_id=card.id,
vendor_id=card.vendor_id, vendor_id=vendor_id,
transaction_type=TransactionType.CARD_DEACTIVATED.value, transaction_type=TransactionType.CARD_DEACTIVATED.value,
transaction_at=datetime.now(UTC), transaction_at=datetime.now(UTC),
) )
@@ -334,6 +468,7 @@ class CardService:
"""Get transaction history for a card.""" """Get transaction history for a card."""
query = ( query = (
db.query(LoyaltyTransaction) db.query(LoyaltyTransaction)
.options(joinedload(LoyaltyTransaction.vendor))
.filter(LoyaltyTransaction.card_id == card_id) .filter(LoyaltyTransaction.card_id == card_id)
.order_by(LoyaltyTransaction.transaction_at.desc()) .order_by(LoyaltyTransaction.transaction_at.desc())
) )

View File

@@ -2,9 +2,14 @@
""" """
Staff PIN service. 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: Handles PIN operations including:
- PIN creation and management - PIN creation and management
- PIN verification with lockout - PIN verification with lockout (per vendor)
- PIN security (failed attempts, lockout) - PIN security (failed attempts, lockout)
""" """
@@ -41,16 +46,17 @@ class PinService:
db: Session, db: Session,
program_id: int, program_id: int,
staff_id: str, staff_id: str,
*,
vendor_id: int | None = None,
) -> StaffPin | None: ) -> StaffPin | None:
"""Get a staff PIN by employee ID.""" """Get a staff PIN by employee ID."""
return ( query = db.query(StaffPin).filter(
db.query(StaffPin) StaffPin.program_id == program_id,
.filter( StaffPin.staff_id == staff_id,
StaffPin.program_id == program_id,
StaffPin.staff_id == staff_id,
)
.first()
) )
if vendor_id:
query = query.filter(StaffPin.vendor_id == vendor_id)
return query.first()
def require_pin(self, db: Session, pin_id: int) -> StaffPin: def require_pin(self, db: Session, pin_id: int) -> StaffPin:
"""Get a PIN or raise exception if not found.""" """Get a PIN or raise exception if not found."""
@@ -64,16 +70,61 @@ class PinService:
db: Session, db: Session,
program_id: int, program_id: int,
*, *,
vendor_id: int | None = None,
is_active: bool | None = None, is_active: bool | None = None,
) -> list[StaffPin]: ) -> 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) 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: if is_active is not None:
query = query.filter(StaffPin.is_active == is_active) query = query.filter(StaffPin.is_active == is_active)
return query.order_by(StaffPin.name).all() 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 # Write Operations
# ========================================================================= # =========================================================================
@@ -91,13 +142,21 @@ class PinService:
Args: Args:
db: Database session db: Database session
program_id: Program ID program_id: Program ID
vendor_id: Vendor ID vendor_id: Vendor ID (location where staff works)
data: PIN creation data data: PIN creation data
Returns: Returns:
Created PIN 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( pin = StaffPin(
company_id=program.company_id,
program_id=program_id, program_id=program_id,
vendor_id=vendor_id, vendor_id=vendor_id,
name=data.name, name=data.name,
@@ -109,7 +168,9 @@ class PinService:
db.commit() db.commit()
db.refresh(pin) 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 return pin
@@ -158,11 +219,12 @@ class PinService:
"""Delete a staff PIN.""" """Delete a staff PIN."""
pin = self.require_pin(db, pin_id) pin = self.require_pin(db, pin_id)
program_id = pin.program_id program_id = pin.program_id
vendor_id = pin.vendor_id
db.delete(pin) db.delete(pin)
db.commit() 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: def unlock_pin(self, db: Session, pin_id: int) -> StaffPin:
"""Unlock a locked staff PIN.""" """Unlock a locked staff PIN."""
@@ -184,16 +246,21 @@ class PinService:
db: Session, db: Session,
program_id: int, program_id: int,
plain_pin: str, plain_pin: str,
*,
vendor_id: int | None = None,
) -> StaffPin: ) -> StaffPin:
""" """
Verify a staff PIN. 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: Args:
db: Database session db: Database session
program_id: Program ID program_id: Program ID
plain_pin: Plain text PIN to verify plain_pin: Plain text PIN to verify
vendor_id: Optional vendor ID to restrict PIN lookup
Returns: Returns:
Verified StaffPin object Verified StaffPin object
@@ -202,8 +269,8 @@ class PinService:
InvalidStaffPinException: PIN is invalid InvalidStaffPinException: PIN is invalid
StaffPinLockedException: PIN is locked StaffPinLockedException: PIN is locked
""" """
# Get all active PINs for the program # Get active PINs (optionally filtered by vendor)
pins = self.list_pins(db, program_id, is_active=True) pins = self.list_pins(db, program_id, vendor_id=vendor_id, is_active=True)
if not pins: if not pins:
raise InvalidStaffPinException() raise InvalidStaffPinException()
@@ -220,7 +287,9 @@ class PinService:
pin.record_success() pin.record_success()
db.commit() 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 return pin
@@ -254,6 +323,8 @@ class PinService:
db: Session, db: Session,
program_id: int, program_id: int,
plain_pin: str, plain_pin: str,
*,
vendor_id: int | None = None,
) -> StaffPin | None: ) -> StaffPin | None:
""" """
Find a matching PIN without recording attempts. Find a matching PIN without recording attempts.
@@ -264,11 +335,12 @@ class PinService:
db: Database session db: Database session
program_id: Program ID program_id: Program ID
plain_pin: Plain text PIN to check plain_pin: Plain text PIN to check
vendor_id: Optional vendor ID to restrict lookup
Returns: Returns:
Matching StaffPin or None 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: for pin in pins:
if not pin.is_locked and pin.verify_pin(plain_pin): if not pin.is_locked and pin.verify_pin(plain_pin):

View File

@@ -2,9 +2,15 @@
""" """
Points service. 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: Handles points operations including:
- Earning points from purchases - Earning points from purchases
- Redeeming points for rewards - Redeeming points for rewards
- Voiding points (for returns)
- Points balance management - Points balance management
""" """
@@ -34,6 +40,7 @@ class PointsService:
self, self,
db: Session, db: Session,
*, *,
vendor_id: int,
card_id: int | None = None, card_id: int | None = None,
qr_code: str | None = None, qr_code: str | None = None,
card_number: str | None = None, card_number: str | None = None,
@@ -51,6 +58,7 @@ class PointsService:
Args: Args:
db: Database session db: Database session
vendor_id: Vendor ID (where purchase is being made)
card_id: Card ID card_id: Card ID
qr_code: QR code data qr_code: QR code data
card_number: Card number card_number: Card number
@@ -64,9 +72,10 @@ class PointsService:
Returns: Returns:
Dict with operation result Dict with operation result
""" """
# Look up the card # Look up the card (validates it belongs to vendor's company)
card = card_service.lookup_card( card = card_service.lookup_card_for_vendor(
db, db,
vendor_id,
card_id=card_id, card_id=card_id,
qr_code=qr_code, qr_code=qr_code,
card_number=card_number, card_number=card_number,
@@ -85,12 +94,26 @@ class PointsService:
logger.warning(f"Points attempted on stamps-only program {program.id}") logger.warning(f"Points attempted on stamps-only program {program.id}")
raise LoyaltyCardInactiveException(card.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 # Verify staff PIN if required
verified_pin = None verified_pin = None
if program.require_staff_pin: if program.require_staff_pin:
if not staff_pin: if not staff_pin:
raise StaffPinRequiredException() 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 # Calculate points
# points_per_euro is per full euro, so divide cents by 100 # points_per_euro is per full euro, so divide cents by 100
@@ -115,11 +138,13 @@ class PointsService:
card.points_balance += points_earned card.points_balance += points_earned
card.total_points_earned += points_earned card.total_points_earned += points_earned
card.last_points_at = now card.last_points_at = now
card.last_activity_at = now
# Create transaction # Create transaction
transaction = LoyaltyTransaction( transaction = LoyaltyTransaction(
company_id=card.company_id,
card_id=card.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, staff_pin_id=verified_pin.id if verified_pin else None,
transaction_type=TransactionType.POINTS_EARNED.value, transaction_type=TransactionType.POINTS_EARNED.value,
points_delta=points_earned, points_delta=points_earned,
@@ -138,7 +163,7 @@ class PointsService:
db.refresh(card) db.refresh(card)
logger.info( 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})" f"(purchase: €{purchase_euros:.2f}, balance: {card.points_balance})"
) )
@@ -152,12 +177,14 @@ class PointsService:
"card_number": card.card_number, "card_number": card.card_number,
"points_balance": card.points_balance, "points_balance": card.points_balance,
"total_points_earned": card.total_points_earned, "total_points_earned": card.total_points_earned,
"vendor_id": vendor_id,
} }
def redeem_points( def redeem_points(
self, self,
db: Session, db: Session,
*, *,
vendor_id: int,
card_id: int | None = None, card_id: int | None = None,
qr_code: str | None = None, qr_code: str | None = None,
card_number: str | None = None, card_number: str | None = None,
@@ -172,6 +199,7 @@ class PointsService:
Args: Args:
db: Database session db: Database session
vendor_id: Vendor ID (where redemption is happening)
card_id: Card ID card_id: Card ID
qr_code: QR code data qr_code: QR code data
card_number: Card number card_number: Card number
@@ -188,9 +216,10 @@ class PointsService:
InvalidRewardException: Reward not found or inactive InvalidRewardException: Reward not found or inactive
InsufficientPointsException: Not enough points InsufficientPointsException: Not enough points
""" """
# Look up the card # Look up the card (validates it belongs to vendor's company)
card = card_service.lookup_card( card = card_service.lookup_card_for_vendor(
db, db,
vendor_id,
card_id=card_id, card_id=card_id,
qr_code=qr_code, qr_code=qr_code,
card_number=card_number, card_number=card_number,
@@ -215,6 +244,10 @@ class PointsService:
points_required = reward["points_required"] points_required = reward["points_required"]
reward_name = reward["name"] reward_name = reward["name"]
# Check minimum redemption
if points_required < program.minimum_redemption_points:
raise InvalidRewardException(reward_id)
# Check if enough points # Check if enough points
if card.points_balance < points_required: if card.points_balance < points_required:
raise InsufficientPointsException(card.points_balance, points_required) raise InsufficientPointsException(card.points_balance, points_required)
@@ -224,18 +257,20 @@ class PointsService:
if program.require_staff_pin: if program.require_staff_pin:
if not staff_pin: if not staff_pin:
raise StaffPinRequiredException() 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 # Redeem points
now = datetime.now(UTC) now = datetime.now(UTC)
card.points_balance -= points_required card.points_balance -= points_required
card.points_redeemed += points_required card.points_redeemed += points_required
card.last_redemption_at = now card.last_redemption_at = now
card.last_activity_at = now
# Create transaction # Create transaction
transaction = LoyaltyTransaction( transaction = LoyaltyTransaction(
company_id=card.company_id,
card_id=card.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, staff_pin_id=verified_pin.id if verified_pin else None,
transaction_type=TransactionType.POINTS_REDEEMED.value, transaction_type=TransactionType.POINTS_REDEEMED.value,
points_delta=-points_required, points_delta=-points_required,
@@ -254,7 +289,7 @@ class PointsService:
db.refresh(card) db.refresh(card)
logger.info( 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})" f"(reward: {reward_name}, balance: {card.points_balance})"
) )
@@ -268,6 +303,140 @@ class PointsService:
"card_number": card.card_number, "card_number": card.card_number,
"points_balance": card.points_balance, "points_balance": card.points_balance,
"total_points_redeemed": card.points_redeemed, "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( def adjust_points(
@@ -276,18 +445,20 @@ class PointsService:
card_id: int, card_id: int,
points_delta: int, points_delta: int,
*, *,
vendor_id: int | None = None,
reason: str, reason: str,
staff_pin: str | None = None, staff_pin: str | None = None,
ip_address: str | None = None, ip_address: str | None = None,
user_agent: str | None = None, user_agent: str | None = None,
) -> dict: ) -> dict:
""" """
Manually adjust points (admin operation). Manually adjust points (admin/vendor operation).
Args: Args:
db: Database session db: Database session
card_id: Card ID card_id: Card ID
points_delta: Points to add (positive) or remove (negative) points_delta: Points to add (positive) or remove (negative)
vendor_id: Vendor ID
reason: Reason for adjustment reason: Reason for adjustment
staff_pin: Staff PIN for verification staff_pin: Staff PIN for verification
ip_address: Request IP for audit ip_address: Request IP for audit
@@ -299,14 +470,15 @@ class PointsService:
card = card_service.require_card(db, card_id) card = card_service.require_card(db, card_id)
program = card.program program = card.program
# Verify staff PIN if required # Verify staff PIN if required and vendor provided
verified_pin = None verified_pin = None
if program.require_staff_pin and staff_pin: if program.require_staff_pin and staff_pin and vendor_id:
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)
# Apply adjustment # Apply adjustment
now = datetime.now(UTC) now = datetime.now(UTC)
card.points_balance += points_delta card.points_balance += points_delta
card.last_activity_at = now
if points_delta > 0: if points_delta > 0:
card.total_points_earned += points_delta card.total_points_earned += points_delta
@@ -320,8 +492,9 @@ class PointsService:
# Create transaction # Create transaction
transaction = LoyaltyTransaction( transaction = LoyaltyTransaction(
company_id=card.company_id,
card_id=card.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, staff_pin_id=verified_pin.id if verified_pin else None,
transaction_type=TransactionType.POINTS_ADJUSTMENT.value, transaction_type=TransactionType.POINTS_ADJUSTMENT.value,
points_delta=points_delta, points_delta=points_delta,

View File

@@ -2,6 +2,11 @@
""" """
Loyalty program service. 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: Handles CRUD operations for loyalty programs including:
- Program creation and configuration - Program creation and configuration
- Program updates - Program updates
@@ -18,7 +23,11 @@ from app.modules.loyalty.exceptions import (
LoyaltyProgramAlreadyExistsException, LoyaltyProgramAlreadyExistsException,
LoyaltyProgramNotFoundException, 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 ( from app.modules.loyalty.schemas.program import (
ProgramCreate, ProgramCreate,
ProgramUpdate, ProgramUpdate,
@@ -42,25 +51,53 @@ class ProgramService:
.first() .first()
) )
def get_program_by_vendor(self, db: Session, vendor_id: int) -> LoyaltyProgram | None: def get_program_by_company(self, db: Session, company_id: int) -> LoyaltyProgram | None:
"""Get a vendor's loyalty program.""" """Get a company's loyalty program."""
return ( return (
db.query(LoyaltyProgram) db.query(LoyaltyProgram)
.filter(LoyaltyProgram.vendor_id == vendor_id) .filter(LoyaltyProgram.company_id == company_id)
.first() .first()
) )
def get_active_program_by_vendor(self, db: Session, vendor_id: int) -> LoyaltyProgram | None: def get_active_program_by_company(self, db: Session, company_id: int) -> LoyaltyProgram | None:
"""Get a vendor's active loyalty program.""" """Get a company's active loyalty program."""
return ( return (
db.query(LoyaltyProgram) db.query(LoyaltyProgram)
.filter( .filter(
LoyaltyProgram.vendor_id == vendor_id, LoyaltyProgram.company_id == company_id,
LoyaltyProgram.is_active == True, LoyaltyProgram.is_active == True,
) )
.first() .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: def require_program(self, db: Session, program_id: int) -> LoyaltyProgram:
"""Get a program or raise exception if not found.""" """Get a program or raise exception if not found."""
program = self.get_program(db, program_id) program = self.get_program(db, program_id)
@@ -68,6 +105,13 @@ class ProgramService:
raise LoyaltyProgramNotFoundException(str(program_id)) raise LoyaltyProgramNotFoundException(str(program_id))
return program 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: def require_program_by_vendor(self, db: Session, vendor_id: int) -> LoyaltyProgram:
"""Get a vendor's program or raise exception if not found.""" """Get a vendor's program or raise exception if not found."""
program = self.get_program_by_vendor(db, vendor_id) program = self.get_program_by_vendor(db, vendor_id)
@@ -82,15 +126,32 @@ class ProgramService:
skip: int = 0, skip: int = 0,
limit: int = 100, limit: int = 100,
is_active: bool | None = None, is_active: bool | None = None,
search: str | None = None,
) -> tuple[list[LoyaltyProgram], int]: ) -> tuple[list[LoyaltyProgram], int]:
"""List all loyalty programs (admin).""" """List all loyalty programs (admin).
query = db.query(LoyaltyProgram)
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: if is_active is not None:
query = query.filter(LoyaltyProgram.is_active == is_active) 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() 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 return programs, total
@@ -101,33 +162,33 @@ class ProgramService:
def create_program( def create_program(
self, self,
db: Session, db: Session,
vendor_id: int, company_id: int,
data: ProgramCreate, data: ProgramCreate,
) -> LoyaltyProgram: ) -> LoyaltyProgram:
""" """
Create a new loyalty program for a vendor. Create a new loyalty program for a company.
Args: Args:
db: Database session db: Database session
vendor_id: Vendor ID company_id: Company ID
data: Program configuration data: Program configuration
Returns: Returns:
Created program Created program
Raises: Raises:
LoyaltyProgramAlreadyExistsException: If vendor already has a program LoyaltyProgramAlreadyExistsException: If company already has a program
""" """
# Check if vendor already has a program # Check if company already has a program
existing = self.get_program_by_vendor(db, vendor_id) existing = self.get_program_by_company(db, company_id)
if existing: if existing:
raise LoyaltyProgramAlreadyExistsException(vendor_id) raise LoyaltyProgramAlreadyExistsException(company_id)
# Convert points_rewards to dict list for JSON storage # Convert points_rewards to dict list for JSON storage
points_rewards_data = [r.model_dump() for r in data.points_rewards] points_rewards_data = [r.model_dump() for r in data.points_rewards]
program = LoyaltyProgram( program = LoyaltyProgram(
vendor_id=vendor_id, company_id=company_id,
loyalty_type=data.loyalty_type, loyalty_type=data.loyalty_type,
# Stamps # Stamps
stamps_target=data.stamps_target, stamps_target=data.stamps_target,
@@ -136,6 +197,10 @@ class ProgramService:
# Points # Points
points_per_euro=data.points_per_euro, points_per_euro=data.points_per_euro,
points_rewards=points_rewards_data, 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 # Anti-fraud
cooldown_minutes=data.cooldown_minutes, cooldown_minutes=data.cooldown_minutes,
max_daily_stamps=data.max_daily_stamps, max_daily_stamps=data.max_daily_stamps,
@@ -155,11 +220,19 @@ class ProgramService:
) )
db.add(program) db.add(program)
db.flush()
# Create default company settings
settings = CompanyLoyaltySettings(
company_id=company_id,
)
db.add(settings)
db.commit() db.commit()
db.refresh(program) db.refresh(program)
logger.info( 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})" f"(type: {program.loyalty_type})"
) )
@@ -224,12 +297,39 @@ class ProgramService:
def delete_program(self, db: Session, program_id: int) -> None: def delete_program(self, db: Session, program_id: int) -> None:
"""Delete a loyalty program and all associated data.""" """Delete a loyalty program and all associated data."""
program = self.require_program(db, program_id) 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.delete(program)
db.commit() 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 # Statistics
@@ -374,6 +474,196 @@ class ProgramService:
"estimated_liability_cents": estimated_liability, "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 # Singleton instance
program_service = ProgramService() program_service = ProgramService()

View File

@@ -2,9 +2,15 @@
""" """
Stamp service. 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: Handles stamp operations including:
- Adding stamps with anti-fraud checks - Adding stamps with anti-fraud checks
- Redeeming stamps for rewards - Redeeming stamps for rewards
- Voiding stamps (for returns)
- Daily limit tracking - Daily limit tracking
""" """
@@ -36,6 +42,7 @@ class StampService:
self, self,
db: Session, db: Session,
*, *,
vendor_id: int,
card_id: int | None = None, card_id: int | None = None,
qr_code: str | None = None, qr_code: str | None = None,
card_number: str | None = None, card_number: str | None = None,
@@ -54,6 +61,7 @@ class StampService:
Args: Args:
db: Database session db: Database session
vendor_id: Vendor ID (where stamp is being added)
card_id: Card ID card_id: Card ID
qr_code: QR code data qr_code: QR code data
card_number: Card number card_number: Card number
@@ -74,9 +82,10 @@ class StampService:
StampCooldownException: Cooldown period not elapsed StampCooldownException: Cooldown period not elapsed
DailyStampLimitException: Daily limit reached DailyStampLimitException: Daily limit reached
""" """
# Look up the card # Look up the card (validates it belongs to vendor's company)
card = card_service.lookup_card( card = card_service.lookup_card_for_vendor(
db, db,
vendor_id,
card_id=card_id, card_id=card_id,
qr_code=qr_code, qr_code=qr_code,
card_number=card_number, card_number=card_number,
@@ -100,7 +109,7 @@ class StampService:
if program.require_staff_pin: if program.require_staff_pin:
if not staff_pin: if not staff_pin:
raise StaffPinRequiredException() 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 # Check cooldown
now = datetime.now(UTC) now = datetime.now(UTC)
@@ -121,14 +130,16 @@ class StampService:
card.stamp_count += 1 card.stamp_count += 1
card.total_stamps_earned += 1 card.total_stamps_earned += 1
card.last_stamp_at = now card.last_stamp_at = now
card.last_activity_at = now
# Check if reward earned # Check if reward earned
reward_earned = card.stamp_count >= program.stamps_target reward_earned = card.stamp_count >= program.stamps_target
# Create transaction # Create transaction
transaction = LoyaltyTransaction( transaction = LoyaltyTransaction(
company_id=card.company_id,
card_id=card.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, staff_pin_id=verified_pin.id if verified_pin else None,
transaction_type=TransactionType.STAMP_EARNED.value, transaction_type=TransactionType.STAMP_EARNED.value,
stamps_delta=1, stamps_delta=1,
@@ -147,7 +158,7 @@ class StampService:
stamps_today += 1 stamps_today += 1
logger.info( 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"(stamps: {card.stamp_count}/{program.stamps_target}, "
f"today: {stamps_today}/{program.max_daily_stamps})" f"today: {stamps_today}/{program.max_daily_stamps})"
) )
@@ -168,12 +179,14 @@ class StampService:
"next_stamp_available_at": next_stamp_at, "next_stamp_available_at": next_stamp_at,
"stamps_today": stamps_today, "stamps_today": stamps_today,
"stamps_remaining_today": max(0, program.max_daily_stamps - stamps_today), "stamps_remaining_today": max(0, program.max_daily_stamps - stamps_today),
"vendor_id": vendor_id,
} }
def redeem_stamps( def redeem_stamps(
self, self,
db: Session, db: Session,
*, *,
vendor_id: int,
card_id: int | None = None, card_id: int | None = None,
qr_code: str | None = None, qr_code: str | None = None,
card_number: str | None = None, card_number: str | None = None,
@@ -187,6 +200,7 @@ class StampService:
Args: Args:
db: Database session db: Database session
vendor_id: Vendor ID (where redemption is happening)
card_id: Card ID card_id: Card ID
qr_code: QR code data qr_code: QR code data
card_number: Card number card_number: Card number
@@ -203,9 +217,10 @@ class StampService:
InsufficientStampsException: Not enough stamps InsufficientStampsException: Not enough stamps
StaffPinRequiredException: PIN required but not provided StaffPinRequiredException: PIN required but not provided
""" """
# Look up the card # Look up the card (validates it belongs to vendor's company)
card = card_service.lookup_card( card = card_service.lookup_card_for_vendor(
db, db,
vendor_id,
card_id=card_id, card_id=card_id,
qr_code=qr_code, qr_code=qr_code,
card_number=card_number, card_number=card_number,
@@ -228,7 +243,7 @@ class StampService:
if program.require_staff_pin: if program.require_staff_pin:
if not staff_pin: if not staff_pin:
raise StaffPinRequiredException() 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 # Redeem stamps
now = datetime.now(UTC) now = datetime.now(UTC)
@@ -236,11 +251,13 @@ class StampService:
card.stamp_count -= stamps_redeemed card.stamp_count -= stamps_redeemed
card.stamps_redeemed += 1 card.stamps_redeemed += 1
card.last_redemption_at = now card.last_redemption_at = now
card.last_activity_at = now
# Create transaction # Create transaction
transaction = LoyaltyTransaction( transaction = LoyaltyTransaction(
company_id=card.company_id,
card_id=card.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, staff_pin_id=verified_pin.id if verified_pin else None,
transaction_type=TransactionType.STAMP_REDEEMED.value, transaction_type=TransactionType.STAMP_REDEEMED.value,
stamps_delta=-stamps_redeemed, stamps_delta=-stamps_redeemed,
@@ -258,7 +275,7 @@ class StampService:
db.refresh(card) db.refresh(card)
logger.info( 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"(reward: {program.stamps_reward_description}, "
f"total redemptions: {card.stamps_redeemed})" f"total redemptions: {card.stamps_redeemed})"
) )
@@ -272,6 +289,125 @@ class StampService:
"stamps_target": program.stamps_target, "stamps_target": program.stamps_target,
"reward_description": program.stamps_reward_description, "reward_description": program.stamps_reward_description,
"total_redemptions": card.stamps_redeemed, "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. Loyalty module Celery tasks.
Background tasks for: Background tasks for:
- Point expiration - Point expiration (daily at 02:00)
- Wallet synchronization - 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. Point expiration task.
Handles expiring points that are older than the configured 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 import logging
from datetime import UTC, datetime, timedelta
from celery import shared_task 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__) logger = logging.getLogger(__name__)
@@ -16,26 +24,175 @@ logger = logging.getLogger(__name__)
@shared_task(name="loyalty.expire_points") @shared_task(name="loyalty.expire_points")
def expire_points() -> dict: 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 For each program with points_expiration_days configured:
can be configured to expire after a certain period. 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: Returns:
Summary of expired points Summary of expired points
""" """
# Future implementation: logger.info("Starting point expiration task...")
# 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("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 { return {
"status": "success", "status": "success",
"cards_processed": 0, "programs_processed": programs_processed,
"points_expired": 0, "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 %}