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:
@@ -12,8 +12,10 @@ Usage:
|
||||
LoyaltyTransaction,
|
||||
StaffPin,
|
||||
AppleDeviceRegistration,
|
||||
CompanyLoyaltySettings,
|
||||
LoyaltyType,
|
||||
TransactionType,
|
||||
StaffPinPolicy,
|
||||
)
|
||||
"""
|
||||
|
||||
@@ -41,15 +43,23 @@ from app.modules.loyalty.models.apple_device import (
|
||||
# Model
|
||||
AppleDeviceRegistration,
|
||||
)
|
||||
from app.modules.loyalty.models.company_settings import (
|
||||
# Enums
|
||||
StaffPinPolicy,
|
||||
# Model
|
||||
CompanyLoyaltySettings,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Enums
|
||||
"LoyaltyType",
|
||||
"TransactionType",
|
||||
"StaffPinPolicy",
|
||||
# Models
|
||||
"LoyaltyProgram",
|
||||
"LoyaltyCard",
|
||||
"LoyaltyTransaction",
|
||||
"StaffPin",
|
||||
"AppleDeviceRegistration",
|
||||
"CompanyLoyaltySettings",
|
||||
]
|
||||
|
||||
135
app/modules/loyalty/models/company_settings.py
Normal file
135
app/modules/loyalty/models/company_settings.py
Normal 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
|
||||
@@ -2,11 +2,16 @@
|
||||
"""
|
||||
Loyalty card database model.
|
||||
|
||||
Company-based loyalty cards:
|
||||
- Cards belong to a Company's loyalty program
|
||||
- Customers can earn and redeem at any vendor within the company
|
||||
- Tracks where customer enrolled for analytics
|
||||
|
||||
Represents a customer's loyalty card (PassObject) that tracks:
|
||||
- Stamp count and history
|
||||
- Points balance and history
|
||||
- Wallet integration (Google/Apple pass IDs)
|
||||
- QR code for scanning
|
||||
- QR code/barcode for scanning
|
||||
"""
|
||||
|
||||
import secrets
|
||||
@@ -28,8 +33,9 @@ from models.database.base import TimestampMixin
|
||||
|
||||
|
||||
def generate_card_number() -> str:
|
||||
"""Generate a unique 12-digit card number."""
|
||||
return "".join([str(secrets.randbelow(10)) for _ in range(12)])
|
||||
"""Generate a unique 12-digit card number formatted as XXXX-XXXX-XXXX."""
|
||||
digits = "".join([str(secrets.randbelow(10)) for _ in range(12)])
|
||||
return f"{digits[:4]}-{digits[4:8]}-{digits[8:]}"
|
||||
|
||||
|
||||
def generate_qr_code_data() -> str:
|
||||
@@ -46,7 +52,10 @@ class LoyaltyCard(Base, TimestampMixin):
|
||||
"""
|
||||
Customer's loyalty card (PassObject).
|
||||
|
||||
Links a customer to a vendor's loyalty program and tracks:
|
||||
Card belongs to a Company's loyalty program.
|
||||
The customer can earn and redeem at any vendor within the company.
|
||||
|
||||
Links a customer to a company's loyalty program and tracks:
|
||||
- Stamps and points balances
|
||||
- Wallet pass integration
|
||||
- Activity timestamps
|
||||
@@ -56,7 +65,16 @@ class LoyaltyCard(Base, TimestampMixin):
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
# Relationships
|
||||
# Company association (card belongs to company's program)
|
||||
company_id = Column(
|
||||
Integer,
|
||||
ForeignKey("companies.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="Company whose program this card belongs to",
|
||||
)
|
||||
|
||||
# Customer and program relationships
|
||||
customer_id = Column(
|
||||
Integer,
|
||||
ForeignKey("customers.id", ondelete="CASCADE"),
|
||||
@@ -69,12 +87,14 @@ class LoyaltyCard(Base, TimestampMixin):
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
vendor_id = Column(
|
||||
|
||||
# Track where customer enrolled (for analytics)
|
||||
enrolled_at_vendor_id = Column(
|
||||
Integer,
|
||||
ForeignKey("vendors.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
ForeignKey("vendors.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
comment="Denormalized for query performance",
|
||||
comment="Vendor where customer enrolled (for analytics)",
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
@@ -86,7 +106,7 @@ class LoyaltyCard(Base, TimestampMixin):
|
||||
nullable=False,
|
||||
default=generate_card_number,
|
||||
index=True,
|
||||
comment="Human-readable card number",
|
||||
comment="Human-readable card number (XXXX-XXXX-XXXX)",
|
||||
)
|
||||
qr_code_data = Column(
|
||||
String(50),
|
||||
@@ -183,13 +203,18 @@ class LoyaltyCard(Base, TimestampMixin):
|
||||
last_points_at = Column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
comment="Last points earned",
|
||||
comment="Last points earned (for expiration tracking)",
|
||||
)
|
||||
last_redemption_at = Column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
comment="Last reward redemption",
|
||||
)
|
||||
last_activity_at = Column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
comment="Any activity (for expiration calculation)",
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# Status
|
||||
@@ -204,9 +229,13 @@ class LoyaltyCard(Base, TimestampMixin):
|
||||
# =========================================================================
|
||||
# Relationships
|
||||
# =========================================================================
|
||||
company = relationship("Company", backref="loyalty_cards")
|
||||
customer = relationship("Customer", backref="loyalty_cards")
|
||||
program = relationship("LoyaltyProgram", back_populates="cards")
|
||||
vendor = relationship("Vendor", backref="loyalty_cards")
|
||||
enrolled_at_vendor = relationship(
|
||||
"Vendor",
|
||||
backref="enrolled_loyalty_cards",
|
||||
)
|
||||
transactions = relationship(
|
||||
"LoyaltyTransaction",
|
||||
back_populates="card",
|
||||
@@ -219,14 +248,15 @@ class LoyaltyCard(Base, TimestampMixin):
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
# Indexes
|
||||
# Indexes - one card per customer per company
|
||||
__table_args__ = (
|
||||
Index("idx_loyalty_card_company_customer", "company_id", "customer_id", unique=True),
|
||||
Index("idx_loyalty_card_company_active", "company_id", "is_active"),
|
||||
Index("idx_loyalty_card_customer_program", "customer_id", "program_id", unique=True),
|
||||
Index("idx_loyalty_card_vendor_active", "vendor_id", "is_active"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<LoyaltyCard(id={self.id}, card_number='{self.card_number}', stamps={self.stamp_count})>"
|
||||
return f"<LoyaltyCard(id={self.id}, card_number='{self.card_number}', stamps={self.stamp_count}, points={self.points_balance})>"
|
||||
|
||||
# =========================================================================
|
||||
# Stamp Operations
|
||||
@@ -241,6 +271,7 @@ class LoyaltyCard(Base, TimestampMixin):
|
||||
self.stamp_count += 1
|
||||
self.total_stamps_earned += 1
|
||||
self.last_stamp_at = datetime.now(UTC)
|
||||
self.last_activity_at = datetime.now(UTC)
|
||||
|
||||
# Check if reward cycle is complete (handled by caller with program.stamps_target)
|
||||
return False # Caller checks against program.stamps_target
|
||||
@@ -258,6 +289,7 @@ class LoyaltyCard(Base, TimestampMixin):
|
||||
self.stamp_count -= stamps_target
|
||||
self.stamps_redeemed += 1
|
||||
self.last_redemption_at = datetime.now(UTC)
|
||||
self.last_activity_at = datetime.now(UTC)
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -270,6 +302,7 @@ class LoyaltyCard(Base, TimestampMixin):
|
||||
self.points_balance += points
|
||||
self.total_points_earned += points
|
||||
self.last_points_at = datetime.now(UTC)
|
||||
self.last_activity_at = datetime.now(UTC)
|
||||
|
||||
def redeem_points(self, points: int) -> bool:
|
||||
"""
|
||||
@@ -281,9 +314,29 @@ class LoyaltyCard(Base, TimestampMixin):
|
||||
self.points_balance -= points
|
||||
self.points_redeemed += points
|
||||
self.last_redemption_at = datetime.now(UTC)
|
||||
self.last_activity_at = datetime.now(UTC)
|
||||
return True
|
||||
return False
|
||||
|
||||
def void_points(self, points: int) -> None:
|
||||
"""
|
||||
Void points (for returns).
|
||||
|
||||
Args:
|
||||
points: Number of points to void
|
||||
"""
|
||||
self.points_balance = max(0, self.points_balance - points)
|
||||
self.last_activity_at = datetime.now(UTC)
|
||||
|
||||
def expire_points(self, points: int) -> None:
|
||||
"""
|
||||
Expire points due to inactivity.
|
||||
|
||||
Args:
|
||||
points: Number of points to expire
|
||||
"""
|
||||
self.points_balance = max(0, self.points_balance - points)
|
||||
|
||||
# =========================================================================
|
||||
# Properties
|
||||
# =========================================================================
|
||||
|
||||
@@ -2,7 +2,12 @@
|
||||
"""
|
||||
Loyalty program database model.
|
||||
|
||||
Defines the vendor's loyalty program configuration including:
|
||||
Company-based loyalty program configuration:
|
||||
- Program belongs to Company (one program per company)
|
||||
- All vendors under a company share the same loyalty program
|
||||
- Customers earn and redeem points at any location (vendor) within the company
|
||||
|
||||
Defines:
|
||||
- Program type (stamps, points, hybrid)
|
||||
- Stamp configuration (target, reward description)
|
||||
- Points configuration (per euro rate, rewards catalog)
|
||||
@@ -41,9 +46,13 @@ class LoyaltyType(str, enum.Enum):
|
||||
|
||||
class LoyaltyProgram(Base, TimestampMixin):
|
||||
"""
|
||||
Vendor's loyalty program configuration.
|
||||
Company's loyalty program configuration.
|
||||
|
||||
Each vendor can have one loyalty program that defines:
|
||||
Program belongs to Company (chain-wide shared points).
|
||||
All vendors under a company share the same loyalty program.
|
||||
Customers can earn and redeem at any vendor within the company.
|
||||
|
||||
Each company can have one loyalty program that defines:
|
||||
- Program type and mechanics
|
||||
- Stamp or points configuration
|
||||
- Anti-fraud rules
|
||||
@@ -54,19 +63,20 @@ class LoyaltyProgram(Base, TimestampMixin):
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
# Vendor association (one program per vendor)
|
||||
vendor_id = Column(
|
||||
# Company association (one program per company)
|
||||
company_id = Column(
|
||||
Integer,
|
||||
ForeignKey("vendors.id", ondelete="CASCADE"),
|
||||
ForeignKey("companies.id", ondelete="CASCADE"),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="Company that owns this program (chain-wide)",
|
||||
)
|
||||
|
||||
# Program type
|
||||
loyalty_type = Column(
|
||||
String(20),
|
||||
default=LoyaltyType.STAMPS.value,
|
||||
default=LoyaltyType.POINTS.value,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
@@ -96,9 +106,9 @@ class LoyaltyProgram(Base, TimestampMixin):
|
||||
# =========================================================================
|
||||
points_per_euro = Column(
|
||||
Integer,
|
||||
default=10,
|
||||
default=1,
|
||||
nullable=False,
|
||||
comment="Points earned per euro spent",
|
||||
comment="Points earned per euro spent (1 euro = X points)",
|
||||
)
|
||||
points_rewards = Column(
|
||||
JSON,
|
||||
@@ -107,6 +117,38 @@ class LoyaltyProgram(Base, TimestampMixin):
|
||||
comment="List of point rewards: [{id, name, points_required, description}]",
|
||||
)
|
||||
|
||||
# Points expiration and bonus settings
|
||||
points_expiration_days = Column(
|
||||
Integer,
|
||||
nullable=True,
|
||||
comment="Days of inactivity before points expire (None = never expire)",
|
||||
)
|
||||
welcome_bonus_points = Column(
|
||||
Integer,
|
||||
default=0,
|
||||
nullable=False,
|
||||
comment="Bonus points awarded on enrollment",
|
||||
)
|
||||
minimum_redemption_points = Column(
|
||||
Integer,
|
||||
default=100,
|
||||
nullable=False,
|
||||
comment="Minimum points required for any redemption",
|
||||
)
|
||||
minimum_purchase_cents = Column(
|
||||
Integer,
|
||||
default=0,
|
||||
nullable=False,
|
||||
comment="Minimum purchase amount (cents) to earn points (0 = no minimum)",
|
||||
)
|
||||
|
||||
# Future tier configuration (Bronze/Silver/Gold)
|
||||
tier_config = Column(
|
||||
JSON,
|
||||
nullable=True,
|
||||
comment='Future: Tier thresholds {"bronze": 0, "silver": 1000, "gold": 5000}',
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# Anti-Fraud Settings
|
||||
# =========================================================================
|
||||
@@ -151,7 +193,7 @@ class LoyaltyProgram(Base, TimestampMixin):
|
||||
logo_url = Column(
|
||||
String(500),
|
||||
nullable=True,
|
||||
comment="URL to vendor logo for card",
|
||||
comment="URL to company logo for card",
|
||||
)
|
||||
hero_image_url = Column(
|
||||
String(500),
|
||||
@@ -210,7 +252,7 @@ class LoyaltyProgram(Base, TimestampMixin):
|
||||
# =========================================================================
|
||||
# Relationships
|
||||
# =========================================================================
|
||||
vendor = relationship("Vendor", back_populates="loyalty_program")
|
||||
company = relationship("Company", backref="loyalty_program")
|
||||
cards = relationship(
|
||||
"LoyaltyCard",
|
||||
back_populates="program",
|
||||
@@ -224,11 +266,11 @@ class LoyaltyProgram(Base, TimestampMixin):
|
||||
|
||||
# Indexes
|
||||
__table_args__ = (
|
||||
Index("idx_loyalty_program_vendor_active", "vendor_id", "is_active"),
|
||||
Index("idx_loyalty_program_company_active", "company_id", "is_active"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<LoyaltyProgram(id={self.id}, vendor_id={self.vendor_id}, type='{self.loyalty_type}')>"
|
||||
return f"<LoyaltyProgram(id={self.id}, company_id={self.company_id}, type='{self.loyalty_type}')>"
|
||||
|
||||
# =========================================================================
|
||||
# Properties
|
||||
|
||||
@@ -2,9 +2,15 @@
|
||||
"""
|
||||
Loyalty transaction database model.
|
||||
|
||||
Company-based transaction tracking:
|
||||
- Tracks which company and vendor processed each transaction
|
||||
- Enables chain-wide reporting while maintaining per-location audit trails
|
||||
- Supports voiding transactions for returns
|
||||
|
||||
Records all loyalty events including:
|
||||
- Stamps earned and redeemed
|
||||
- Points earned and redeemed
|
||||
- Welcome bonuses and expirations
|
||||
- Associated metadata (staff PIN, purchase amount, IP, etc.)
|
||||
"""
|
||||
|
||||
@@ -31,10 +37,12 @@ class TransactionType(str, enum.Enum):
|
||||
# Stamps
|
||||
STAMP_EARNED = "stamp_earned"
|
||||
STAMP_REDEEMED = "stamp_redeemed"
|
||||
STAMP_VOIDED = "stamp_voided" # Stamps voided due to return
|
||||
|
||||
# Points
|
||||
POINTS_EARNED = "points_earned"
|
||||
POINTS_REDEEMED = "points_redeemed"
|
||||
POINTS_VOIDED = "points_voided" # Points voided due to return
|
||||
|
||||
# Adjustments (manual corrections by staff)
|
||||
STAMP_ADJUSTMENT = "stamp_adjustment"
|
||||
@@ -44,6 +52,10 @@ class TransactionType(str, enum.Enum):
|
||||
CARD_CREATED = "card_created"
|
||||
CARD_DEACTIVATED = "card_deactivated"
|
||||
|
||||
# Bonuses and expiration
|
||||
WELCOME_BONUS = "welcome_bonus" # Welcome bonus points on enrollment
|
||||
POINTS_EXPIRED = "points_expired" # Points expired due to inactivity
|
||||
|
||||
|
||||
class LoyaltyTransaction(Base, TimestampMixin):
|
||||
"""
|
||||
@@ -51,12 +63,25 @@ class LoyaltyTransaction(Base, TimestampMixin):
|
||||
|
||||
Immutable audit log of all loyalty operations for fraud
|
||||
detection, analytics, and customer support.
|
||||
|
||||
Tracks which vendor (location) processed the transaction,
|
||||
enabling chain-wide reporting while maintaining per-location
|
||||
audit trails.
|
||||
"""
|
||||
|
||||
__tablename__ = "loyalty_transactions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
# Company association
|
||||
company_id = Column(
|
||||
Integer,
|
||||
ForeignKey("companies.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="Company that owns the loyalty program",
|
||||
)
|
||||
|
||||
# Core relationships
|
||||
card_id = Column(
|
||||
Integer,
|
||||
@@ -66,10 +91,10 @@ class LoyaltyTransaction(Base, TimestampMixin):
|
||||
)
|
||||
vendor_id = Column(
|
||||
Integer,
|
||||
ForeignKey("vendors.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
ForeignKey("vendors.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
comment="Denormalized for query performance",
|
||||
comment="Vendor (location) that processed this transaction",
|
||||
)
|
||||
staff_pin_id = Column(
|
||||
Integer,
|
||||
@@ -79,6 +104,15 @@ class LoyaltyTransaction(Base, TimestampMixin):
|
||||
comment="Staff PIN used for this operation",
|
||||
)
|
||||
|
||||
# Related transaction (for voids/returns)
|
||||
related_transaction_id = Column(
|
||||
Integer,
|
||||
ForeignKey("loyalty_transactions.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
comment="Original transaction (for voids/returns)",
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# Transaction Details
|
||||
# =========================================================================
|
||||
@@ -175,15 +209,23 @@ class LoyaltyTransaction(Base, TimestampMixin):
|
||||
# =========================================================================
|
||||
# Relationships
|
||||
# =========================================================================
|
||||
company = relationship("Company", backref="loyalty_transactions")
|
||||
card = relationship("LoyaltyCard", back_populates="transactions")
|
||||
vendor = relationship("Vendor", backref="loyalty_transactions")
|
||||
staff_pin = relationship("StaffPin", backref="transactions")
|
||||
related_transaction = relationship(
|
||||
"LoyaltyTransaction",
|
||||
remote_side=[id],
|
||||
backref="voiding_transactions",
|
||||
)
|
||||
|
||||
# Indexes
|
||||
__table_args__ = (
|
||||
Index("idx_loyalty_tx_card_type", "card_id", "transaction_type"),
|
||||
Index("idx_loyalty_tx_vendor_date", "vendor_id", "transaction_at"),
|
||||
Index("idx_loyalty_tx_type_date", "transaction_type", "transaction_at"),
|
||||
Index("idx_loyalty_tx_company_date", "company_id", "transaction_at"),
|
||||
Index("idx_loyalty_tx_company_vendor", "company_id", "vendor_id"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
@@ -202,6 +244,7 @@ class LoyaltyTransaction(Base, TimestampMixin):
|
||||
return self.transaction_type in (
|
||||
TransactionType.STAMP_EARNED.value,
|
||||
TransactionType.STAMP_REDEEMED.value,
|
||||
TransactionType.STAMP_VOIDED.value,
|
||||
TransactionType.STAMP_ADJUSTMENT.value,
|
||||
)
|
||||
|
||||
@@ -212,6 +255,9 @@ class LoyaltyTransaction(Base, TimestampMixin):
|
||||
TransactionType.POINTS_EARNED.value,
|
||||
TransactionType.POINTS_REDEEMED.value,
|
||||
TransactionType.POINTS_ADJUSTMENT.value,
|
||||
TransactionType.POINTS_VOIDED.value,
|
||||
TransactionType.WELCOME_BONUS.value,
|
||||
TransactionType.POINTS_EXPIRED.value,
|
||||
)
|
||||
|
||||
@property
|
||||
@@ -220,6 +266,7 @@ class LoyaltyTransaction(Base, TimestampMixin):
|
||||
return self.transaction_type in (
|
||||
TransactionType.STAMP_EARNED.value,
|
||||
TransactionType.POINTS_EARNED.value,
|
||||
TransactionType.WELCOME_BONUS.value,
|
||||
)
|
||||
|
||||
@property
|
||||
@@ -230,9 +277,24 @@ class LoyaltyTransaction(Base, TimestampMixin):
|
||||
TransactionType.POINTS_REDEEMED.value,
|
||||
)
|
||||
|
||||
@property
|
||||
def is_void_transaction(self) -> bool:
|
||||
"""Check if this is a void transaction (for returns)."""
|
||||
return self.transaction_type in (
|
||||
TransactionType.POINTS_VOIDED.value,
|
||||
TransactionType.STAMP_VOIDED.value,
|
||||
)
|
||||
|
||||
@property
|
||||
def staff_name(self) -> str | None:
|
||||
"""Get the name of the staff member who performed this transaction."""
|
||||
if self.staff_pin:
|
||||
return self.staff_pin.name
|
||||
return None
|
||||
|
||||
@property
|
||||
def vendor_name(self) -> str | None:
|
||||
"""Get the name of the vendor where this transaction occurred."""
|
||||
if self.vendor:
|
||||
return self.vendor.name
|
||||
return None
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
"""
|
||||
Staff PIN database model.
|
||||
|
||||
Company-based staff PINs:
|
||||
- PINs belong to a company's loyalty program
|
||||
- Each vendor (location) has its own set of staff PINs
|
||||
- Staff can only use PINs at their assigned location
|
||||
|
||||
Provides fraud prevention by requiring staff to authenticate
|
||||
before performing stamp/points operations. Includes:
|
||||
- Secure PIN hashing with bcrypt
|
||||
@@ -34,13 +39,25 @@ class StaffPin(Base, TimestampMixin):
|
||||
Each staff member can have their own PIN to authenticate
|
||||
stamp/points operations. PINs are hashed with bcrypt and
|
||||
include lockout protection.
|
||||
|
||||
PINs are scoped to a specific vendor (location) within the
|
||||
company's loyalty program.
|
||||
"""
|
||||
|
||||
__tablename__ = "staff_pins"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
# Relationships
|
||||
# Company association
|
||||
company_id = Column(
|
||||
Integer,
|
||||
ForeignKey("companies.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="Company that owns the loyalty program",
|
||||
)
|
||||
|
||||
# Program and vendor relationships
|
||||
program_id = Column(
|
||||
Integer,
|
||||
ForeignKey("loyalty_programs.id", ondelete="CASCADE"),
|
||||
@@ -52,7 +69,7 @@ class StaffPin(Base, TimestampMixin):
|
||||
ForeignKey("vendors.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="Denormalized for query performance",
|
||||
comment="Vendor (location) where this staff member works",
|
||||
)
|
||||
|
||||
# Staff identity
|
||||
@@ -104,17 +121,19 @@ class StaffPin(Base, TimestampMixin):
|
||||
# =========================================================================
|
||||
# Relationships
|
||||
# =========================================================================
|
||||
company = relationship("Company", backref="staff_pins")
|
||||
program = relationship("LoyaltyProgram", back_populates="staff_pins")
|
||||
vendor = relationship("Vendor", backref="staff_pins")
|
||||
|
||||
# Indexes
|
||||
__table_args__ = (
|
||||
Index("idx_staff_pin_company_active", "company_id", "is_active"),
|
||||
Index("idx_staff_pin_vendor_active", "vendor_id", "is_active"),
|
||||
Index("idx_staff_pin_program_active", "program_id", "is_active"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<StaffPin(id={self.id}, name='{self.name}', active={self.is_active})>"
|
||||
return f"<StaffPin(id={self.id}, name='{self.name}', vendor_id={self.vendor_id}, active={self.is_active})>"
|
||||
|
||||
# =========================================================================
|
||||
# PIN Operations
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
Loyalty module admin routes.
|
||||
|
||||
Platform admin endpoints for:
|
||||
- Viewing all loyalty programs
|
||||
- Viewing all loyalty programs (company-based)
|
||||
- Company loyalty settings management
|
||||
- Platform-wide analytics
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_admin_api, require_module_access
|
||||
@@ -19,6 +20,9 @@ from app.modules.loyalty.schemas import (
|
||||
ProgramListResponse,
|
||||
ProgramResponse,
|
||||
ProgramStatsResponse,
|
||||
CompanyStatsResponse,
|
||||
CompanySettingsResponse,
|
||||
CompanySettingsUpdate,
|
||||
)
|
||||
from app.modules.loyalty.services import program_service
|
||||
from app.modules.tenancy.models import User
|
||||
@@ -42,15 +46,22 @@ def list_programs(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
is_active: bool | None = Query(None),
|
||||
search: str | None = Query(None, description="Search by company name"),
|
||||
current_user: User = Depends(get_current_admin_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""List all loyalty programs (platform admin)."""
|
||||
from sqlalchemy import func
|
||||
|
||||
from app.modules.loyalty.models import LoyaltyCard, LoyaltyTransaction
|
||||
from app.modules.tenancy.models import Company
|
||||
|
||||
programs, total = program_service.list_programs(
|
||||
db,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
is_active=is_active,
|
||||
search=search,
|
||||
)
|
||||
|
||||
program_responses = []
|
||||
@@ -59,6 +70,47 @@ def list_programs(
|
||||
response.is_stamps_enabled = program.is_stamps_enabled
|
||||
response.is_points_enabled = program.is_points_enabled
|
||||
response.display_name = program.display_name
|
||||
|
||||
# Get company name
|
||||
company = db.query(Company).filter(Company.id == program.company_id).first()
|
||||
if company:
|
||||
response.company_name = company.name
|
||||
|
||||
# Get basic stats for this program
|
||||
response.total_cards = (
|
||||
db.query(func.count(LoyaltyCard.id))
|
||||
.filter(LoyaltyCard.company_id == program.company_id)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
response.active_cards = (
|
||||
db.query(func.count(LoyaltyCard.id))
|
||||
.filter(
|
||||
LoyaltyCard.company_id == program.company_id,
|
||||
LoyaltyCard.is_active == True,
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
response.total_points_issued = (
|
||||
db.query(func.sum(LoyaltyTransaction.points_delta))
|
||||
.filter(
|
||||
LoyaltyTransaction.company_id == program.company_id,
|
||||
LoyaltyTransaction.points_delta > 0,
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
response.total_points_redeemed = (
|
||||
db.query(func.sum(func.abs(LoyaltyTransaction.points_delta)))
|
||||
.filter(
|
||||
LoyaltyTransaction.company_id == program.company_id,
|
||||
LoyaltyTransaction.points_delta < 0,
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
program_responses.append(response)
|
||||
|
||||
return ProgramListResponse(programs=program_responses, total=total)
|
||||
@@ -92,6 +144,60 @@ def get_program_stats(
|
||||
return ProgramStatsResponse(**stats)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Company Management
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@admin_router.get("/companies/{company_id}/stats", response_model=CompanyStatsResponse)
|
||||
def get_company_stats(
|
||||
company_id: int = Path(..., gt=0),
|
||||
current_user: User = Depends(get_current_admin_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Get company-wide loyalty statistics across all locations."""
|
||||
stats = program_service.get_company_stats(db, company_id)
|
||||
if "error" in stats:
|
||||
raise HTTPException(status_code=404, detail=stats["error"])
|
||||
|
||||
return CompanyStatsResponse(**stats)
|
||||
|
||||
|
||||
@admin_router.get("/companies/{company_id}/settings", response_model=CompanySettingsResponse)
|
||||
def get_company_settings(
|
||||
company_id: int = Path(..., gt=0),
|
||||
current_user: User = Depends(get_current_admin_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Get company loyalty settings."""
|
||||
settings = program_service.get_or_create_company_settings(db, company_id)
|
||||
return CompanySettingsResponse.model_validate(settings)
|
||||
|
||||
|
||||
@admin_router.patch("/companies/{company_id}/settings", response_model=CompanySettingsResponse)
|
||||
def update_company_settings(
|
||||
data: CompanySettingsUpdate,
|
||||
company_id: int = Path(..., gt=0),
|
||||
current_user: User = Depends(get_current_admin_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Update company loyalty settings (admin only)."""
|
||||
from app.modules.loyalty.models import CompanyLoyaltySettings
|
||||
|
||||
settings = program_service.get_or_create_company_settings(db, company_id)
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(settings, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(settings)
|
||||
|
||||
logger.info(f"Updated company {company_id} loyalty settings: {list(update_data.keys())}")
|
||||
|
||||
return CompanySettingsResponse.model_validate(settings)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Platform Stats
|
||||
# =============================================================================
|
||||
@@ -136,10 +242,39 @@ def get_platform_stats(
|
||||
or 0
|
||||
)
|
||||
|
||||
# Points issued/redeemed (last 30 days)
|
||||
points_issued_30d = (
|
||||
db.query(func.sum(LoyaltyTransaction.points_delta))
|
||||
.filter(
|
||||
LoyaltyTransaction.transaction_at >= thirty_days_ago,
|
||||
LoyaltyTransaction.points_delta > 0,
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
points_redeemed_30d = (
|
||||
db.query(func.sum(func.abs(LoyaltyTransaction.points_delta)))
|
||||
.filter(
|
||||
LoyaltyTransaction.transaction_at >= thirty_days_ago,
|
||||
LoyaltyTransaction.points_delta < 0,
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
# Company count with programs
|
||||
companies_with_programs = (
|
||||
db.query(func.count(func.distinct(LoyaltyProgram.company_id))).scalar() or 0
|
||||
)
|
||||
|
||||
return {
|
||||
"total_programs": total_programs,
|
||||
"active_programs": active_programs,
|
||||
"companies_with_programs": companies_with_programs,
|
||||
"total_cards": total_cards,
|
||||
"active_cards": active_cards,
|
||||
"transactions_30d": transactions_30d,
|
||||
"points_issued_30d": points_issued_30d,
|
||||
"points_redeemed_30d": points_redeemed_30d,
|
||||
}
|
||||
|
||||
216
app/modules/loyalty/routes/api/storefront.py
Normal file
216
app/modules/loyalty/routes/api/storefront.py
Normal 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}
|
||||
@@ -2,12 +2,15 @@
|
||||
"""
|
||||
Loyalty module vendor routes.
|
||||
|
||||
Store/vendor endpoints for:
|
||||
- Program management
|
||||
- Staff PINs
|
||||
- Card operations (stamps, points, redemptions)
|
||||
Company-based vendor endpoints for:
|
||||
- Program management (company-wide, managed by vendor)
|
||||
- Staff PINs (per-vendor)
|
||||
- Card operations (stamps, points, redemptions, voids)
|
||||
- Customer cards lookup
|
||||
- Dashboard stats
|
||||
|
||||
All operations are scoped to the vendor's company.
|
||||
Cards can be used at any vendor within the same company.
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -36,14 +39,23 @@ from app.modules.loyalty.schemas import (
|
||||
PointsEarnResponse,
|
||||
PointsRedeemRequest,
|
||||
PointsRedeemResponse,
|
||||
PointsVoidRequest,
|
||||
PointsVoidResponse,
|
||||
PointsAdjustRequest,
|
||||
PointsAdjustResponse,
|
||||
ProgramCreate,
|
||||
ProgramResponse,
|
||||
ProgramStatsResponse,
|
||||
ProgramUpdate,
|
||||
CompanyStatsResponse,
|
||||
StampRedeemRequest,
|
||||
StampRedeemResponse,
|
||||
StampRequest,
|
||||
StampResponse,
|
||||
StampVoidRequest,
|
||||
StampVoidResponse,
|
||||
TransactionListResponse,
|
||||
TransactionResponse,
|
||||
)
|
||||
from app.modules.loyalty.services import (
|
||||
card_service,
|
||||
@@ -54,7 +66,7 @@ from app.modules.loyalty.services import (
|
||||
wallet_service,
|
||||
)
|
||||
from app.modules.enums import FrontendType
|
||||
from app.modules.tenancy.models import User
|
||||
from app.modules.tenancy.models import User, Vendor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -72,6 +84,14 @@ def get_client_info(request: Request) -> tuple[str | None, str | None]:
|
||||
return ip, user_agent
|
||||
|
||||
|
||||
def get_vendor_company_id(db: Session, vendor_id: int) -> int:
|
||||
"""Get the company ID for a vendor."""
|
||||
vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first()
|
||||
if not vendor:
|
||||
raise HTTPException(status_code=404, detail="Vendor not found")
|
||||
return vendor.company_id
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Program Management
|
||||
# =============================================================================
|
||||
@@ -82,7 +102,7 @@ def get_program(
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Get the vendor's loyalty program."""
|
||||
"""Get the company's loyalty program."""
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
program = program_service.get_program_by_vendor(db, vendor_id)
|
||||
@@ -103,11 +123,12 @@ def create_program(
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Create a loyalty program for the vendor."""
|
||||
"""Create a loyalty program for the company."""
|
||||
vendor_id = current_user.token_vendor_id
|
||||
company_id = get_vendor_company_id(db, vendor_id)
|
||||
|
||||
try:
|
||||
program = program_service.create_program(db, vendor_id, data)
|
||||
program = program_service.create_program(db, company_id, data)
|
||||
except LoyaltyException as e:
|
||||
raise HTTPException(status_code=e.status_code, detail=e.message)
|
||||
|
||||
@@ -125,7 +146,7 @@ def update_program(
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Update the vendor's loyalty program."""
|
||||
"""Update the company's loyalty program."""
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
program = program_service.get_program_by_vendor(db, vendor_id)
|
||||
@@ -158,6 +179,22 @@ def get_stats(
|
||||
return ProgramStatsResponse(**stats)
|
||||
|
||||
|
||||
@vendor_router.get("/stats/company", response_model=CompanyStatsResponse)
|
||||
def get_company_stats(
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Get company-wide loyalty statistics across all locations."""
|
||||
vendor_id = current_user.token_vendor_id
|
||||
company_id = get_vendor_company_id(db, vendor_id)
|
||||
|
||||
stats = program_service.get_company_stats(db, company_id)
|
||||
if "error" in stats:
|
||||
raise HTTPException(status_code=404, detail=stats["error"])
|
||||
|
||||
return CompanyStatsResponse(**stats)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Staff PINs
|
||||
# =============================================================================
|
||||
@@ -168,14 +205,15 @@ def list_pins(
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""List all staff PINs for the loyalty program."""
|
||||
"""List staff PINs for this vendor location."""
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
program = program_service.get_program_by_vendor(db, vendor_id)
|
||||
if not program:
|
||||
raise HTTPException(status_code=404, detail="No loyalty program configured")
|
||||
|
||||
pins = pin_service.list_pins(db, program.id)
|
||||
# List PINs for this vendor only
|
||||
pins = pin_service.list_pins(db, program.id, vendor_id=vendor_id)
|
||||
|
||||
return PinListResponse(
|
||||
pins=[PinResponse.model_validate(pin) for pin in pins],
|
||||
@@ -189,7 +227,7 @@ def create_pin(
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Create a new staff PIN."""
|
||||
"""Create a new staff PIN for this vendor location."""
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
program = program_service.get_program_by_vendor(db, vendor_id)
|
||||
@@ -244,19 +282,30 @@ def list_cards(
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
is_active: bool | None = Query(None),
|
||||
search: str | None = Query(None, max_length=100),
|
||||
enrolled_here: bool = Query(False, description="Only show cards enrolled at this location"),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""List loyalty cards for the vendor."""
|
||||
"""
|
||||
List loyalty cards for the company.
|
||||
|
||||
By default lists all cards in the company's loyalty program.
|
||||
Use enrolled_here=true to filter to cards enrolled at this location.
|
||||
"""
|
||||
vendor_id = current_user.token_vendor_id
|
||||
company_id = get_vendor_company_id(db, vendor_id)
|
||||
|
||||
program = program_service.get_program_by_vendor(db, vendor_id)
|
||||
if not program:
|
||||
raise HTTPException(status_code=404, detail="No loyalty program configured")
|
||||
|
||||
# Filter by enrolled_at_vendor_id if requested
|
||||
filter_vendor_id = vendor_id if enrolled_here else None
|
||||
|
||||
cards, total = card_service.list_cards(
|
||||
db,
|
||||
vendor_id,
|
||||
company_id,
|
||||
vendor_id=filter_vendor_id,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
is_active=is_active,
|
||||
@@ -269,8 +318,9 @@ def list_cards(
|
||||
id=card.id,
|
||||
card_number=card.card_number,
|
||||
customer_id=card.customer_id,
|
||||
vendor_id=card.vendor_id,
|
||||
company_id=card.company_id,
|
||||
program_id=card.program_id,
|
||||
enrolled_at_vendor_id=card.enrolled_at_vendor_id,
|
||||
stamp_count=card.stamp_count,
|
||||
stamps_target=program.stamps_target,
|
||||
stamps_until_reward=max(0, program.stamps_target - card.stamp_count),
|
||||
@@ -298,12 +348,18 @@ def lookup_card(
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Look up a card by ID, QR code, or card number."""
|
||||
"""
|
||||
Look up a card by ID, QR code, or card number.
|
||||
|
||||
Card must belong to the same company as the vendor.
|
||||
"""
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
try:
|
||||
card = card_service.lookup_card(
|
||||
# Uses lookup_card_for_vendor which validates company membership
|
||||
card = card_service.lookup_card_for_vendor(
|
||||
db,
|
||||
vendor_id,
|
||||
card_id=card_id,
|
||||
qr_code=qr_code,
|
||||
card_number=card_number,
|
||||
@@ -311,10 +367,6 @@ def lookup_card(
|
||||
except LoyaltyCardNotFoundException:
|
||||
raise HTTPException(status_code=404, detail="Card not found")
|
||||
|
||||
# Verify card belongs to this vendor
|
||||
if card.vendor_id != vendor_id:
|
||||
raise HTTPException(status_code=404, detail="Card not found")
|
||||
|
||||
program = card.program
|
||||
|
||||
# Check cooldown
|
||||
@@ -328,18 +380,27 @@ def lookup_card(
|
||||
# Get stamps today
|
||||
stamps_today = card_service.get_stamps_today(db, card.id)
|
||||
|
||||
# Get available points rewards
|
||||
available_rewards = []
|
||||
for reward in program.points_rewards or []:
|
||||
if reward.get("is_active", True) and card.points_balance >= reward.get("points_required", 0):
|
||||
available_rewards.append(reward)
|
||||
|
||||
return CardLookupResponse(
|
||||
card_id=card.id,
|
||||
card_number=card.card_number,
|
||||
customer_id=card.customer_id,
|
||||
customer_name=card.customer.full_name if card.customer else None,
|
||||
customer_email=card.customer.email if card.customer else "",
|
||||
company_id=card.company_id,
|
||||
company_name=card.company.name if card.company else None,
|
||||
stamp_count=card.stamp_count,
|
||||
stamps_target=program.stamps_target,
|
||||
stamps_until_reward=max(0, program.stamps_target - card.stamp_count),
|
||||
points_balance=card.points_balance,
|
||||
can_redeem_stamps=card.stamp_count >= program.stamps_target,
|
||||
stamp_reward_description=program.stamps_reward_description,
|
||||
available_rewards=available_rewards,
|
||||
can_stamp=can_stamp,
|
||||
cooldown_ends_at=cooldown_ends,
|
||||
stamps_today=stamps_today,
|
||||
@@ -354,14 +415,19 @@ def enroll_customer(
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Enroll a customer in the loyalty program."""
|
||||
"""
|
||||
Enroll a customer in the company's loyalty program.
|
||||
|
||||
The card will be associated with the company and track which
|
||||
vendor enrolled them.
|
||||
"""
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
if not data.customer_id:
|
||||
raise HTTPException(status_code=400, detail="customer_id is required")
|
||||
|
||||
try:
|
||||
card = card_service.enroll_customer(db, data.customer_id, vendor_id)
|
||||
card = card_service.enroll_customer_for_vendor(db, data.customer_id, vendor_id)
|
||||
except LoyaltyException as e:
|
||||
raise HTTPException(status_code=e.status_code, detail=e.message)
|
||||
|
||||
@@ -371,11 +437,12 @@ def enroll_customer(
|
||||
id=card.id,
|
||||
card_number=card.card_number,
|
||||
customer_id=card.customer_id,
|
||||
vendor_id=card.vendor_id,
|
||||
company_id=card.company_id,
|
||||
program_id=card.program_id,
|
||||
enrolled_at_vendor_id=card.enrolled_at_vendor_id,
|
||||
stamp_count=card.stamp_count,
|
||||
stamps_target=program.stamps_target,
|
||||
stamps_until_reward=program.stamps_target,
|
||||
stamps_until_reward=max(0, program.stamps_target - card.stamp_count),
|
||||
total_stamps_earned=card.total_stamps_earned,
|
||||
stamps_redeemed=card.stamps_redeemed,
|
||||
points_balance=card.points_balance,
|
||||
@@ -386,6 +453,33 @@ def enroll_customer(
|
||||
)
|
||||
|
||||
|
||||
@vendor_router.get("/cards/{card_id}/transactions", response_model=TransactionListResponse)
|
||||
def get_card_transactions(
|
||||
card_id: int = Path(..., gt=0),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Get transaction history for a card."""
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
# Verify card belongs to this company
|
||||
try:
|
||||
card = card_service.lookup_card_for_vendor(db, vendor_id, card_id=card_id)
|
||||
except LoyaltyCardNotFoundException:
|
||||
raise HTTPException(status_code=404, detail="Card not found")
|
||||
|
||||
transactions, total = card_service.get_card_transactions(
|
||||
db, card_id, skip=skip, limit=limit
|
||||
)
|
||||
|
||||
return TransactionListResponse(
|
||||
transactions=[TransactionResponse.model_validate(t) for t in transactions],
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Stamp Operations
|
||||
# =============================================================================
|
||||
@@ -399,11 +493,13 @@ def add_stamp(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Add a stamp to a loyalty card."""
|
||||
vendor_id = current_user.token_vendor_id
|
||||
ip, user_agent = get_client_info(request)
|
||||
|
||||
try:
|
||||
result = stamp_service.add_stamp(
|
||||
db,
|
||||
vendor_id=vendor_id,
|
||||
card_id=data.card_id,
|
||||
qr_code=data.qr_code,
|
||||
card_number=data.card_number,
|
||||
@@ -426,11 +522,13 @@ def redeem_stamps(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Redeem stamps for a reward."""
|
||||
vendor_id = current_user.token_vendor_id
|
||||
ip, user_agent = get_client_info(request)
|
||||
|
||||
try:
|
||||
result = stamp_service.redeem_stamps(
|
||||
db,
|
||||
vendor_id=vendor_id,
|
||||
card_id=data.card_id,
|
||||
qr_code=data.qr_code,
|
||||
card_number=data.card_number,
|
||||
@@ -445,6 +543,37 @@ def redeem_stamps(
|
||||
return StampRedeemResponse(**result)
|
||||
|
||||
|
||||
@vendor_router.post("/stamp/void", response_model=StampVoidResponse)
|
||||
def void_stamps(
|
||||
request: Request,
|
||||
data: StampVoidRequest,
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Void stamps for a return."""
|
||||
vendor_id = current_user.token_vendor_id
|
||||
ip, user_agent = get_client_info(request)
|
||||
|
||||
try:
|
||||
result = stamp_service.void_stamps(
|
||||
db,
|
||||
vendor_id=vendor_id,
|
||||
card_id=data.card_id,
|
||||
qr_code=data.qr_code,
|
||||
card_number=data.card_number,
|
||||
stamps_to_void=data.stamps_to_void,
|
||||
original_transaction_id=data.original_transaction_id,
|
||||
staff_pin=data.staff_pin,
|
||||
ip_address=ip,
|
||||
user_agent=user_agent,
|
||||
notes=data.notes,
|
||||
)
|
||||
except LoyaltyException as e:
|
||||
raise HTTPException(status_code=e.status_code, detail=e.message)
|
||||
|
||||
return StampVoidResponse(**result)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Points Operations
|
||||
# =============================================================================
|
||||
@@ -458,11 +587,13 @@ def earn_points(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Earn points from a purchase."""
|
||||
vendor_id = current_user.token_vendor_id
|
||||
ip, user_agent = get_client_info(request)
|
||||
|
||||
try:
|
||||
result = points_service.earn_points(
|
||||
db,
|
||||
vendor_id=vendor_id,
|
||||
card_id=data.card_id,
|
||||
qr_code=data.qr_code,
|
||||
card_number=data.card_number,
|
||||
@@ -487,11 +618,13 @@ def redeem_points(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Redeem points for a reward."""
|
||||
vendor_id = current_user.token_vendor_id
|
||||
ip, user_agent = get_client_info(request)
|
||||
|
||||
try:
|
||||
result = points_service.redeem_points(
|
||||
db,
|
||||
vendor_id=vendor_id,
|
||||
card_id=data.card_id,
|
||||
qr_code=data.qr_code,
|
||||
card_number=data.card_number,
|
||||
@@ -505,3 +638,64 @@ def redeem_points(
|
||||
raise HTTPException(status_code=e.status_code, detail=e.message)
|
||||
|
||||
return PointsRedeemResponse(**result)
|
||||
|
||||
|
||||
@vendor_router.post("/points/void", response_model=PointsVoidResponse)
|
||||
def void_points(
|
||||
request: Request,
|
||||
data: PointsVoidRequest,
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Void points for a return."""
|
||||
vendor_id = current_user.token_vendor_id
|
||||
ip, user_agent = get_client_info(request)
|
||||
|
||||
try:
|
||||
result = points_service.void_points(
|
||||
db,
|
||||
vendor_id=vendor_id,
|
||||
card_id=data.card_id,
|
||||
qr_code=data.qr_code,
|
||||
card_number=data.card_number,
|
||||
points_to_void=data.points_to_void,
|
||||
original_transaction_id=data.original_transaction_id,
|
||||
order_reference=data.order_reference,
|
||||
staff_pin=data.staff_pin,
|
||||
ip_address=ip,
|
||||
user_agent=user_agent,
|
||||
notes=data.notes,
|
||||
)
|
||||
except LoyaltyException as e:
|
||||
raise HTTPException(status_code=e.status_code, detail=e.message)
|
||||
|
||||
return PointsVoidResponse(**result)
|
||||
|
||||
|
||||
@vendor_router.post("/cards/{card_id}/points/adjust", response_model=PointsAdjustResponse)
|
||||
def adjust_points(
|
||||
request: Request,
|
||||
data: PointsAdjustRequest,
|
||||
card_id: int = Path(..., gt=0),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Manually adjust points (vendor operation)."""
|
||||
vendor_id = current_user.token_vendor_id
|
||||
ip, user_agent = get_client_info(request)
|
||||
|
||||
try:
|
||||
result = points_service.adjust_points(
|
||||
db,
|
||||
card_id=card_id,
|
||||
points_delta=data.points_delta,
|
||||
vendor_id=vendor_id,
|
||||
reason=data.reason,
|
||||
staff_pin=data.staff_pin,
|
||||
ip_address=ip,
|
||||
user_agent=user_agent,
|
||||
)
|
||||
except LoyaltyException as e:
|
||||
raise HTTPException(status_code=e.status_code, detail=e.message)
|
||||
|
||||
return PointsAdjustResponse(**result)
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
# app/modules/loyalty/routes/pages/__init__.py
|
||||
"""
|
||||
Loyalty module page routes.
|
||||
Loyalty module page routes (HTML rendering).
|
||||
|
||||
Reserved for future HTML page endpoints (enrollment pages, etc.).
|
||||
Provides Jinja2 template rendering for:
|
||||
- Admin pages: Platform loyalty programs dashboard and company management
|
||||
- Vendor pages: Loyalty terminal, cards management, settings
|
||||
- Storefront pages: Customer loyalty dashboard, self-enrollment
|
||||
"""
|
||||
|
||||
__all__: list[str] = []
|
||||
from app.modules.loyalty.routes.pages.admin import router as admin_router
|
||||
from app.modules.loyalty.routes.pages.vendor import router as vendor_router
|
||||
from app.modules.loyalty.routes.pages.storefront import router as storefront_router
|
||||
|
||||
__all__ = ["admin_router", "vendor_router", "storefront_router"]
|
||||
|
||||
124
app/modules/loyalty/routes/pages/admin.py
Normal file
124
app/modules/loyalty/routes/pages/admin.py
Normal 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,
|
||||
},
|
||||
)
|
||||
151
app/modules/loyalty/routes/pages/storefront.py
Normal file
151
app/modules/loyalty/routes/pages/storefront.py
Normal 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,
|
||||
)
|
||||
226
app/modules/loyalty/routes/pages/vendor.py
Normal file
226
app/modules/loyalty/routes/pages/vendor.py
Normal 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),
|
||||
)
|
||||
@@ -33,8 +33,13 @@ from app.modules.loyalty.schemas.program import (
|
||||
ProgramListResponse,
|
||||
# Points rewards
|
||||
PointsRewardConfig,
|
||||
TierConfig,
|
||||
# Stats
|
||||
ProgramStatsResponse,
|
||||
CompanyStatsResponse,
|
||||
# Company settings
|
||||
CompanySettingsResponse,
|
||||
CompanySettingsUpdate,
|
||||
)
|
||||
|
||||
from app.modules.loyalty.schemas.card import (
|
||||
@@ -44,6 +49,9 @@ from app.modules.loyalty.schemas.card import (
|
||||
CardDetailResponse,
|
||||
CardListResponse,
|
||||
CardLookupResponse,
|
||||
# Transactions
|
||||
TransactionResponse,
|
||||
TransactionListResponse,
|
||||
)
|
||||
|
||||
from app.modules.loyalty.schemas.stamp import (
|
||||
@@ -52,6 +60,8 @@ from app.modules.loyalty.schemas.stamp import (
|
||||
StampResponse,
|
||||
StampRedeemRequest,
|
||||
StampRedeemResponse,
|
||||
StampVoidRequest,
|
||||
StampVoidResponse,
|
||||
)
|
||||
|
||||
from app.modules.loyalty.schemas.points import (
|
||||
@@ -60,6 +70,10 @@ from app.modules.loyalty.schemas.points import (
|
||||
PointsEarnResponse,
|
||||
PointsRedeemRequest,
|
||||
PointsRedeemResponse,
|
||||
PointsVoidRequest,
|
||||
PointsVoidResponse,
|
||||
PointsAdjustRequest,
|
||||
PointsAdjustResponse,
|
||||
)
|
||||
|
||||
from app.modules.loyalty.schemas.pin import (
|
||||
@@ -79,23 +93,35 @@ __all__ = [
|
||||
"ProgramResponse",
|
||||
"ProgramListResponse",
|
||||
"PointsRewardConfig",
|
||||
"TierConfig",
|
||||
"ProgramStatsResponse",
|
||||
"CompanyStatsResponse",
|
||||
"CompanySettingsResponse",
|
||||
"CompanySettingsUpdate",
|
||||
# Card
|
||||
"CardEnrollRequest",
|
||||
"CardResponse",
|
||||
"CardDetailResponse",
|
||||
"CardListResponse",
|
||||
"CardLookupResponse",
|
||||
"TransactionResponse",
|
||||
"TransactionListResponse",
|
||||
# Stamp
|
||||
"StampRequest",
|
||||
"StampResponse",
|
||||
"StampRedeemRequest",
|
||||
"StampRedeemResponse",
|
||||
"StampVoidRequest",
|
||||
"StampVoidResponse",
|
||||
# Points
|
||||
"PointsEarnRequest",
|
||||
"PointsEarnResponse",
|
||||
"PointsRedeemRequest",
|
||||
"PointsRedeemResponse",
|
||||
"PointsVoidRequest",
|
||||
"PointsVoidResponse",
|
||||
"PointsAdjustRequest",
|
||||
"PointsAdjustResponse",
|
||||
# PIN
|
||||
"PinCreate",
|
||||
"PinUpdate",
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
# app/modules/loyalty/schemas/card.py
|
||||
"""
|
||||
Pydantic schemas for loyalty card operations.
|
||||
|
||||
Company-based cards:
|
||||
- Cards belong to a company's loyalty program
|
||||
- One card per customer per company
|
||||
- Can be used at any vendor within the company
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
@@ -29,8 +34,9 @@ class CardResponse(BaseModel):
|
||||
id: int
|
||||
card_number: str
|
||||
customer_id: int
|
||||
vendor_id: int
|
||||
company_id: int
|
||||
program_id: int
|
||||
enrolled_at_vendor_id: int | None = None
|
||||
|
||||
# Stamps
|
||||
stamp_count: int
|
||||
@@ -64,6 +70,9 @@ class CardDetailResponse(CardResponse):
|
||||
customer_name: str | None = None
|
||||
customer_email: str | None = None
|
||||
|
||||
# Company info
|
||||
company_name: str | None = None
|
||||
|
||||
# Program info
|
||||
program_name: str
|
||||
program_type: str
|
||||
@@ -73,6 +82,7 @@ class CardDetailResponse(CardResponse):
|
||||
last_stamp_at: datetime | None = None
|
||||
last_points_at: datetime | None = None
|
||||
last_redemption_at: datetime | None = None
|
||||
last_activity_at: datetime | None = None
|
||||
|
||||
# Wallet URLs
|
||||
google_wallet_url: str | None = None
|
||||
@@ -98,6 +108,10 @@ class CardLookupResponse(BaseModel):
|
||||
customer_name: str | None = None
|
||||
customer_email: str
|
||||
|
||||
# Company context
|
||||
company_id: int
|
||||
company_name: str | None = None
|
||||
|
||||
# Current balances
|
||||
stamp_count: int
|
||||
stamps_target: int
|
||||
@@ -108,6 +122,9 @@ class CardLookupResponse(BaseModel):
|
||||
can_redeem_stamps: bool = False
|
||||
stamp_reward_description: str | None = None
|
||||
|
||||
# Available points rewards
|
||||
available_rewards: list[dict] = []
|
||||
|
||||
# Cooldown status
|
||||
can_stamp: bool = True
|
||||
cooldown_ends_at: datetime | None = None
|
||||
@@ -116,3 +133,44 @@ class CardLookupResponse(BaseModel):
|
||||
stamps_today: int = 0
|
||||
max_daily_stamps: int = 5
|
||||
can_earn_more_stamps: bool = True
|
||||
|
||||
|
||||
class TransactionResponse(BaseModel):
|
||||
"""Schema for a loyalty transaction."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
card_id: int
|
||||
vendor_id: int | None = None
|
||||
vendor_name: str | None = None
|
||||
transaction_type: str
|
||||
|
||||
# Deltas
|
||||
stamps_delta: int = 0
|
||||
points_delta: int = 0
|
||||
|
||||
# Balances after
|
||||
stamps_balance_after: int | None = None
|
||||
points_balance_after: int | None = None
|
||||
|
||||
# Context
|
||||
purchase_amount_cents: int | None = None
|
||||
order_reference: str | None = None
|
||||
reward_id: str | None = None
|
||||
reward_description: str | None = None
|
||||
notes: str | None = None
|
||||
|
||||
# Staff
|
||||
staff_name: str | None = None
|
||||
|
||||
# Timestamps
|
||||
transaction_at: datetime
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class TransactionListResponse(BaseModel):
|
||||
"""Schema for listing transactions."""
|
||||
|
||||
transactions: list[TransactionResponse]
|
||||
total: int
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
# app/modules/loyalty/schemas/points.py
|
||||
"""
|
||||
Pydantic schemas for points operations.
|
||||
|
||||
Company-based points:
|
||||
- Points earned at any vendor count toward company total
|
||||
- Points can be redeemed at any vendor within the company
|
||||
- Supports voiding points for returns
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
@@ -67,6 +72,9 @@ class PointsEarnResponse(BaseModel):
|
||||
points_balance: int
|
||||
total_points_earned: int
|
||||
|
||||
# Location
|
||||
vendor_id: int | None = None
|
||||
|
||||
|
||||
class PointsRedeemRequest(BaseModel):
|
||||
"""Schema for redeeming points for a reward."""
|
||||
@@ -122,3 +130,108 @@ class PointsRedeemResponse(BaseModel):
|
||||
card_number: str
|
||||
points_balance: int
|
||||
total_points_redeemed: int
|
||||
|
||||
# Location
|
||||
vendor_id: int | None = None
|
||||
|
||||
|
||||
class PointsVoidRequest(BaseModel):
|
||||
"""Schema for voiding points (for returns)."""
|
||||
|
||||
card_id: int | None = Field(
|
||||
None,
|
||||
description="Card ID (use this or qr_code)",
|
||||
)
|
||||
qr_code: str | None = Field(
|
||||
None,
|
||||
description="QR code data from card scan",
|
||||
)
|
||||
card_number: str | None = Field(
|
||||
None,
|
||||
description="Card number (manual entry)",
|
||||
)
|
||||
|
||||
# Points to void (use one method)
|
||||
points_to_void: int | None = Field(
|
||||
None,
|
||||
gt=0,
|
||||
description="Number of points to void",
|
||||
)
|
||||
original_transaction_id: int | None = Field(
|
||||
None,
|
||||
description="ID of original transaction to void",
|
||||
)
|
||||
order_reference: str | None = Field(
|
||||
None,
|
||||
max_length=100,
|
||||
description="Order reference to find and void",
|
||||
)
|
||||
|
||||
# Authentication
|
||||
staff_pin: str | None = Field(
|
||||
None,
|
||||
min_length=4,
|
||||
max_length=6,
|
||||
description="Staff PIN for verification",
|
||||
)
|
||||
|
||||
# Required metadata
|
||||
notes: str | None = Field(
|
||||
None,
|
||||
max_length=500,
|
||||
description="Reason for voiding",
|
||||
)
|
||||
|
||||
|
||||
class PointsVoidResponse(BaseModel):
|
||||
"""Schema for points void response."""
|
||||
|
||||
success: bool = True
|
||||
message: str = "Points voided successfully"
|
||||
|
||||
# Void info
|
||||
points_voided: int
|
||||
|
||||
# Card state after void
|
||||
card_id: int
|
||||
card_number: str
|
||||
points_balance: int
|
||||
|
||||
# Location
|
||||
vendor_id: int | None = None
|
||||
|
||||
|
||||
class PointsAdjustRequest(BaseModel):
|
||||
"""Schema for manual points adjustment (admin)."""
|
||||
|
||||
points_delta: int = Field(
|
||||
...,
|
||||
description="Points to add (positive) or remove (negative)",
|
||||
)
|
||||
reason: str = Field(
|
||||
...,
|
||||
min_length=5,
|
||||
max_length=500,
|
||||
description="Reason for adjustment (required)",
|
||||
)
|
||||
staff_pin: str | None = Field(
|
||||
None,
|
||||
min_length=4,
|
||||
max_length=6,
|
||||
description="Staff PIN for verification",
|
||||
)
|
||||
|
||||
|
||||
class PointsAdjustResponse(BaseModel):
|
||||
"""Schema for points adjustment response."""
|
||||
|
||||
success: bool = True
|
||||
message: str = "Points adjusted successfully"
|
||||
|
||||
# Adjustment info
|
||||
points_delta: int
|
||||
|
||||
# Card state after adjustment
|
||||
card_id: int
|
||||
card_number: str
|
||||
points_balance: int
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
# app/modules/loyalty/schemas/program.py
|
||||
"""
|
||||
Pydantic schemas for loyalty program operations.
|
||||
|
||||
Company-based programs:
|
||||
- One program per company
|
||||
- All vendors under a company share the same program
|
||||
- Supports chain-wide loyalty across locations
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
@@ -18,12 +23,22 @@ class PointsRewardConfig(BaseModel):
|
||||
is_active: bool = Field(True, description="Whether reward is currently available")
|
||||
|
||||
|
||||
class TierConfig(BaseModel):
|
||||
"""Configuration for a loyalty tier (future use)."""
|
||||
|
||||
id: str = Field(..., description="Tier identifier")
|
||||
name: str = Field(..., max_length=50, description="Tier name (e.g., Bronze, Silver, Gold)")
|
||||
points_threshold: int = Field(..., ge=0, description="Points needed to reach this tier")
|
||||
benefits: list[str] = Field(default_factory=list, description="List of tier benefits")
|
||||
multiplier: float = Field(1.0, ge=1.0, description="Points earning multiplier")
|
||||
|
||||
|
||||
class ProgramCreate(BaseModel):
|
||||
"""Schema for creating a loyalty program."""
|
||||
|
||||
# Program type
|
||||
loyalty_type: str = Field(
|
||||
"stamps",
|
||||
"points",
|
||||
pattern="^(stamps|points|hybrid)$",
|
||||
description="Program type: stamps, points, or hybrid",
|
||||
)
|
||||
@@ -42,11 +57,37 @@ class ProgramCreate(BaseModel):
|
||||
)
|
||||
|
||||
# Points configuration
|
||||
points_per_euro: int = Field(10, ge=1, le=1000, description="Points per euro spent")
|
||||
points_per_euro: int = Field(1, ge=1, le=100, description="Points per euro spent")
|
||||
points_rewards: list[PointsRewardConfig] = Field(
|
||||
default_factory=list,
|
||||
description="Available point rewards",
|
||||
)
|
||||
points_expiration_days: int | None = Field(
|
||||
None,
|
||||
ge=30,
|
||||
description="Days of inactivity before points expire (None = never)",
|
||||
)
|
||||
welcome_bonus_points: int = Field(
|
||||
0,
|
||||
ge=0,
|
||||
description="Bonus points awarded on enrollment",
|
||||
)
|
||||
minimum_redemption_points: int = Field(
|
||||
100,
|
||||
ge=1,
|
||||
description="Minimum points required for redemption",
|
||||
)
|
||||
minimum_purchase_cents: int = Field(
|
||||
0,
|
||||
ge=0,
|
||||
description="Minimum purchase amount to earn points (0 = no minimum)",
|
||||
)
|
||||
|
||||
# Future: Tier configuration
|
||||
tier_config: list[TierConfig] | None = Field(
|
||||
None,
|
||||
description="Tier configuration (future use)",
|
||||
)
|
||||
|
||||
# Anti-fraud
|
||||
cooldown_minutes: int = Field(15, ge=0, le=1440, description="Minutes between stamps")
|
||||
@@ -90,8 +131,15 @@ class ProgramUpdate(BaseModel):
|
||||
stamps_reward_value_cents: int | None = Field(None, ge=0)
|
||||
|
||||
# Points configuration
|
||||
points_per_euro: int | None = Field(None, ge=1, le=1000)
|
||||
points_per_euro: int | None = Field(None, ge=1, le=100)
|
||||
points_rewards: list[PointsRewardConfig] | None = None
|
||||
points_expiration_days: int | None = Field(None, ge=30)
|
||||
welcome_bonus_points: int | None = Field(None, ge=0)
|
||||
minimum_redemption_points: int | None = Field(None, ge=1)
|
||||
minimum_purchase_cents: int | None = Field(None, ge=0)
|
||||
|
||||
# Future: Tier configuration
|
||||
tier_config: list[TierConfig] | None = None
|
||||
|
||||
# Anti-fraud
|
||||
cooldown_minutes: int | None = Field(None, ge=0, le=1440)
|
||||
@@ -123,7 +171,8 @@ class ProgramResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
vendor_id: int
|
||||
company_id: int
|
||||
company_name: str | None = None # Populated by API from Company join
|
||||
loyalty_type: str
|
||||
|
||||
# Stamps
|
||||
@@ -134,6 +183,10 @@ class ProgramResponse(BaseModel):
|
||||
# Points
|
||||
points_per_euro: int
|
||||
points_rewards: list[PointsRewardConfig] = []
|
||||
points_expiration_days: int | None = None
|
||||
welcome_bonus_points: int = 0
|
||||
minimum_redemption_points: int = 100
|
||||
minimum_purchase_cents: int = 0
|
||||
|
||||
# Anti-fraud
|
||||
cooldown_minutes: int
|
||||
@@ -167,6 +220,12 @@ class ProgramResponse(BaseModel):
|
||||
is_points_enabled: bool = False
|
||||
display_name: str = "Loyalty Card"
|
||||
|
||||
# Stats (populated by API)
|
||||
total_cards: int | None = None
|
||||
active_cards: int | None = None
|
||||
total_points_issued: int | None = None
|
||||
total_points_redeemed: int | None = None
|
||||
|
||||
|
||||
class ProgramListResponse(BaseModel):
|
||||
"""Schema for listing loyalty programs (admin)."""
|
||||
@@ -201,3 +260,61 @@ class ProgramStatsResponse(BaseModel):
|
||||
|
||||
# Value
|
||||
estimated_liability_cents: int = 0 # Unredeemed stamps/points value
|
||||
|
||||
|
||||
class CompanyStatsResponse(BaseModel):
|
||||
"""Schema for company-wide loyalty statistics across all locations."""
|
||||
|
||||
company_id: int
|
||||
program_id: int | None = None # May be None if no program set up
|
||||
|
||||
# Cards
|
||||
total_cards: int = 0
|
||||
active_cards: int = 0
|
||||
|
||||
# Points - all time
|
||||
total_points_issued: int = 0
|
||||
total_points_redeemed: int = 0
|
||||
|
||||
# Points - last 30 days
|
||||
points_issued_30d: int = 0
|
||||
points_redeemed_30d: int = 0
|
||||
transactions_30d: int = 0
|
||||
|
||||
# Program info (optional)
|
||||
program: dict | None = None
|
||||
|
||||
# Location breakdown
|
||||
locations: list[dict] = [] # Per-location breakdown
|
||||
|
||||
|
||||
class CompanySettingsResponse(BaseModel):
|
||||
"""Schema for company loyalty settings."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
company_id: int
|
||||
staff_pin_policy: str
|
||||
staff_pin_lockout_attempts: int
|
||||
staff_pin_lockout_minutes: int
|
||||
allow_self_enrollment: bool
|
||||
allow_void_transactions: bool
|
||||
allow_cross_location_redemption: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class CompanySettingsUpdate(BaseModel):
|
||||
"""Schema for updating company loyalty settings."""
|
||||
|
||||
staff_pin_policy: str | None = Field(
|
||||
None,
|
||||
pattern="^(required|optional|disabled)$",
|
||||
description="Staff PIN policy: required, optional, or disabled",
|
||||
)
|
||||
staff_pin_lockout_attempts: int | None = Field(None, ge=3, le=10)
|
||||
staff_pin_lockout_minutes: int | None = Field(None, ge=5, le=120)
|
||||
allow_self_enrollment: bool | None = None
|
||||
allow_void_transactions: bool | None = None
|
||||
allow_cross_location_redemption: bool | None = None
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
# app/modules/loyalty/schemas/stamp.py
|
||||
"""
|
||||
Pydantic schemas for stamp operations.
|
||||
|
||||
Company-based stamps:
|
||||
- Stamps earned at any vendor count toward company total
|
||||
- Stamps can be redeemed at any vendor within the company
|
||||
- Supports voiding stamps for returns
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
@@ -64,6 +69,9 @@ class StampResponse(BaseModel):
|
||||
stamps_today: int
|
||||
stamps_remaining_today: int
|
||||
|
||||
# Location
|
||||
vendor_id: int | None = None
|
||||
|
||||
|
||||
class StampRedeemRequest(BaseModel):
|
||||
"""Schema for redeeming stamps for a reward."""
|
||||
@@ -112,3 +120,67 @@ class StampRedeemResponse(BaseModel):
|
||||
# Reward info
|
||||
reward_description: str
|
||||
total_redemptions: int # Lifetime redemptions for this card
|
||||
|
||||
# Location
|
||||
vendor_id: int | None = None
|
||||
|
||||
|
||||
class StampVoidRequest(BaseModel):
|
||||
"""Schema for voiding stamps (for returns)."""
|
||||
|
||||
card_id: int | None = Field(
|
||||
None,
|
||||
description="Card ID (use this or qr_code)",
|
||||
)
|
||||
qr_code: str | None = Field(
|
||||
None,
|
||||
description="QR code data from card scan",
|
||||
)
|
||||
card_number: str | None = Field(
|
||||
None,
|
||||
description="Card number (manual entry)",
|
||||
)
|
||||
|
||||
# Stamps to void (use one method)
|
||||
stamps_to_void: int | None = Field(
|
||||
None,
|
||||
gt=0,
|
||||
description="Number of stamps to void",
|
||||
)
|
||||
original_transaction_id: int | None = Field(
|
||||
None,
|
||||
description="ID of original transaction to void",
|
||||
)
|
||||
|
||||
# Authentication
|
||||
staff_pin: str | None = Field(
|
||||
None,
|
||||
min_length=4,
|
||||
max_length=6,
|
||||
description="Staff PIN for verification",
|
||||
)
|
||||
|
||||
# Required metadata
|
||||
notes: str | None = Field(
|
||||
None,
|
||||
max_length=500,
|
||||
description="Reason for voiding",
|
||||
)
|
||||
|
||||
|
||||
class StampVoidResponse(BaseModel):
|
||||
"""Schema for stamp void response."""
|
||||
|
||||
success: bool = True
|
||||
message: str = "Stamps voided successfully"
|
||||
|
||||
# Void info
|
||||
stamps_voided: int
|
||||
|
||||
# Card state after void
|
||||
card_id: int
|
||||
card_number: str
|
||||
stamp_count: int
|
||||
|
||||
# Location
|
||||
vendor_id: int | None = None
|
||||
|
||||
@@ -2,9 +2,14 @@
|
||||
"""
|
||||
Loyalty card service.
|
||||
|
||||
Company-based card operations:
|
||||
- Cards belong to a company's loyalty program
|
||||
- One card per customer per company
|
||||
- Can be used at any vendor within the company
|
||||
|
||||
Handles card operations including:
|
||||
- Customer enrollment
|
||||
- Card lookup (by ID, QR code, card number)
|
||||
- Customer enrollment (with welcome bonus)
|
||||
- Card lookup (by ID, QR code, card number, email, phone)
|
||||
- Card management (activation, deactivation)
|
||||
"""
|
||||
|
||||
@@ -19,7 +24,12 @@ from app.modules.loyalty.exceptions import (
|
||||
LoyaltyProgramInactiveException,
|
||||
LoyaltyProgramNotFoundException,
|
||||
)
|
||||
from app.modules.loyalty.models import LoyaltyCard, LoyaltyProgram, LoyaltyTransaction, TransactionType
|
||||
from app.modules.loyalty.models import (
|
||||
LoyaltyCard,
|
||||
LoyaltyProgram,
|
||||
LoyaltyTransaction,
|
||||
TransactionType,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -51,10 +61,31 @@ class CardService:
|
||||
|
||||
def get_card_by_number(self, db: Session, card_number: str) -> LoyaltyCard | None:
|
||||
"""Get a loyalty card by card number."""
|
||||
# Normalize card number (remove dashes)
|
||||
normalized = card_number.replace("-", "").replace(" ", "")
|
||||
return (
|
||||
db.query(LoyaltyCard)
|
||||
.options(joinedload(LoyaltyCard.program))
|
||||
.filter(LoyaltyCard.card_number == card_number)
|
||||
.filter(
|
||||
LoyaltyCard.card_number.replace("-", "") == normalized
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
def get_card_by_customer_and_company(
|
||||
self,
|
||||
db: Session,
|
||||
customer_id: int,
|
||||
company_id: int,
|
||||
) -> LoyaltyCard | None:
|
||||
"""Get a customer's card for a company's program."""
|
||||
return (
|
||||
db.query(LoyaltyCard)
|
||||
.options(joinedload(LoyaltyCard.program))
|
||||
.filter(
|
||||
LoyaltyCard.customer_id == customer_id,
|
||||
LoyaltyCard.company_id == company_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
@@ -89,6 +120,7 @@ class CardService:
|
||||
card_id: int | None = None,
|
||||
qr_code: str | None = None,
|
||||
card_number: str | None = None,
|
||||
company_id: int | None = None,
|
||||
) -> LoyaltyCard:
|
||||
"""
|
||||
Look up a card by any identifier.
|
||||
@@ -97,7 +129,8 @@ class CardService:
|
||||
db: Database session
|
||||
card_id: Card ID
|
||||
qr_code: QR code data
|
||||
card_number: Card number
|
||||
card_number: Card number (with or without dashes)
|
||||
company_id: Optional company filter
|
||||
|
||||
Returns:
|
||||
Found card
|
||||
@@ -118,28 +151,73 @@ class CardService:
|
||||
identifier = card_id or qr_code or card_number or "unknown"
|
||||
raise LoyaltyCardNotFoundException(str(identifier))
|
||||
|
||||
# Filter by company if specified
|
||||
if company_id and card.company_id != company_id:
|
||||
raise LoyaltyCardNotFoundException(str(card_id or qr_code or card_number))
|
||||
|
||||
return card
|
||||
|
||||
def list_cards(
|
||||
def lookup_card_for_vendor(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_id: int,
|
||||
*,
|
||||
card_id: int | None = None,
|
||||
qr_code: str | None = None,
|
||||
card_number: str | None = None,
|
||||
) -> LoyaltyCard:
|
||||
"""
|
||||
Look up a card for a specific vendor (must be in same company).
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Vendor ID (to get company context)
|
||||
card_id: Card ID
|
||||
qr_code: QR code data
|
||||
card_number: Card number
|
||||
|
||||
Returns:
|
||||
Found card (verified to be in vendor's company)
|
||||
|
||||
Raises:
|
||||
LoyaltyCardNotFoundException: If no card found or wrong company
|
||||
"""
|
||||
from app.modules.tenancy.models import Vendor
|
||||
|
||||
vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first()
|
||||
if not vendor:
|
||||
raise LoyaltyCardNotFoundException("vendor not found")
|
||||
|
||||
return self.lookup_card(
|
||||
db,
|
||||
card_id=card_id,
|
||||
qr_code=qr_code,
|
||||
card_number=card_number,
|
||||
company_id=vendor.company_id,
|
||||
)
|
||||
|
||||
def list_cards(
|
||||
self,
|
||||
db: Session,
|
||||
company_id: int,
|
||||
*,
|
||||
vendor_id: int | None = None,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
is_active: bool | None = None,
|
||||
search: str | None = None,
|
||||
) -> tuple[list[LoyaltyCard], int]:
|
||||
"""
|
||||
List loyalty cards for a vendor.
|
||||
List loyalty cards for a company.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Vendor ID
|
||||
company_id: Company ID
|
||||
vendor_id: Optional filter by enrolled vendor
|
||||
skip: Pagination offset
|
||||
limit: Pagination limit
|
||||
is_active: Filter by active status
|
||||
search: Search by card number or customer email
|
||||
search: Search by card number, email, or name
|
||||
|
||||
Returns:
|
||||
(cards, total_count)
|
||||
@@ -149,18 +227,24 @@ class CardService:
|
||||
query = (
|
||||
db.query(LoyaltyCard)
|
||||
.options(joinedload(LoyaltyCard.customer))
|
||||
.filter(LoyaltyCard.vendor_id == vendor_id)
|
||||
.filter(LoyaltyCard.company_id == company_id)
|
||||
)
|
||||
|
||||
if vendor_id:
|
||||
query = query.filter(LoyaltyCard.enrolled_at_vendor_id == vendor_id)
|
||||
|
||||
if is_active is not None:
|
||||
query = query.filter(LoyaltyCard.is_active == is_active)
|
||||
|
||||
if search:
|
||||
# Normalize search term for card number matching
|
||||
search_normalized = search.replace("-", "").replace(" ", "")
|
||||
query = query.join(Customer).filter(
|
||||
(LoyaltyCard.card_number.ilike(f"%{search}%"))
|
||||
(LoyaltyCard.card_number.replace("-", "").ilike(f"%{search_normalized}%"))
|
||||
| (Customer.email.ilike(f"%{search}%"))
|
||||
| (Customer.first_name.ilike(f"%{search}%"))
|
||||
| (Customer.last_name.ilike(f"%{search}%"))
|
||||
| (Customer.phone.ilike(f"%{search}%"))
|
||||
)
|
||||
|
||||
total = query.count()
|
||||
@@ -181,7 +265,7 @@ class CardService:
|
||||
"""List all loyalty cards for a customer."""
|
||||
return (
|
||||
db.query(LoyaltyCard)
|
||||
.options(joinedload(LoyaltyCard.program))
|
||||
.options(joinedload(LoyaltyCard.program), joinedload(LoyaltyCard.company))
|
||||
.filter(LoyaltyCard.customer_id == customer_id)
|
||||
.all()
|
||||
)
|
||||
@@ -194,18 +278,18 @@ class CardService:
|
||||
self,
|
||||
db: Session,
|
||||
customer_id: int,
|
||||
vendor_id: int,
|
||||
company_id: int,
|
||||
*,
|
||||
program_id: int | None = None,
|
||||
enrolled_at_vendor_id: int | None = None,
|
||||
) -> LoyaltyCard:
|
||||
"""
|
||||
Enroll a customer in a loyalty program.
|
||||
Enroll a customer in a company's loyalty program.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
customer_id: Customer ID
|
||||
vendor_id: Vendor ID
|
||||
program_id: Optional program ID (defaults to vendor's program)
|
||||
company_id: Company ID
|
||||
enrolled_at_vendor_id: Vendor where customer enrolled (for analytics)
|
||||
|
||||
Returns:
|
||||
Created loyalty card
|
||||
@@ -216,35 +300,29 @@ class CardService:
|
||||
LoyaltyCardAlreadyExistsException: If customer already enrolled
|
||||
"""
|
||||
# Get the program
|
||||
if program_id:
|
||||
program = (
|
||||
db.query(LoyaltyProgram)
|
||||
.filter(LoyaltyProgram.id == program_id)
|
||||
.first()
|
||||
)
|
||||
else:
|
||||
program = (
|
||||
db.query(LoyaltyProgram)
|
||||
.filter(LoyaltyProgram.vendor_id == vendor_id)
|
||||
.first()
|
||||
)
|
||||
program = (
|
||||
db.query(LoyaltyProgram)
|
||||
.filter(LoyaltyProgram.company_id == company_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not program:
|
||||
raise LoyaltyProgramNotFoundException(f"vendor:{vendor_id}")
|
||||
raise LoyaltyProgramNotFoundException(f"company:{company_id}")
|
||||
|
||||
if not program.is_active:
|
||||
raise LoyaltyProgramInactiveException(program.id)
|
||||
|
||||
# Check if customer already has a card
|
||||
existing = self.get_card_by_customer_and_program(db, customer_id, program.id)
|
||||
existing = self.get_card_by_customer_and_company(db, customer_id, company_id)
|
||||
if existing:
|
||||
raise LoyaltyCardAlreadyExistsException(customer_id, program.id)
|
||||
|
||||
# Create the card
|
||||
card = LoyaltyCard(
|
||||
company_id=company_id,
|
||||
customer_id=customer_id,
|
||||
program_id=program.id,
|
||||
vendor_id=vendor_id,
|
||||
enrolled_at_vendor_id=enrolled_at_vendor_id,
|
||||
)
|
||||
|
||||
db.add(card)
|
||||
@@ -252,32 +330,88 @@ class CardService:
|
||||
|
||||
# Create enrollment transaction
|
||||
transaction = LoyaltyTransaction(
|
||||
company_id=company_id,
|
||||
card_id=card.id,
|
||||
vendor_id=vendor_id,
|
||||
vendor_id=enrolled_at_vendor_id,
|
||||
transaction_type=TransactionType.CARD_CREATED.value,
|
||||
transaction_at=datetime.now(UTC),
|
||||
)
|
||||
db.add(transaction)
|
||||
|
||||
# Award welcome bonus if configured
|
||||
if program.welcome_bonus_points > 0:
|
||||
card.add_points(program.welcome_bonus_points)
|
||||
|
||||
bonus_transaction = LoyaltyTransaction(
|
||||
company_id=company_id,
|
||||
card_id=card.id,
|
||||
vendor_id=enrolled_at_vendor_id,
|
||||
transaction_type=TransactionType.WELCOME_BONUS.value,
|
||||
points_delta=program.welcome_bonus_points,
|
||||
points_balance_after=card.points_balance,
|
||||
notes="Welcome bonus on enrollment",
|
||||
transaction_at=datetime.now(UTC),
|
||||
)
|
||||
db.add(bonus_transaction)
|
||||
|
||||
db.commit()
|
||||
db.refresh(card)
|
||||
|
||||
logger.info(
|
||||
f"Enrolled customer {customer_id} in loyalty program {program.id} "
|
||||
f"(card: {card.card_number})"
|
||||
f"Enrolled customer {customer_id} in company {company_id} loyalty program "
|
||||
f"(card: {card.card_number}, bonus: {program.welcome_bonus_points} pts)"
|
||||
)
|
||||
|
||||
return card
|
||||
|
||||
def deactivate_card(self, db: Session, card_id: int) -> LoyaltyCard:
|
||||
def enroll_customer_for_vendor(
|
||||
self,
|
||||
db: Session,
|
||||
customer_id: int,
|
||||
vendor_id: int,
|
||||
) -> LoyaltyCard:
|
||||
"""
|
||||
Enroll a customer through a specific vendor.
|
||||
|
||||
Looks up the vendor's company and enrolls in the company's program.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
customer_id: Customer ID
|
||||
vendor_id: Vendor ID
|
||||
|
||||
Returns:
|
||||
Created loyalty card
|
||||
"""
|
||||
from app.modules.tenancy.models import Vendor
|
||||
|
||||
vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first()
|
||||
if not vendor:
|
||||
raise LoyaltyProgramNotFoundException(f"vendor:{vendor_id}")
|
||||
|
||||
return self.enroll_customer(
|
||||
db,
|
||||
customer_id,
|
||||
vendor.company_id,
|
||||
enrolled_at_vendor_id=vendor_id,
|
||||
)
|
||||
|
||||
def deactivate_card(
|
||||
self,
|
||||
db: Session,
|
||||
card_id: int,
|
||||
*,
|
||||
vendor_id: int | None = None,
|
||||
) -> LoyaltyCard:
|
||||
"""Deactivate a loyalty card."""
|
||||
card = self.require_card(db, card_id)
|
||||
card.is_active = False
|
||||
|
||||
# Create deactivation transaction
|
||||
transaction = LoyaltyTransaction(
|
||||
company_id=card.company_id,
|
||||
card_id=card.id,
|
||||
vendor_id=card.vendor_id,
|
||||
vendor_id=vendor_id,
|
||||
transaction_type=TransactionType.CARD_DEACTIVATED.value,
|
||||
transaction_at=datetime.now(UTC),
|
||||
)
|
||||
@@ -334,6 +468,7 @@ class CardService:
|
||||
"""Get transaction history for a card."""
|
||||
query = (
|
||||
db.query(LoyaltyTransaction)
|
||||
.options(joinedload(LoyaltyTransaction.vendor))
|
||||
.filter(LoyaltyTransaction.card_id == card_id)
|
||||
.order_by(LoyaltyTransaction.transaction_at.desc())
|
||||
)
|
||||
|
||||
@@ -2,9 +2,14 @@
|
||||
"""
|
||||
Staff PIN service.
|
||||
|
||||
Company-based PIN operations:
|
||||
- PINs belong to a company's loyalty program
|
||||
- Each vendor (location) has its own set of staff PINs
|
||||
- Staff can only use PINs at their assigned location
|
||||
|
||||
Handles PIN operations including:
|
||||
- PIN creation and management
|
||||
- PIN verification with lockout
|
||||
- PIN verification with lockout (per vendor)
|
||||
- PIN security (failed attempts, lockout)
|
||||
"""
|
||||
|
||||
@@ -41,16 +46,17 @@ class PinService:
|
||||
db: Session,
|
||||
program_id: int,
|
||||
staff_id: str,
|
||||
*,
|
||||
vendor_id: int | None = None,
|
||||
) -> StaffPin | None:
|
||||
"""Get a staff PIN by employee ID."""
|
||||
return (
|
||||
db.query(StaffPin)
|
||||
.filter(
|
||||
StaffPin.program_id == program_id,
|
||||
StaffPin.staff_id == staff_id,
|
||||
)
|
||||
.first()
|
||||
query = db.query(StaffPin).filter(
|
||||
StaffPin.program_id == program_id,
|
||||
StaffPin.staff_id == staff_id,
|
||||
)
|
||||
if vendor_id:
|
||||
query = query.filter(StaffPin.vendor_id == vendor_id)
|
||||
return query.first()
|
||||
|
||||
def require_pin(self, db: Session, pin_id: int) -> StaffPin:
|
||||
"""Get a PIN or raise exception if not found."""
|
||||
@@ -64,16 +70,61 @@ class PinService:
|
||||
db: Session,
|
||||
program_id: int,
|
||||
*,
|
||||
vendor_id: int | None = None,
|
||||
is_active: bool | None = None,
|
||||
) -> list[StaffPin]:
|
||||
"""List all staff PINs for a program."""
|
||||
"""
|
||||
List staff PINs for a program.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
program_id: Program ID
|
||||
vendor_id: Optional filter by vendor (location)
|
||||
is_active: Filter by active status
|
||||
|
||||
Returns:
|
||||
List of StaffPin objects
|
||||
"""
|
||||
query = db.query(StaffPin).filter(StaffPin.program_id == program_id)
|
||||
|
||||
if vendor_id is not None:
|
||||
query = query.filter(StaffPin.vendor_id == vendor_id)
|
||||
|
||||
if is_active is not None:
|
||||
query = query.filter(StaffPin.is_active == is_active)
|
||||
|
||||
return query.order_by(StaffPin.name).all()
|
||||
|
||||
def list_pins_for_company(
|
||||
self,
|
||||
db: Session,
|
||||
company_id: int,
|
||||
*,
|
||||
vendor_id: int | None = None,
|
||||
is_active: bool | None = None,
|
||||
) -> list[StaffPin]:
|
||||
"""
|
||||
List staff PINs for a company.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
company_id: Company ID
|
||||
vendor_id: Optional filter by vendor (location)
|
||||
is_active: Filter by active status
|
||||
|
||||
Returns:
|
||||
List of StaffPin objects
|
||||
"""
|
||||
query = db.query(StaffPin).filter(StaffPin.company_id == company_id)
|
||||
|
||||
if vendor_id is not None:
|
||||
query = query.filter(StaffPin.vendor_id == vendor_id)
|
||||
|
||||
if is_active is not None:
|
||||
query = query.filter(StaffPin.is_active == is_active)
|
||||
|
||||
return query.order_by(StaffPin.vendor_id, StaffPin.name).all()
|
||||
|
||||
# =========================================================================
|
||||
# Write Operations
|
||||
# =========================================================================
|
||||
@@ -91,13 +142,21 @@ class PinService:
|
||||
Args:
|
||||
db: Database session
|
||||
program_id: Program ID
|
||||
vendor_id: Vendor ID
|
||||
vendor_id: Vendor ID (location where staff works)
|
||||
data: PIN creation data
|
||||
|
||||
Returns:
|
||||
Created PIN
|
||||
"""
|
||||
from app.modules.loyalty.models import LoyaltyProgram
|
||||
|
||||
# Get company_id from program
|
||||
program = db.query(LoyaltyProgram).filter(LoyaltyProgram.id == program_id).first()
|
||||
if not program:
|
||||
raise StaffPinNotFoundException(f"program:{program_id}")
|
||||
|
||||
pin = StaffPin(
|
||||
company_id=program.company_id,
|
||||
program_id=program_id,
|
||||
vendor_id=vendor_id,
|
||||
name=data.name,
|
||||
@@ -109,7 +168,9 @@ class PinService:
|
||||
db.commit()
|
||||
db.refresh(pin)
|
||||
|
||||
logger.info(f"Created staff PIN {pin.id} for '{pin.name}' in program {program_id}")
|
||||
logger.info(
|
||||
f"Created staff PIN {pin.id} for '{pin.name}' at vendor {vendor_id}"
|
||||
)
|
||||
|
||||
return pin
|
||||
|
||||
@@ -158,11 +219,12 @@ class PinService:
|
||||
"""Delete a staff PIN."""
|
||||
pin = self.require_pin(db, pin_id)
|
||||
program_id = pin.program_id
|
||||
vendor_id = pin.vendor_id
|
||||
|
||||
db.delete(pin)
|
||||
db.commit()
|
||||
|
||||
logger.info(f"Deleted staff PIN {pin_id} from program {program_id}")
|
||||
logger.info(f"Deleted staff PIN {pin_id} from vendor {vendor_id}")
|
||||
|
||||
def unlock_pin(self, db: Session, pin_id: int) -> StaffPin:
|
||||
"""Unlock a locked staff PIN."""
|
||||
@@ -184,16 +246,21 @@ class PinService:
|
||||
db: Session,
|
||||
program_id: int,
|
||||
plain_pin: str,
|
||||
*,
|
||||
vendor_id: int | None = None,
|
||||
) -> StaffPin:
|
||||
"""
|
||||
Verify a staff PIN.
|
||||
|
||||
Checks all active PINs for the program and returns the matching one.
|
||||
For company-wide programs, if vendor_id is provided, only checks
|
||||
PINs assigned to that vendor. This ensures staff can only use
|
||||
their PIN at their assigned location.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
program_id: Program ID
|
||||
plain_pin: Plain text PIN to verify
|
||||
vendor_id: Optional vendor ID to restrict PIN lookup
|
||||
|
||||
Returns:
|
||||
Verified StaffPin object
|
||||
@@ -202,8 +269,8 @@ class PinService:
|
||||
InvalidStaffPinException: PIN is invalid
|
||||
StaffPinLockedException: PIN is locked
|
||||
"""
|
||||
# Get all active PINs for the program
|
||||
pins = self.list_pins(db, program_id, is_active=True)
|
||||
# Get active PINs (optionally filtered by vendor)
|
||||
pins = self.list_pins(db, program_id, vendor_id=vendor_id, is_active=True)
|
||||
|
||||
if not pins:
|
||||
raise InvalidStaffPinException()
|
||||
@@ -220,7 +287,9 @@ class PinService:
|
||||
pin.record_success()
|
||||
db.commit()
|
||||
|
||||
logger.debug(f"PIN verified for '{pin.name}' in program {program_id}")
|
||||
logger.debug(
|
||||
f"PIN verified for '{pin.name}' at vendor {pin.vendor_id}"
|
||||
)
|
||||
|
||||
return pin
|
||||
|
||||
@@ -254,6 +323,8 @@ class PinService:
|
||||
db: Session,
|
||||
program_id: int,
|
||||
plain_pin: str,
|
||||
*,
|
||||
vendor_id: int | None = None,
|
||||
) -> StaffPin | None:
|
||||
"""
|
||||
Find a matching PIN without recording attempts.
|
||||
@@ -264,11 +335,12 @@ class PinService:
|
||||
db: Database session
|
||||
program_id: Program ID
|
||||
plain_pin: Plain text PIN to check
|
||||
vendor_id: Optional vendor ID to restrict lookup
|
||||
|
||||
Returns:
|
||||
Matching StaffPin or None
|
||||
"""
|
||||
pins = self.list_pins(db, program_id, is_active=True)
|
||||
pins = self.list_pins(db, program_id, vendor_id=vendor_id, is_active=True)
|
||||
|
||||
for pin in pins:
|
||||
if not pin.is_locked and pin.verify_pin(plain_pin):
|
||||
|
||||
@@ -2,9 +2,15 @@
|
||||
"""
|
||||
Points service.
|
||||
|
||||
Company-based points operations:
|
||||
- Points earned at any vendor count toward company total
|
||||
- Points can be redeemed at any vendor within the company
|
||||
- Supports voiding points for returns
|
||||
|
||||
Handles points operations including:
|
||||
- Earning points from purchases
|
||||
- Redeeming points for rewards
|
||||
- Voiding points (for returns)
|
||||
- Points balance management
|
||||
"""
|
||||
|
||||
@@ -34,6 +40,7 @@ class PointsService:
|
||||
self,
|
||||
db: Session,
|
||||
*,
|
||||
vendor_id: int,
|
||||
card_id: int | None = None,
|
||||
qr_code: str | None = None,
|
||||
card_number: str | None = None,
|
||||
@@ -51,6 +58,7 @@ class PointsService:
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Vendor ID (where purchase is being made)
|
||||
card_id: Card ID
|
||||
qr_code: QR code data
|
||||
card_number: Card number
|
||||
@@ -64,9 +72,10 @@ class PointsService:
|
||||
Returns:
|
||||
Dict with operation result
|
||||
"""
|
||||
# Look up the card
|
||||
card = card_service.lookup_card(
|
||||
# Look up the card (validates it belongs to vendor's company)
|
||||
card = card_service.lookup_card_for_vendor(
|
||||
db,
|
||||
vendor_id,
|
||||
card_id=card_id,
|
||||
qr_code=qr_code,
|
||||
card_number=card_number,
|
||||
@@ -85,12 +94,26 @@ class PointsService:
|
||||
logger.warning(f"Points attempted on stamps-only program {program.id}")
|
||||
raise LoyaltyCardInactiveException(card.id)
|
||||
|
||||
# Check minimum purchase amount
|
||||
if program.minimum_purchase_cents > 0 and purchase_amount_cents < program.minimum_purchase_cents:
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Purchase below minimum of €{program.minimum_purchase_cents/100:.2f}",
|
||||
"points_earned": 0,
|
||||
"points_per_euro": program.points_per_euro,
|
||||
"purchase_amount_cents": purchase_amount_cents,
|
||||
"card_id": card.id,
|
||||
"card_number": card.card_number,
|
||||
"points_balance": card.points_balance,
|
||||
"total_points_earned": card.total_points_earned,
|
||||
}
|
||||
|
||||
# Verify staff PIN if required
|
||||
verified_pin = None
|
||||
if program.require_staff_pin:
|
||||
if not staff_pin:
|
||||
raise StaffPinRequiredException()
|
||||
verified_pin = pin_service.verify_pin(db, program.id, staff_pin)
|
||||
verified_pin = pin_service.verify_pin(db, program.id, staff_pin, vendor_id=vendor_id)
|
||||
|
||||
# Calculate points
|
||||
# points_per_euro is per full euro, so divide cents by 100
|
||||
@@ -115,11 +138,13 @@ class PointsService:
|
||||
card.points_balance += points_earned
|
||||
card.total_points_earned += points_earned
|
||||
card.last_points_at = now
|
||||
card.last_activity_at = now
|
||||
|
||||
# Create transaction
|
||||
transaction = LoyaltyTransaction(
|
||||
company_id=card.company_id,
|
||||
card_id=card.id,
|
||||
vendor_id=card.vendor_id,
|
||||
vendor_id=vendor_id,
|
||||
staff_pin_id=verified_pin.id if verified_pin else None,
|
||||
transaction_type=TransactionType.POINTS_EARNED.value,
|
||||
points_delta=points_earned,
|
||||
@@ -138,7 +163,7 @@ class PointsService:
|
||||
db.refresh(card)
|
||||
|
||||
logger.info(
|
||||
f"Added {points_earned} points to card {card.id} "
|
||||
f"Added {points_earned} points to card {card.id} at vendor {vendor_id} "
|
||||
f"(purchase: €{purchase_euros:.2f}, balance: {card.points_balance})"
|
||||
)
|
||||
|
||||
@@ -152,12 +177,14 @@ class PointsService:
|
||||
"card_number": card.card_number,
|
||||
"points_balance": card.points_balance,
|
||||
"total_points_earned": card.total_points_earned,
|
||||
"vendor_id": vendor_id,
|
||||
}
|
||||
|
||||
def redeem_points(
|
||||
self,
|
||||
db: Session,
|
||||
*,
|
||||
vendor_id: int,
|
||||
card_id: int | None = None,
|
||||
qr_code: str | None = None,
|
||||
card_number: str | None = None,
|
||||
@@ -172,6 +199,7 @@ class PointsService:
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Vendor ID (where redemption is happening)
|
||||
card_id: Card ID
|
||||
qr_code: QR code data
|
||||
card_number: Card number
|
||||
@@ -188,9 +216,10 @@ class PointsService:
|
||||
InvalidRewardException: Reward not found or inactive
|
||||
InsufficientPointsException: Not enough points
|
||||
"""
|
||||
# Look up the card
|
||||
card = card_service.lookup_card(
|
||||
# Look up the card (validates it belongs to vendor's company)
|
||||
card = card_service.lookup_card_for_vendor(
|
||||
db,
|
||||
vendor_id,
|
||||
card_id=card_id,
|
||||
qr_code=qr_code,
|
||||
card_number=card_number,
|
||||
@@ -215,6 +244,10 @@ class PointsService:
|
||||
points_required = reward["points_required"]
|
||||
reward_name = reward["name"]
|
||||
|
||||
# Check minimum redemption
|
||||
if points_required < program.minimum_redemption_points:
|
||||
raise InvalidRewardException(reward_id)
|
||||
|
||||
# Check if enough points
|
||||
if card.points_balance < points_required:
|
||||
raise InsufficientPointsException(card.points_balance, points_required)
|
||||
@@ -224,18 +257,20 @@ class PointsService:
|
||||
if program.require_staff_pin:
|
||||
if not staff_pin:
|
||||
raise StaffPinRequiredException()
|
||||
verified_pin = pin_service.verify_pin(db, program.id, staff_pin)
|
||||
verified_pin = pin_service.verify_pin(db, program.id, staff_pin, vendor_id=vendor_id)
|
||||
|
||||
# Redeem points
|
||||
now = datetime.now(UTC)
|
||||
card.points_balance -= points_required
|
||||
card.points_redeemed += points_required
|
||||
card.last_redemption_at = now
|
||||
card.last_activity_at = now
|
||||
|
||||
# Create transaction
|
||||
transaction = LoyaltyTransaction(
|
||||
company_id=card.company_id,
|
||||
card_id=card.id,
|
||||
vendor_id=card.vendor_id,
|
||||
vendor_id=vendor_id,
|
||||
staff_pin_id=verified_pin.id if verified_pin else None,
|
||||
transaction_type=TransactionType.POINTS_REDEEMED.value,
|
||||
points_delta=-points_required,
|
||||
@@ -254,7 +289,7 @@ class PointsService:
|
||||
db.refresh(card)
|
||||
|
||||
logger.info(
|
||||
f"Redeemed {points_required} points from card {card.id} "
|
||||
f"Redeemed {points_required} points from card {card.id} at vendor {vendor_id} "
|
||||
f"(reward: {reward_name}, balance: {card.points_balance})"
|
||||
)
|
||||
|
||||
@@ -268,6 +303,140 @@ class PointsService:
|
||||
"card_number": card.card_number,
|
||||
"points_balance": card.points_balance,
|
||||
"total_points_redeemed": card.points_redeemed,
|
||||
"vendor_id": vendor_id,
|
||||
}
|
||||
|
||||
def void_points(
|
||||
self,
|
||||
db: Session,
|
||||
*,
|
||||
vendor_id: int,
|
||||
card_id: int | None = None,
|
||||
qr_code: str | None = None,
|
||||
card_number: str | None = None,
|
||||
points_to_void: int | None = None,
|
||||
original_transaction_id: int | None = None,
|
||||
order_reference: str | None = None,
|
||||
staff_pin: str | None = None,
|
||||
ip_address: str | None = None,
|
||||
user_agent: str | None = None,
|
||||
notes: str | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Void points for a return.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Vendor ID
|
||||
card_id: Card ID
|
||||
qr_code: QR code data
|
||||
card_number: Card number
|
||||
points_to_void: Number of points to void (if not using original_transaction_id)
|
||||
original_transaction_id: ID of original earn transaction to void
|
||||
order_reference: Order reference (to find original transaction)
|
||||
staff_pin: Staff PIN for verification
|
||||
ip_address: Request IP for audit
|
||||
user_agent: Request user agent for audit
|
||||
notes: Reason for voiding
|
||||
|
||||
Returns:
|
||||
Dict with operation result
|
||||
"""
|
||||
# Look up the card
|
||||
card = card_service.lookup_card_for_vendor(
|
||||
db,
|
||||
vendor_id,
|
||||
card_id=card_id,
|
||||
qr_code=qr_code,
|
||||
card_number=card_number,
|
||||
)
|
||||
|
||||
program = card.program
|
||||
|
||||
# Verify staff PIN if required
|
||||
verified_pin = None
|
||||
if program.require_staff_pin:
|
||||
if not staff_pin:
|
||||
raise StaffPinRequiredException()
|
||||
verified_pin = pin_service.verify_pin(db, program.id, staff_pin, vendor_id=vendor_id)
|
||||
|
||||
# Determine points to void
|
||||
original_transaction = None
|
||||
if original_transaction_id:
|
||||
original_transaction = (
|
||||
db.query(LoyaltyTransaction)
|
||||
.filter(
|
||||
LoyaltyTransaction.id == original_transaction_id,
|
||||
LoyaltyTransaction.card_id == card.id,
|
||||
LoyaltyTransaction.transaction_type == TransactionType.POINTS_EARNED.value,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if original_transaction:
|
||||
points_to_void = original_transaction.points_delta
|
||||
elif order_reference:
|
||||
original_transaction = (
|
||||
db.query(LoyaltyTransaction)
|
||||
.filter(
|
||||
LoyaltyTransaction.order_reference == order_reference,
|
||||
LoyaltyTransaction.card_id == card.id,
|
||||
LoyaltyTransaction.transaction_type == TransactionType.POINTS_EARNED.value,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if original_transaction:
|
||||
points_to_void = original_transaction.points_delta
|
||||
|
||||
if not points_to_void or points_to_void <= 0:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No points to void",
|
||||
"card_id": card.id,
|
||||
"card_number": card.card_number,
|
||||
"points_balance": card.points_balance,
|
||||
}
|
||||
|
||||
# Void the points (can reduce balance below what was earned)
|
||||
now = datetime.now(UTC)
|
||||
actual_voided = min(points_to_void, card.points_balance)
|
||||
card.points_balance = max(0, card.points_balance - points_to_void)
|
||||
card.last_activity_at = now
|
||||
|
||||
# Create void transaction
|
||||
transaction = LoyaltyTransaction(
|
||||
company_id=card.company_id,
|
||||
card_id=card.id,
|
||||
vendor_id=vendor_id,
|
||||
staff_pin_id=verified_pin.id if verified_pin else None,
|
||||
transaction_type=TransactionType.POINTS_VOIDED.value,
|
||||
points_delta=-actual_voided,
|
||||
stamps_balance_after=card.stamp_count,
|
||||
points_balance_after=card.points_balance,
|
||||
related_transaction_id=original_transaction.id if original_transaction else None,
|
||||
order_reference=order_reference,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
notes=notes or "Points voided for return",
|
||||
transaction_at=now,
|
||||
)
|
||||
db.add(transaction)
|
||||
|
||||
db.commit()
|
||||
db.refresh(card)
|
||||
|
||||
logger.info(
|
||||
f"Voided {actual_voided} points from card {card.id} at vendor {vendor_id} "
|
||||
f"(balance: {card.points_balance})"
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Points voided successfully",
|
||||
"points_voided": actual_voided,
|
||||
"card_id": card.id,
|
||||
"card_number": card.card_number,
|
||||
"points_balance": card.points_balance,
|
||||
"vendor_id": vendor_id,
|
||||
}
|
||||
|
||||
def adjust_points(
|
||||
@@ -276,18 +445,20 @@ class PointsService:
|
||||
card_id: int,
|
||||
points_delta: int,
|
||||
*,
|
||||
vendor_id: int | None = None,
|
||||
reason: str,
|
||||
staff_pin: str | None = None,
|
||||
ip_address: str | None = None,
|
||||
user_agent: str | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Manually adjust points (admin operation).
|
||||
Manually adjust points (admin/vendor operation).
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
card_id: Card ID
|
||||
points_delta: Points to add (positive) or remove (negative)
|
||||
vendor_id: Vendor ID
|
||||
reason: Reason for adjustment
|
||||
staff_pin: Staff PIN for verification
|
||||
ip_address: Request IP for audit
|
||||
@@ -299,14 +470,15 @@ class PointsService:
|
||||
card = card_service.require_card(db, card_id)
|
||||
program = card.program
|
||||
|
||||
# Verify staff PIN if required
|
||||
# Verify staff PIN if required and vendor provided
|
||||
verified_pin = None
|
||||
if program.require_staff_pin and staff_pin:
|
||||
verified_pin = pin_service.verify_pin(db, program.id, staff_pin)
|
||||
if program.require_staff_pin and staff_pin and vendor_id:
|
||||
verified_pin = pin_service.verify_pin(db, program.id, staff_pin, vendor_id=vendor_id)
|
||||
|
||||
# Apply adjustment
|
||||
now = datetime.now(UTC)
|
||||
card.points_balance += points_delta
|
||||
card.last_activity_at = now
|
||||
|
||||
if points_delta > 0:
|
||||
card.total_points_earned += points_delta
|
||||
@@ -320,8 +492,9 @@ class PointsService:
|
||||
|
||||
# Create transaction
|
||||
transaction = LoyaltyTransaction(
|
||||
company_id=card.company_id,
|
||||
card_id=card.id,
|
||||
vendor_id=card.vendor_id,
|
||||
vendor_id=vendor_id,
|
||||
staff_pin_id=verified_pin.id if verified_pin else None,
|
||||
transaction_type=TransactionType.POINTS_ADJUSTMENT.value,
|
||||
points_delta=points_delta,
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
"""
|
||||
Loyalty program service.
|
||||
|
||||
Company-based program management:
|
||||
- Programs belong to companies, not individual vendors
|
||||
- All vendors under a company share the same loyalty program
|
||||
- One program per company
|
||||
|
||||
Handles CRUD operations for loyalty programs including:
|
||||
- Program creation and configuration
|
||||
- Program updates
|
||||
@@ -18,7 +23,11 @@ from app.modules.loyalty.exceptions import (
|
||||
LoyaltyProgramAlreadyExistsException,
|
||||
LoyaltyProgramNotFoundException,
|
||||
)
|
||||
from app.modules.loyalty.models import LoyaltyProgram, LoyaltyType
|
||||
from app.modules.loyalty.models import (
|
||||
LoyaltyProgram,
|
||||
LoyaltyType,
|
||||
CompanyLoyaltySettings,
|
||||
)
|
||||
from app.modules.loyalty.schemas.program import (
|
||||
ProgramCreate,
|
||||
ProgramUpdate,
|
||||
@@ -42,25 +51,53 @@ class ProgramService:
|
||||
.first()
|
||||
)
|
||||
|
||||
def get_program_by_vendor(self, db: Session, vendor_id: int) -> LoyaltyProgram | None:
|
||||
"""Get a vendor's loyalty program."""
|
||||
def get_program_by_company(self, db: Session, company_id: int) -> LoyaltyProgram | None:
|
||||
"""Get a company's loyalty program."""
|
||||
return (
|
||||
db.query(LoyaltyProgram)
|
||||
.filter(LoyaltyProgram.vendor_id == vendor_id)
|
||||
.filter(LoyaltyProgram.company_id == company_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
def get_active_program_by_vendor(self, db: Session, vendor_id: int) -> LoyaltyProgram | None:
|
||||
"""Get a vendor's active loyalty program."""
|
||||
def get_active_program_by_company(self, db: Session, company_id: int) -> LoyaltyProgram | None:
|
||||
"""Get a company's active loyalty program."""
|
||||
return (
|
||||
db.query(LoyaltyProgram)
|
||||
.filter(
|
||||
LoyaltyProgram.vendor_id == vendor_id,
|
||||
LoyaltyProgram.company_id == company_id,
|
||||
LoyaltyProgram.is_active == True,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
def get_program_by_vendor(self, db: Session, vendor_id: int) -> LoyaltyProgram | None:
|
||||
"""
|
||||
Get the loyalty program for a vendor.
|
||||
|
||||
Looks up the vendor's company and returns the company's program.
|
||||
"""
|
||||
from app.modules.tenancy.models import Vendor
|
||||
|
||||
vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first()
|
||||
if not vendor:
|
||||
return None
|
||||
|
||||
return self.get_program_by_company(db, vendor.company_id)
|
||||
|
||||
def get_active_program_by_vendor(self, db: Session, vendor_id: int) -> LoyaltyProgram | None:
|
||||
"""
|
||||
Get the active loyalty program for a vendor.
|
||||
|
||||
Looks up the vendor's company and returns the company's active program.
|
||||
"""
|
||||
from app.modules.tenancy.models import Vendor
|
||||
|
||||
vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first()
|
||||
if not vendor:
|
||||
return None
|
||||
|
||||
return self.get_active_program_by_company(db, vendor.company_id)
|
||||
|
||||
def require_program(self, db: Session, program_id: int) -> LoyaltyProgram:
|
||||
"""Get a program or raise exception if not found."""
|
||||
program = self.get_program(db, program_id)
|
||||
@@ -68,6 +105,13 @@ class ProgramService:
|
||||
raise LoyaltyProgramNotFoundException(str(program_id))
|
||||
return program
|
||||
|
||||
def require_program_by_company(self, db: Session, company_id: int) -> LoyaltyProgram:
|
||||
"""Get a company's program or raise exception if not found."""
|
||||
program = self.get_program_by_company(db, company_id)
|
||||
if not program:
|
||||
raise LoyaltyProgramNotFoundException(f"company:{company_id}")
|
||||
return program
|
||||
|
||||
def require_program_by_vendor(self, db: Session, vendor_id: int) -> LoyaltyProgram:
|
||||
"""Get a vendor's program or raise exception if not found."""
|
||||
program = self.get_program_by_vendor(db, vendor_id)
|
||||
@@ -82,15 +126,32 @@ class ProgramService:
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
is_active: bool | None = None,
|
||||
search: str | None = None,
|
||||
) -> tuple[list[LoyaltyProgram], int]:
|
||||
"""List all loyalty programs (admin)."""
|
||||
query = db.query(LoyaltyProgram)
|
||||
"""List all loyalty programs (admin).
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
skip: Number of records to skip
|
||||
limit: Maximum records to return
|
||||
is_active: Filter by active status
|
||||
search: Search by company name (case-insensitive)
|
||||
"""
|
||||
from app.modules.tenancy.models import Company
|
||||
|
||||
query = db.query(LoyaltyProgram).join(
|
||||
Company, LoyaltyProgram.company_id == Company.id
|
||||
)
|
||||
|
||||
if is_active is not None:
|
||||
query = query.filter(LoyaltyProgram.is_active == is_active)
|
||||
|
||||
if search:
|
||||
search_pattern = f"%{search}%"
|
||||
query = query.filter(Company.name.ilike(search_pattern))
|
||||
|
||||
total = query.count()
|
||||
programs = query.offset(skip).limit(limit).all()
|
||||
programs = query.order_by(LoyaltyProgram.created_at.desc()).offset(skip).limit(limit).all()
|
||||
|
||||
return programs, total
|
||||
|
||||
@@ -101,33 +162,33 @@ class ProgramService:
|
||||
def create_program(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_id: int,
|
||||
company_id: int,
|
||||
data: ProgramCreate,
|
||||
) -> LoyaltyProgram:
|
||||
"""
|
||||
Create a new loyalty program for a vendor.
|
||||
Create a new loyalty program for a company.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Vendor ID
|
||||
company_id: Company ID
|
||||
data: Program configuration
|
||||
|
||||
Returns:
|
||||
Created program
|
||||
|
||||
Raises:
|
||||
LoyaltyProgramAlreadyExistsException: If vendor already has a program
|
||||
LoyaltyProgramAlreadyExistsException: If company already has a program
|
||||
"""
|
||||
# Check if vendor already has a program
|
||||
existing = self.get_program_by_vendor(db, vendor_id)
|
||||
# Check if company already has a program
|
||||
existing = self.get_program_by_company(db, company_id)
|
||||
if existing:
|
||||
raise LoyaltyProgramAlreadyExistsException(vendor_id)
|
||||
raise LoyaltyProgramAlreadyExistsException(company_id)
|
||||
|
||||
# Convert points_rewards to dict list for JSON storage
|
||||
points_rewards_data = [r.model_dump() for r in data.points_rewards]
|
||||
|
||||
program = LoyaltyProgram(
|
||||
vendor_id=vendor_id,
|
||||
company_id=company_id,
|
||||
loyalty_type=data.loyalty_type,
|
||||
# Stamps
|
||||
stamps_target=data.stamps_target,
|
||||
@@ -136,6 +197,10 @@ class ProgramService:
|
||||
# Points
|
||||
points_per_euro=data.points_per_euro,
|
||||
points_rewards=points_rewards_data,
|
||||
points_expiration_days=data.points_expiration_days,
|
||||
welcome_bonus_points=data.welcome_bonus_points,
|
||||
minimum_redemption_points=data.minimum_redemption_points,
|
||||
minimum_purchase_cents=data.minimum_purchase_cents,
|
||||
# Anti-fraud
|
||||
cooldown_minutes=data.cooldown_minutes,
|
||||
max_daily_stamps=data.max_daily_stamps,
|
||||
@@ -155,11 +220,19 @@ class ProgramService:
|
||||
)
|
||||
|
||||
db.add(program)
|
||||
db.flush()
|
||||
|
||||
# Create default company settings
|
||||
settings = CompanyLoyaltySettings(
|
||||
company_id=company_id,
|
||||
)
|
||||
db.add(settings)
|
||||
|
||||
db.commit()
|
||||
db.refresh(program)
|
||||
|
||||
logger.info(
|
||||
f"Created loyalty program {program.id} for vendor {vendor_id} "
|
||||
f"Created loyalty program {program.id} for company {company_id} "
|
||||
f"(type: {program.loyalty_type})"
|
||||
)
|
||||
|
||||
@@ -224,12 +297,39 @@ class ProgramService:
|
||||
def delete_program(self, db: Session, program_id: int) -> None:
|
||||
"""Delete a loyalty program and all associated data."""
|
||||
program = self.require_program(db, program_id)
|
||||
vendor_id = program.vendor_id
|
||||
company_id = program.company_id
|
||||
|
||||
# Also delete company settings
|
||||
db.query(CompanyLoyaltySettings).filter(
|
||||
CompanyLoyaltySettings.company_id == company_id
|
||||
).delete()
|
||||
|
||||
db.delete(program)
|
||||
db.commit()
|
||||
|
||||
logger.info(f"Deleted loyalty program {program_id} for vendor {vendor_id}")
|
||||
logger.info(f"Deleted loyalty program {program_id} for company {company_id}")
|
||||
|
||||
# =========================================================================
|
||||
# Company Settings
|
||||
# =========================================================================
|
||||
|
||||
def get_company_settings(self, db: Session, company_id: int) -> CompanyLoyaltySettings | None:
|
||||
"""Get company loyalty settings."""
|
||||
return (
|
||||
db.query(CompanyLoyaltySettings)
|
||||
.filter(CompanyLoyaltySettings.company_id == company_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
def get_or_create_company_settings(self, db: Session, company_id: int) -> CompanyLoyaltySettings:
|
||||
"""Get or create company loyalty settings."""
|
||||
settings = self.get_company_settings(db, company_id)
|
||||
if not settings:
|
||||
settings = CompanyLoyaltySettings(company_id=company_id)
|
||||
db.add(settings)
|
||||
db.commit()
|
||||
db.refresh(settings)
|
||||
return settings
|
||||
|
||||
# =========================================================================
|
||||
# Statistics
|
||||
@@ -374,6 +474,196 @@ class ProgramService:
|
||||
"estimated_liability_cents": estimated_liability,
|
||||
}
|
||||
|
||||
def get_company_stats(self, db: Session, company_id: int) -> dict:
|
||||
"""
|
||||
Get statistics for a company's loyalty program across all locations.
|
||||
|
||||
Returns dict with per-vendor breakdown.
|
||||
"""
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from sqlalchemy import func
|
||||
|
||||
from app.modules.loyalty.models import LoyaltyCard, LoyaltyTransaction
|
||||
from app.modules.tenancy.models import Vendor
|
||||
|
||||
program = self.get_program_by_company(db, company_id)
|
||||
|
||||
# Base stats dict
|
||||
stats = {
|
||||
"company_id": company_id,
|
||||
"program_id": program.id if program else None,
|
||||
"total_cards": 0,
|
||||
"active_cards": 0,
|
||||
"total_points_issued": 0,
|
||||
"total_points_redeemed": 0,
|
||||
"points_issued_30d": 0,
|
||||
"points_redeemed_30d": 0,
|
||||
"transactions_30d": 0,
|
||||
"program": None,
|
||||
"locations": [],
|
||||
}
|
||||
|
||||
if not program:
|
||||
return stats
|
||||
|
||||
# Add program info
|
||||
stats["program"] = {
|
||||
"id": program.id,
|
||||
"display_name": program.display_name,
|
||||
"card_name": program.card_name,
|
||||
"loyalty_type": program.loyalty_type.value if hasattr(program.loyalty_type, 'value') else str(program.loyalty_type),
|
||||
"points_per_euro": program.points_per_euro,
|
||||
"welcome_bonus_points": program.welcome_bonus_points,
|
||||
"minimum_redemption_points": program.minimum_redemption_points,
|
||||
"points_expiration_days": program.points_expiration_days,
|
||||
"is_active": program.is_active,
|
||||
}
|
||||
|
||||
thirty_days_ago = datetime.now(UTC) - timedelta(days=30)
|
||||
|
||||
# Total cards
|
||||
stats["total_cards"] = (
|
||||
db.query(func.count(LoyaltyCard.id))
|
||||
.filter(LoyaltyCard.company_id == company_id)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
# Active cards
|
||||
stats["active_cards"] = (
|
||||
db.query(func.count(LoyaltyCard.id))
|
||||
.filter(
|
||||
LoyaltyCard.company_id == company_id,
|
||||
LoyaltyCard.is_active == True,
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
# Total points issued (all time)
|
||||
stats["total_points_issued"] = (
|
||||
db.query(func.sum(LoyaltyTransaction.points_delta))
|
||||
.filter(
|
||||
LoyaltyTransaction.company_id == company_id,
|
||||
LoyaltyTransaction.points_delta > 0,
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
# Total points redeemed (all time)
|
||||
stats["total_points_redeemed"] = (
|
||||
db.query(func.sum(func.abs(LoyaltyTransaction.points_delta)))
|
||||
.filter(
|
||||
LoyaltyTransaction.company_id == company_id,
|
||||
LoyaltyTransaction.points_delta < 0,
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
# Points issued (30 days)
|
||||
stats["points_issued_30d"] = (
|
||||
db.query(func.sum(LoyaltyTransaction.points_delta))
|
||||
.filter(
|
||||
LoyaltyTransaction.company_id == company_id,
|
||||
LoyaltyTransaction.points_delta > 0,
|
||||
LoyaltyTransaction.transaction_at >= thirty_days_ago,
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
# Points redeemed (30 days)
|
||||
stats["points_redeemed_30d"] = (
|
||||
db.query(func.sum(func.abs(LoyaltyTransaction.points_delta)))
|
||||
.filter(
|
||||
LoyaltyTransaction.company_id == company_id,
|
||||
LoyaltyTransaction.points_delta < 0,
|
||||
LoyaltyTransaction.transaction_at >= thirty_days_ago,
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
# Transactions (30 days)
|
||||
stats["transactions_30d"] = (
|
||||
db.query(func.count(LoyaltyTransaction.id))
|
||||
.filter(
|
||||
LoyaltyTransaction.company_id == company_id,
|
||||
LoyaltyTransaction.transaction_at >= thirty_days_ago,
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
# Get all vendors for this company for location breakdown
|
||||
vendors = db.query(Vendor).filter(Vendor.company_id == company_id).all()
|
||||
|
||||
location_stats = []
|
||||
for vendor in vendors:
|
||||
# Cards enrolled at this vendor
|
||||
enrolled_count = (
|
||||
db.query(func.count(LoyaltyCard.id))
|
||||
.filter(
|
||||
LoyaltyCard.company_id == company_id,
|
||||
LoyaltyCard.enrolled_at_vendor_id == vendor.id,
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
# Points earned at this vendor
|
||||
points_earned = (
|
||||
db.query(func.sum(LoyaltyTransaction.points_delta))
|
||||
.filter(
|
||||
LoyaltyTransaction.company_id == company_id,
|
||||
LoyaltyTransaction.vendor_id == vendor.id,
|
||||
LoyaltyTransaction.points_delta > 0,
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
# Points redeemed at this vendor
|
||||
points_redeemed = (
|
||||
db.query(func.sum(func.abs(LoyaltyTransaction.points_delta)))
|
||||
.filter(
|
||||
LoyaltyTransaction.company_id == company_id,
|
||||
LoyaltyTransaction.vendor_id == vendor.id,
|
||||
LoyaltyTransaction.points_delta < 0,
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
# Transactions (30 days) at this vendor
|
||||
transactions_30d = (
|
||||
db.query(func.count(LoyaltyTransaction.id))
|
||||
.filter(
|
||||
LoyaltyTransaction.company_id == company_id,
|
||||
LoyaltyTransaction.vendor_id == vendor.id,
|
||||
LoyaltyTransaction.transaction_at >= thirty_days_ago,
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
location_stats.append({
|
||||
"vendor_id": vendor.id,
|
||||
"vendor_name": vendor.name,
|
||||
"vendor_code": vendor.vendor_code,
|
||||
"enrolled_count": enrolled_count,
|
||||
"points_earned": points_earned,
|
||||
"points_redeemed": points_redeemed,
|
||||
"transactions_30d": transactions_30d,
|
||||
})
|
||||
|
||||
stats["locations"] = location_stats
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
# Singleton instance
|
||||
program_service = ProgramService()
|
||||
|
||||
@@ -2,9 +2,15 @@
|
||||
"""
|
||||
Stamp service.
|
||||
|
||||
Company-based stamp operations:
|
||||
- Stamps earned at any vendor count toward company total
|
||||
- Stamps can be redeemed at any vendor within the company
|
||||
- Supports voiding stamps for returns
|
||||
|
||||
Handles stamp operations including:
|
||||
- Adding stamps with anti-fraud checks
|
||||
- Redeeming stamps for rewards
|
||||
- Voiding stamps (for returns)
|
||||
- Daily limit tracking
|
||||
"""
|
||||
|
||||
@@ -36,6 +42,7 @@ class StampService:
|
||||
self,
|
||||
db: Session,
|
||||
*,
|
||||
vendor_id: int,
|
||||
card_id: int | None = None,
|
||||
qr_code: str | None = None,
|
||||
card_number: str | None = None,
|
||||
@@ -54,6 +61,7 @@ class StampService:
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Vendor ID (where stamp is being added)
|
||||
card_id: Card ID
|
||||
qr_code: QR code data
|
||||
card_number: Card number
|
||||
@@ -74,9 +82,10 @@ class StampService:
|
||||
StampCooldownException: Cooldown period not elapsed
|
||||
DailyStampLimitException: Daily limit reached
|
||||
"""
|
||||
# Look up the card
|
||||
card = card_service.lookup_card(
|
||||
# Look up the card (validates it belongs to vendor's company)
|
||||
card = card_service.lookup_card_for_vendor(
|
||||
db,
|
||||
vendor_id,
|
||||
card_id=card_id,
|
||||
qr_code=qr_code,
|
||||
card_number=card_number,
|
||||
@@ -100,7 +109,7 @@ class StampService:
|
||||
if program.require_staff_pin:
|
||||
if not staff_pin:
|
||||
raise StaffPinRequiredException()
|
||||
verified_pin = pin_service.verify_pin(db, program.id, staff_pin)
|
||||
verified_pin = pin_service.verify_pin(db, program.id, staff_pin, vendor_id=vendor_id)
|
||||
|
||||
# Check cooldown
|
||||
now = datetime.now(UTC)
|
||||
@@ -121,14 +130,16 @@ class StampService:
|
||||
card.stamp_count += 1
|
||||
card.total_stamps_earned += 1
|
||||
card.last_stamp_at = now
|
||||
card.last_activity_at = now
|
||||
|
||||
# Check if reward earned
|
||||
reward_earned = card.stamp_count >= program.stamps_target
|
||||
|
||||
# Create transaction
|
||||
transaction = LoyaltyTransaction(
|
||||
company_id=card.company_id,
|
||||
card_id=card.id,
|
||||
vendor_id=card.vendor_id,
|
||||
vendor_id=vendor_id,
|
||||
staff_pin_id=verified_pin.id if verified_pin else None,
|
||||
transaction_type=TransactionType.STAMP_EARNED.value,
|
||||
stamps_delta=1,
|
||||
@@ -147,7 +158,7 @@ class StampService:
|
||||
stamps_today += 1
|
||||
|
||||
logger.info(
|
||||
f"Added stamp to card {card.id} "
|
||||
f"Added stamp to card {card.id} at vendor {vendor_id} "
|
||||
f"(stamps: {card.stamp_count}/{program.stamps_target}, "
|
||||
f"today: {stamps_today}/{program.max_daily_stamps})"
|
||||
)
|
||||
@@ -168,12 +179,14 @@ class StampService:
|
||||
"next_stamp_available_at": next_stamp_at,
|
||||
"stamps_today": stamps_today,
|
||||
"stamps_remaining_today": max(0, program.max_daily_stamps - stamps_today),
|
||||
"vendor_id": vendor_id,
|
||||
}
|
||||
|
||||
def redeem_stamps(
|
||||
self,
|
||||
db: Session,
|
||||
*,
|
||||
vendor_id: int,
|
||||
card_id: int | None = None,
|
||||
qr_code: str | None = None,
|
||||
card_number: str | None = None,
|
||||
@@ -187,6 +200,7 @@ class StampService:
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Vendor ID (where redemption is happening)
|
||||
card_id: Card ID
|
||||
qr_code: QR code data
|
||||
card_number: Card number
|
||||
@@ -203,9 +217,10 @@ class StampService:
|
||||
InsufficientStampsException: Not enough stamps
|
||||
StaffPinRequiredException: PIN required but not provided
|
||||
"""
|
||||
# Look up the card
|
||||
card = card_service.lookup_card(
|
||||
# Look up the card (validates it belongs to vendor's company)
|
||||
card = card_service.lookup_card_for_vendor(
|
||||
db,
|
||||
vendor_id,
|
||||
card_id=card_id,
|
||||
qr_code=qr_code,
|
||||
card_number=card_number,
|
||||
@@ -228,7 +243,7 @@ class StampService:
|
||||
if program.require_staff_pin:
|
||||
if not staff_pin:
|
||||
raise StaffPinRequiredException()
|
||||
verified_pin = pin_service.verify_pin(db, program.id, staff_pin)
|
||||
verified_pin = pin_service.verify_pin(db, program.id, staff_pin, vendor_id=vendor_id)
|
||||
|
||||
# Redeem stamps
|
||||
now = datetime.now(UTC)
|
||||
@@ -236,11 +251,13 @@ class StampService:
|
||||
card.stamp_count -= stamps_redeemed
|
||||
card.stamps_redeemed += 1
|
||||
card.last_redemption_at = now
|
||||
card.last_activity_at = now
|
||||
|
||||
# Create transaction
|
||||
transaction = LoyaltyTransaction(
|
||||
company_id=card.company_id,
|
||||
card_id=card.id,
|
||||
vendor_id=card.vendor_id,
|
||||
vendor_id=vendor_id,
|
||||
staff_pin_id=verified_pin.id if verified_pin else None,
|
||||
transaction_type=TransactionType.STAMP_REDEEMED.value,
|
||||
stamps_delta=-stamps_redeemed,
|
||||
@@ -258,7 +275,7 @@ class StampService:
|
||||
db.refresh(card)
|
||||
|
||||
logger.info(
|
||||
f"Redeemed stamps from card {card.id} "
|
||||
f"Redeemed stamps from card {card.id} at vendor {vendor_id} "
|
||||
f"(reward: {program.stamps_reward_description}, "
|
||||
f"total redemptions: {card.stamps_redeemed})"
|
||||
)
|
||||
@@ -272,6 +289,125 @@ class StampService:
|
||||
"stamps_target": program.stamps_target,
|
||||
"reward_description": program.stamps_reward_description,
|
||||
"total_redemptions": card.stamps_redeemed,
|
||||
"vendor_id": vendor_id,
|
||||
}
|
||||
|
||||
def void_stamps(
|
||||
self,
|
||||
db: Session,
|
||||
*,
|
||||
vendor_id: int,
|
||||
card_id: int | None = None,
|
||||
qr_code: str | None = None,
|
||||
card_number: str | None = None,
|
||||
stamps_to_void: int | None = None,
|
||||
original_transaction_id: int | None = None,
|
||||
staff_pin: str | None = None,
|
||||
ip_address: str | None = None,
|
||||
user_agent: str | None = None,
|
||||
notes: str | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Void stamps for a return.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Vendor ID
|
||||
card_id: Card ID
|
||||
qr_code: QR code data
|
||||
card_number: Card number
|
||||
stamps_to_void: Number of stamps to void (if not using original_transaction_id)
|
||||
original_transaction_id: ID of original stamp transaction to void
|
||||
staff_pin: Staff PIN for verification
|
||||
ip_address: Request IP for audit
|
||||
user_agent: Request user agent for audit
|
||||
notes: Reason for voiding
|
||||
|
||||
Returns:
|
||||
Dict with operation result
|
||||
"""
|
||||
# Look up the card
|
||||
card = card_service.lookup_card_for_vendor(
|
||||
db,
|
||||
vendor_id,
|
||||
card_id=card_id,
|
||||
qr_code=qr_code,
|
||||
card_number=card_number,
|
||||
)
|
||||
|
||||
program = card.program
|
||||
|
||||
# Verify staff PIN if required
|
||||
verified_pin = None
|
||||
if program.require_staff_pin:
|
||||
if not staff_pin:
|
||||
raise StaffPinRequiredException()
|
||||
verified_pin = pin_service.verify_pin(db, program.id, staff_pin, vendor_id=vendor_id)
|
||||
|
||||
# Determine stamps to void
|
||||
original_transaction = None
|
||||
if original_transaction_id:
|
||||
original_transaction = (
|
||||
db.query(LoyaltyTransaction)
|
||||
.filter(
|
||||
LoyaltyTransaction.id == original_transaction_id,
|
||||
LoyaltyTransaction.card_id == card.id,
|
||||
LoyaltyTransaction.transaction_type == TransactionType.STAMP_EARNED.value,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if original_transaction:
|
||||
stamps_to_void = original_transaction.stamps_delta
|
||||
|
||||
if not stamps_to_void or stamps_to_void <= 0:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No stamps to void",
|
||||
"card_id": card.id,
|
||||
"card_number": card.card_number,
|
||||
"stamp_count": card.stamp_count,
|
||||
}
|
||||
|
||||
# Void the stamps (can reduce balance below what was earned)
|
||||
now = datetime.now(UTC)
|
||||
actual_voided = min(stamps_to_void, card.stamp_count)
|
||||
card.stamp_count = max(0, card.stamp_count - stamps_to_void)
|
||||
card.last_activity_at = now
|
||||
|
||||
# Create void transaction
|
||||
transaction = LoyaltyTransaction(
|
||||
company_id=card.company_id,
|
||||
card_id=card.id,
|
||||
vendor_id=vendor_id,
|
||||
staff_pin_id=verified_pin.id if verified_pin else None,
|
||||
transaction_type=TransactionType.STAMP_VOIDED.value,
|
||||
stamps_delta=-actual_voided,
|
||||
stamps_balance_after=card.stamp_count,
|
||||
points_balance_after=card.points_balance,
|
||||
related_transaction_id=original_transaction.id if original_transaction else None,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
notes=notes or "Stamps voided for return",
|
||||
transaction_at=now,
|
||||
)
|
||||
db.add(transaction)
|
||||
|
||||
db.commit()
|
||||
db.refresh(card)
|
||||
|
||||
logger.info(
|
||||
f"Voided {actual_voided} stamps from card {card.id} at vendor {vendor_id} "
|
||||
f"(balance: {card.stamp_count})"
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Stamps voided successfully",
|
||||
"stamps_voided": actual_voided,
|
||||
"card_id": card.id,
|
||||
"card_number": card.card_number,
|
||||
"stamp_count": card.stamp_count,
|
||||
"vendor_id": vendor_id,
|
||||
}
|
||||
|
||||
|
||||
|
||||
115
app/modules/loyalty/static/admin/js/loyalty-analytics.js
Normal file
115
app/modules/loyalty/static/admin/js/loyalty-analytics.js
Normal 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');
|
||||
208
app/modules/loyalty/static/admin/js/loyalty-company-detail.js
Normal file
208
app/modules/loyalty/static/admin/js/loyalty-company-detail.js
Normal 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');
|
||||
173
app/modules/loyalty/static/admin/js/loyalty-company-settings.js
Normal file
173
app/modules/loyalty/static/admin/js/loyalty-company-settings.js
Normal 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');
|
||||
264
app/modules/loyalty/static/admin/js/loyalty-programs.js
Normal file
264
app/modules/loyalty/static/admin/js/loyalty-programs.js
Normal 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');
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
94
app/modules/loyalty/static/storefront/js/loyalty-enroll.js
Normal file
94
app/modules/loyalty/static/storefront/js/loyalty-enroll.js
Normal 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;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
119
app/modules/loyalty/static/storefront/js/loyalty-history.js
Normal file
119
app/modules/loyalty/static/storefront/js/loyalty-history.js
Normal 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;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
104
app/modules/loyalty/static/vendor/js/loyalty-card-detail.js
vendored
Normal file
104
app/modules/loyalty/static/vendor/js/loyalty-card-detail.js
vendored
Normal 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');
|
||||
160
app/modules/loyalty/static/vendor/js/loyalty-cards.js
vendored
Normal file
160
app/modules/loyalty/static/vendor/js/loyalty-cards.js
vendored
Normal 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');
|
||||
101
app/modules/loyalty/static/vendor/js/loyalty-enroll.js
vendored
Normal file
101
app/modules/loyalty/static/vendor/js/loyalty-enroll.js
vendored
Normal 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');
|
||||
118
app/modules/loyalty/static/vendor/js/loyalty-settings.js
vendored
Normal file
118
app/modules/loyalty/static/vendor/js/loyalty-settings.js
vendored
Normal 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');
|
||||
74
app/modules/loyalty/static/vendor/js/loyalty-stats.js
vendored
Normal file
74
app/modules/loyalty/static/vendor/js/loyalty-stats.js
vendored
Normal 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');
|
||||
286
app/modules/loyalty/static/vendor/js/loyalty-terminal.js
vendored
Normal file
286
app/modules/loyalty/static/vendor/js/loyalty-terminal.js
vendored
Normal 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');
|
||||
@@ -3,8 +3,17 @@
|
||||
Loyalty module Celery tasks.
|
||||
|
||||
Background tasks for:
|
||||
- Point expiration
|
||||
- Wallet synchronization
|
||||
- Point expiration (daily at 02:00)
|
||||
- Wallet synchronization (hourly)
|
||||
|
||||
Task registration is handled by the module definition in definition.py
|
||||
which specifies the task paths and schedules.
|
||||
"""
|
||||
|
||||
__all__: list[str] = []
|
||||
from app.modules.loyalty.tasks.point_expiration import expire_points
|
||||
from app.modules.loyalty.tasks.wallet_sync import sync_wallet_passes
|
||||
|
||||
__all__ = [
|
||||
"expire_points",
|
||||
"sync_wallet_passes",
|
||||
]
|
||||
|
||||
@@ -3,12 +3,20 @@
|
||||
Point expiration task.
|
||||
|
||||
Handles expiring points that are older than the configured
|
||||
expiration period (future enhancement).
|
||||
expiration period based on card inactivity.
|
||||
|
||||
Runs daily at 02:00 via the scheduled task configuration in definition.py.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from celery import shared_task
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import SessionLocal
|
||||
from app.modules.loyalty.models import LoyaltyCard, LoyaltyProgram, LoyaltyTransaction
|
||||
from app.modules.loyalty.models.loyalty_transaction import TransactionType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -16,26 +24,175 @@ logger = logging.getLogger(__name__)
|
||||
@shared_task(name="loyalty.expire_points")
|
||||
def expire_points() -> dict:
|
||||
"""
|
||||
Expire points that are past their expiration date.
|
||||
Expire points that are past their expiration date based on card inactivity.
|
||||
|
||||
This is a placeholder for future functionality where points
|
||||
can be configured to expire after a certain period.
|
||||
For each program with points_expiration_days configured:
|
||||
1. Find cards that haven't had activity in the expiration period
|
||||
2. Expire all points on those cards
|
||||
3. Create POINTS_EXPIRED transaction records
|
||||
4. Update card balances
|
||||
|
||||
Returns:
|
||||
Summary of expired points
|
||||
"""
|
||||
# Future implementation:
|
||||
# 1. Find programs with point expiration enabled
|
||||
# 2. Find cards with points earned before expiration threshold
|
||||
# 3. Calculate points to expire
|
||||
# 4. Create adjustment transactions
|
||||
# 5. Update card balances
|
||||
# 6. Notify customers (optional)
|
||||
logger.info("Starting point expiration task...")
|
||||
|
||||
logger.info("Point expiration task running (no-op for now)")
|
||||
db: Session = SessionLocal()
|
||||
try:
|
||||
result = _process_point_expiration(db)
|
||||
db.commit()
|
||||
logger.info(
|
||||
f"Point expiration complete: {result['cards_processed']} cards, "
|
||||
f"{result['points_expired']} points expired"
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Point expiration task failed: {e}", exc_info=True)
|
||||
return {
|
||||
"status": "error",
|
||||
"error": str(e),
|
||||
"cards_processed": 0,
|
||||
"points_expired": 0,
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _process_point_expiration(db: Session) -> dict:
|
||||
"""
|
||||
Process point expiration for all programs.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
Summary of expired points
|
||||
"""
|
||||
total_cards_processed = 0
|
||||
total_points_expired = 0
|
||||
programs_processed = 0
|
||||
|
||||
# Find all active programs with point expiration configured
|
||||
programs = (
|
||||
db.query(LoyaltyProgram)
|
||||
.filter(
|
||||
LoyaltyProgram.is_active == True,
|
||||
LoyaltyProgram.points_expiration_days.isnot(None),
|
||||
LoyaltyProgram.points_expiration_days > 0,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
logger.info(f"Found {len(programs)} programs with point expiration configured")
|
||||
|
||||
for program in programs:
|
||||
cards_count, points_count = _expire_points_for_program(db, program)
|
||||
total_cards_processed += cards_count
|
||||
total_points_expired += points_count
|
||||
programs_processed += 1
|
||||
|
||||
logger.debug(
|
||||
f"Program {program.id} (company {program.company_id}): "
|
||||
f"{cards_count} cards, {points_count} points expired"
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"cards_processed": 0,
|
||||
"points_expired": 0,
|
||||
"programs_processed": programs_processed,
|
||||
"cards_processed": total_cards_processed,
|
||||
"points_expired": total_points_expired,
|
||||
}
|
||||
|
||||
|
||||
def _expire_points_for_program(db: Session, program: LoyaltyProgram) -> tuple[int, int]:
|
||||
"""
|
||||
Expire points for a specific loyalty program.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
program: Loyalty program to process
|
||||
|
||||
Returns:
|
||||
Tuple of (cards_processed, points_expired)
|
||||
"""
|
||||
if not program.points_expiration_days:
|
||||
return 0, 0
|
||||
|
||||
# Calculate expiration threshold
|
||||
expiration_threshold = datetime.now(UTC) - timedelta(days=program.points_expiration_days)
|
||||
|
||||
logger.debug(
|
||||
f"Processing program {program.id}: expiration after {program.points_expiration_days} days "
|
||||
f"(threshold: {expiration_threshold})"
|
||||
)
|
||||
|
||||
# Find cards with:
|
||||
# - Points balance > 0
|
||||
# - Last activity before expiration threshold
|
||||
# - Belonging to this program's company
|
||||
cards_to_expire = (
|
||||
db.query(LoyaltyCard)
|
||||
.filter(
|
||||
LoyaltyCard.company_id == program.company_id,
|
||||
LoyaltyCard.points_balance > 0,
|
||||
LoyaltyCard.last_activity_at < expiration_threshold,
|
||||
LoyaltyCard.is_active == True,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
if not cards_to_expire:
|
||||
logger.debug(f"No cards to expire for program {program.id}")
|
||||
return 0, 0
|
||||
|
||||
logger.info(f"Found {len(cards_to_expire)} cards to expire for program {program.id}")
|
||||
|
||||
cards_processed = 0
|
||||
points_expired = 0
|
||||
|
||||
for card in cards_to_expire:
|
||||
if card.points_balance <= 0:
|
||||
continue
|
||||
|
||||
expired_points = card.points_balance
|
||||
|
||||
# Create expiration transaction
|
||||
transaction = LoyaltyTransaction(
|
||||
card_id=card.id,
|
||||
company_id=program.company_id,
|
||||
vendor_id=None, # System action, no vendor
|
||||
transaction_type=TransactionType.POINTS_EXPIRED.value,
|
||||
points_delta=-expired_points,
|
||||
balance_after=0,
|
||||
stamps_delta=0,
|
||||
stamps_balance_after=card.stamps_balance,
|
||||
notes=f"Points expired after {program.points_expiration_days} days of inactivity",
|
||||
transaction_at=datetime.now(UTC),
|
||||
)
|
||||
db.add(transaction)
|
||||
|
||||
# Update card balance
|
||||
card.points_balance = 0
|
||||
card.total_points_voided = (card.total_points_voided or 0) + expired_points
|
||||
# Note: We don't update last_activity_at for expiration
|
||||
|
||||
cards_processed += 1
|
||||
points_expired += expired_points
|
||||
|
||||
logger.debug(
|
||||
f"Expired {expired_points} points from card {card.id} "
|
||||
f"(last activity: {card.last_activity_at})"
|
||||
)
|
||||
|
||||
return cards_processed, points_expired
|
||||
|
||||
|
||||
# Allow running directly for testing
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
result = expire_points()
|
||||
print(f"Result: {result}")
|
||||
sys.exit(0 if result["status"] == "success" else 1)
|
||||
|
||||
162
app/modules/loyalty/templates/loyalty/admin/analytics.html
Normal file
162
app/modules/loyalty/templates/loyalty/admin/analytics.html
Normal 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 %}
|
||||
238
app/modules/loyalty/templates/loyalty/admin/company-detail.html
Normal file
238
app/modules/loyalty/templates/loyalty/admin/company-detail.html
Normal 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 %}
|
||||
@@ -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 %}
|
||||
241
app/modules/loyalty/templates/loyalty/admin/programs.html
Normal file
241
app/modules/loyalty/templates/loyalty/admin/programs.html
Normal 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 %}
|
||||
226
app/modules/loyalty/templates/loyalty/storefront/dashboard.html
Normal file
226
app/modules/loyalty/templates/loyalty/storefront/dashboard.html
Normal 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 %}
|
||||
@@ -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 %}
|
||||
135
app/modules/loyalty/templates/loyalty/storefront/enroll.html
Normal file
135
app/modules/loyalty/templates/loyalty/storefront/enroll.html
Normal 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 %}
|
||||
107
app/modules/loyalty/templates/loyalty/storefront/history.html
Normal file
107
app/modules/loyalty/templates/loyalty/storefront/history.html
Normal 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 %}
|
||||
158
app/modules/loyalty/templates/loyalty/vendor/card-detail.html
vendored
Normal file
158
app/modules/loyalty/templates/loyalty/vendor/card-detail.html
vendored
Normal 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 %}
|
||||
150
app/modules/loyalty/templates/loyalty/vendor/cards.html
vendored
Normal file
150
app/modules/loyalty/templates/loyalty/vendor/cards.html
vendored
Normal 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 %}
|
||||
146
app/modules/loyalty/templates/loyalty/vendor/enroll.html
vendored
Normal file
146
app/modules/loyalty/templates/loyalty/vendor/enroll.html
vendored
Normal 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 %}
|
||||
158
app/modules/loyalty/templates/loyalty/vendor/settings.html
vendored
Normal file
158
app/modules/loyalty/templates/loyalty/vendor/settings.html
vendored
Normal 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 %}
|
||||
134
app/modules/loyalty/templates/loyalty/vendor/stats.html
vendored
Normal file
134
app/modules/loyalty/templates/loyalty/vendor/stats.html
vendored
Normal 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 %}
|
||||
309
app/modules/loyalty/templates/loyalty/vendor/terminal.html
vendored
Normal file
309
app/modules/loyalty/templates/loyalty/vendor/terminal.html
vendored
Normal 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 %}
|
||||
Reference in New Issue
Block a user