Skip to main content
VSM Store’s loyalty program (internally called V-Coins) awards points on every purchase, tracks a tier progression from Bronze to Platinum, and supports point redemption for discounts at checkout. The program is fully configurable from the admin panel via store_settings.

Routes

RouteComponentAuth
/loyaltyLoyaltyYes
/statsStatsYes

Tier System

Four tiers are defined in src/lib/domain/loyalty.ts as LOYALTY_TIERS:
TierIDMinimum SpentDiscountFree ShippingFree Shipping Min
Bronzebronze$0 MXN0%No
Silversilver$5,000 MXN5%YesOrders ≥ $1,000
Goldgold$20,000 MXN10%Yes (always)
Platinumplatinum$50,000 MXN15%Express, always
// Re-exported from loyalty.service.ts
type Tier = 'bronze' | 'silver' | 'gold' | 'platinum';

// customer_profiles.tier stores the current tier
tier: 'bronze' | 'silver' | 'gold' | 'platinum';
Tier thresholds and multipliers can be overridden from the admin panel via dynamic tier configuration stored in store_settings.loyalty_config. The getTierInfo(tier, dynamicTiers?) function in loyalty.service.ts merges dynamic config with defaults.

Points Earning

The conversion rate is defined in src/lib/domain/loyalty.ts:
$100 MXN spent = 10 loyalty points
import { calculateLoyaltyPoints } from '@/lib/domain/loyalty';

calculateLoyaltyPoints(500);  // → 50 points
calculateLoyaltyPoints(1200); // → 120 points
Points are awarded inside createOrder() in orders.service.ts automatically after the order row is inserted:
const points = data.earned_points ?? calculateLoyaltyPoints(data.total);
if (points > 0) {
    await addLoyaltyPoints(
        data.customer_id,
        points,
        order.id,
        `Compra #${order.order_number}`
    );
}
The earned_points override in CreateOrderData lets the checkout flow pass a pre-calculated value (e.g., after applying bonus multipliers).

Adding Points — addLoyaltyPoints()

// loyalty.service.ts
export async function addLoyaltyPoints(
    customerId: string,
    points: number,
    orderId: string,
    description: string
): Promise<void>
Internally calls the process_loyalty_points Supabase RPC with p_type: 'earned'. This RPC handles the atomic insert to loyalty_points and updates the customer’s cached balance.

Points Balance — getPointsBalance()

// loyalty.service.ts
export async function getPointsBalance(customerId: string): Promise<number>
Calls the get_customer_points_balance Supabase RPC function, which computes the net balance from all earned and spent transactions:
const { data } = await supabase
    .rpc('get_customer_points_balance', { p_customer_id: customerId });
return data ?? 0;
import { usePointsBalance } from '@/hooks/useOrders';

const { data: balance } = usePointsBalance(customerId);
// Query key: ['points', customerId]
// staleTime: 5 minutes

Points Redemption — redeemPoints()

// loyalty.service.ts
export async function redeemPoints(
    customerId: string,
    points: number,
    orderId?: string
): Promise<{ discount: number }>
Redemption rules (sourced from store_settings.loyalty_config, with these defaults):
{
    points_per_currency: 0.1,       // Points earned per MXN
    currency_per_point: 0.1,        // MXN discount per point redeemed
    min_points_to_redeem: 100,      // Minimum points required
    max_points_per_order: 1000,     // Cap per order redemption
    points_expiry_days: 365,        // Points expire after 1 year
    enable_loyalty: true
}
The function validates the minimum threshold, caps at max_points_per_order, then calls process_loyalty_points RPC with p_type: 'spent' and a negative point amount.
If store_settings.loyalty_config.enable_loyalty is false, redeemPoints() throws 'El programa de lealtad está desactivado'. Always check this before displaying the redemption UI.

loyalty_points Table

Every point transaction is recorded as an immutable row:
loyalty_points (
    id              uuid PRIMARY KEY,
    customer_id     uuid REFERENCES customer_profiles(id),
    points          integer,          -- positive = earned, negative = spent
    transaction_type 'earned' | 'spent' | 'expired' | 'adjustment',
    order_id        uuid REFERENCES orders(id) NULL,
    description     text,             -- e.g. "Compra #VSM-00042"
    created_at      timestamptz
)
The PointsTransaction TypeScript interface:
export interface PointsTransaction {
    id: string;
    points: number;
    transaction_type: 'earned' | 'spent' | 'expired' | 'adjustment';
    description: string;
    order_id: string | null;
    created_at: string;
}

Hooks

import { useLoyalty } from '@/hooks/useLoyalty';
// Query key: ['loyalty']
// Returns loyalty history and tier info

import { usePointsBalance } from '@/hooks/useOrders';
// Query key: ['points', customerId]
// Returns current point balance as a number
getPointsHistory(customerId) returns the last 50 transactions ordered by created_at DESC:
export async function getPointsHistory(
    customerId: string
): Promise<PointsTransaction[]>

Tier Progress

import { getProgressToNextTier } from '@/services/loyalty.service';

const progress = getProgressToNextTier(customer.total_spent);
// Returns:
// {
//     currentTier: 'silver',
//     nextTier: 'gold',
//     progress: 62,        // percentage toward next tier
//     remaining: 1900      // MXN remaining
// }
The LoyaltyDashboard component (src/components/loyalty/LoyaltyDashboard.tsx) renders this as a progress bar with the current and next tier badges.

AI-Powered Smart Rewards

The loyalty-intelligence Supabase Edge Function analyzes customer behavior and generates personalized discount propositions stored in smart_loyalty_propositions:
// Request a new AI reward
export async function generateSmartReward(
    customerId: string
): Promise<SmartLoyaltyProposition | null>

// Fetch active (unclaimed, non-expired) proposition
export async function getActiveIAProposition(
    customerId: string
): Promise<SmartLoyaltyProposition | null>

// Mark proposition as claimed
export async function claimIAProposition(
    propositionId: string
): Promise<void>
export interface SmartLoyaltyProposition {
    id: string;
    customer_id: string;
    coupon_code: string;            // Applies to coupons table
    generated_code: string;         // Unique code for this proposition
    personalized_message: string;   // Gemini-generated copy
    discount_value: number;
    discount_type: 'percentage' | 'fixed';
    expires_at: string;             // ISO timestamp
    is_claimed: boolean;
}
Smart rewards are generated on-demand by calling generateSmartReward() — typically triggered when the customer opens the Loyalty Dashboard. Show the proposition card only if getActiveIAProposition() returns a non-null value and expires_at is in the future.

Admin Loyalty Stats

The admin panel accesses aggregate loyalty data via getAdminLoyaltyStats(), which calls the get_admin_loyalty_stats Supabase RPC:
interface LoyaltyStatsData {
    puntos_hoy: number;
    ultimo_canje: {
        created_at?: string;
        full_name?: string;
        points?: number;
    } | null;
    top_usuarios: Array<{
        id: string;
        full_name: string;
        balance: number;
    }>;
}

Build docs developers (and LLMs) love