Skip to main content
The cart is managed by a Zustand store with persist middleware. Checkout routes the order through either a formatted WhatsApp message or Mercado Pago payment preference. Loyalty points are automatically calculated and awarded on every completed order.

Cart State

The full CartState interface from src/stores/cart.store.ts:
interface CartState {
    // State
    items: CartItem[];
    isOpen: boolean;
    bundleOffer: SmartBundleOffer | null;

    // Actions
    addItem(product: Product, quantity?: number, variant?: { id: string; name: string } | null): void;
    removeItem(productId: string, variantId?: string | null): void;
    updateQuantity(productId: string, quantity: number, variantId?: string | null): void;
    clearCart(): void;
    toggleCart(): void;
    openCart(): void;
    closeCart(): void;
    loadOrderItems(items: CartItem[]): void;
    validateCart(): Promise<CartValidationResult>;

    // Smart bundles
    setBundleOffer(offer: SmartBundleOffer | null): void;
    applyBundle(product: Product, couponCode: string): void;
}
Memoized selectors (use these in components to prevent unnecessary re-renders):
import { useCartStore, selectTotalItems, selectSubtotal, selectTotal } from '@/stores/cart.store';

const totalItems = useCartStore(selectTotalItems); // Sum of all quantities
const subtotal   = useCartStore(selectSubtotal);   // Sum of price * quantity
const total      = useCartStore(selectTotal);      // Currently equals subtotal

Persistence

localStorage key

vsm-cart — stored as JSON by Zustand persist middleware.

Persisted fields

Only items is persisted. isOpen, bundleOffer are always reset to defaults on page load.
The store uses schema versioning (version: 2). If the persisted data is from version < 2, the cart is cleared during hydration to avoid stale Product objects with missing fields:
migrate: (persisted, version) => {
    if (version < 2) {
        return { items: [] };
    }
    return persisted as { items: CartItem[] };
}
The store also listens for storage events to synchronize the cart across multiple open browser tabs.

Cart Actions

Rejects silently if !product.is_active or product.status === 'discontinued'.If the product+variant combination already exists in the cart, increments quantity (clamped to product.stock). Otherwise appends a new CartItem.Fires trackAddToCart(product, quantity) (GA4 add_to_cart event) asynchronously via dynamic import.
Removes the item matching both productId and variantId from items[]. Variant-aware — removing a base product does not remove its variants.
Clamps the new quantity to Math.min(quantity, item.product.stock). If quantity <= 0, delegates to removeItem().
Replaces the entire cart with items from a previous order. Used by the re-order feature on the Order History page (/orders). Preserves variant_id and variant_name from historical order items.
Fetches current product data via getProductsByIds() and checks each cart item for:
  • Removed / deactivated products → removes from cart, records type: 'removed'
  • Out of stock → removes from cart, records type: 'out_of_stock'
  • Price changes → updates product reference, records type: 'price_changed'
  • Stock adjustments → clamps quantity, records type: 'stock_adjusted'
Returns CartValidationResult { issues: CartValidationIssue[], hasIssues: boolean }.

Cart Item and Types

export interface CartItem {
    product: Product;
    quantity: number;
    variant_id?: string | null;
    variant_name?: string | null;
}

export type DeliveryType = 'pickup' | 'delivery';
export type PaymentMethod = 'whatsapp' | 'mercadopago' | 'cash' | 'transfer' | 'card';
export type PaymentStatus = 'pending' | 'paid' | 'failed' | 'refunded';

Checkout Flow

1

CartSidebar

Rendered as a slide-in panel (src/components/cart/CartSidebar.tsx). Displays items, subtotal, and a CTA to proceed to checkout. Accessible via openCart() / toggleCart().
2

Checkout page

/checkout renders <CheckoutForm /> (src/components/cart/CheckoutForm.tsx). Collects customerName, customerPhone, deliveryType, address, and paymentMethod.
3

Coupon validation

validateCoupon(code) from coupons.service.ts checks the coupon against the coupons table (min purchase, max uses, active status). A valid coupon returns discount amount.
4

Payment routing

Based on paymentMethod, the checkout branches into WhatsApp or Mercado Pago.
5

Order creation

createOrder(data) from orders.service.ts inserts the order and automatically awards loyalty points.

Payment Methods

When paymentMethod === 'whatsapp', SITE_CONFIG.orderWhatsApp.generateMessage(order) builds a formatted message and opens https://wa.me/{number}?text={encodedMessage}.
// From src/config/site.ts
SITE_CONFIG.orderWhatsApp.generateMessage(order: Order): string
// Returns a WhatsApp-formatted string:
// 🛒 *NUEVO PEDIDO — VSM Store*
// 📋 Orden #${order.id}
// 👤 ${order.customerName}
// 📱 ${order.customerPhone}
// *PRODUCTOS:*
// • Product Name (variant) x2 — $299.00
// 💰 *TOTAL: $598.00 MXN*
// 📦 🚚 Envío a domicilio / 🏪 Recoger en tienda
// 💳 payment method
After the WhatsApp window opens, markWhatsAppSent(orderId) is called to record whatsapp_sent: true and whatsapp_sent_at on the order.

Coupon Validation

// coupons.service.ts
validateCoupon(code: string): Promise<Coupon | null>
The coupons table stores:
  • code — unique coupon string
  • discount_type: 'fixed' | 'percent'
  • discount_value — amount or percentage
  • min_purchase — minimum cart subtotal required
  • max_uses — maximum redemption count

Loyalty Points on Checkout

Loyalty points are awarded automatically inside createOrder() without any customer action required:
// From orders.service.ts createOrder():
const points = data.earned_points ?? calculateLoyaltyPoints(data.total);
if (points > 0) {
    await addLoyaltyPoints(
        data.customer_id,
        points,
        order.id,
        `Compra #${order.order_number}`
    );
}
The conversion rate is defined in src/lib/domain/loyalty.ts:
$100 MXN = 10 loyalty points
If addLoyaltyPoints fails, the error is logged but does not throw — the order succeeds regardless.

GA4 Tracking

// Fired in cart.store.ts addItem():
import('@/lib/analytics').then(({ trackAddToCart }) => {
    trackAddToCart(product, quantity);
});
Additional checkout events from src/lib/analytics.ts:
  • view_item — fires on product detail page load
  • begin_checkout — fires when checkout form opens
  • purchase — fires on successful order creation

Build docs developers (and LLMs) love