Skip to main content

Overview

PC Fix features a sophisticated shopping cart system that provides a seamless user experience with client-side state management using Zustand, server-side persistence, and automated abandoned cart recovery to maximize conversion rates.

Client-Side Cart Management

Zustand State Store

The shopping cart uses Zustand for reactive state management with session storage persistence:
packages/web/src/stores/cartStore.ts
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import type { Product } from '../data/mock-data';

export interface CartItem extends Product {
  quantity: number;
}

interface CartState {
  items: CartItem[];
  addItem: (product: Product) => void;
  removeItem: (productId: string) => void;
  increaseQuantity: (productId: string) => void;
  decreaseQuantity: (productId: string) => void;
  clearCart: () => void;
}

export const useCartStore = create<CartState>()(persist(
  (set) => ({
    items: [],
    addItem: (product) => set((state) => {
      const existingItem = state.items.find((item) => item.id === product.id);
      if (existingItem) {
        const updatedItems = state.items.map((item) =>
          item.id === product.id ? { ...item, quantity: item.quantity + 1 } : item
        );
        return { items: updatedItems };
      } else {
        return { items: [...state.items, { ...product, quantity: 1 }] };
      }
    }),
    removeItem: (productId) => set((state) => ({
      items: state.items.filter((item) => item.id !== productId),
    })),
    increaseQuantity: (productId) => set((state) => ({
      items: state.items.map((item) =>
        item.id === productId ? { ...item, quantity: Math.min(item.stock, item.quantity + 1) } : item
      ),
    })),
    decreaseQuantity: (productId) => set((state) => ({
      items: state.items.map((item) =>
        item.id === productId ? { ...item, quantity: Math.max(1, item.quantity - 1) } : item
      ),
    })),
    clearCart: () => set({ items: [] }),
  }),
  {
    name: 'cart-session-storage',
    storage: createJSONStorage(() => sessionStorage),
  }
));

Cart Operations

Add to Cart

Automatically increments quantity if item already exists in cart

Stock Validation

Prevents adding more items than available in stock

Quantity Controls

Increase/decrease quantity with minimum of 1 and maximum of stock level

Remove Items

Instantly remove items from cart with one click

Server-Side Cart Persistence

Database Schema

Carts are persisted in the database with support for abandoned cart tracking:
packages/api/prisma/schema.prisma
model Cart {
  id                 Int        @id @default(autoincrement())
  userId             Int        @unique
  user               User       @relation(fields: [userId], references: [id], onDelete: Cascade)
  items              CartItem[]
  abandonedEmailSent Boolean    @default(false)
  updatedAt          DateTime   @updatedAt
  createdAt          DateTime   @default(now())
}

model CartItem {
  id        Int      @id @default(autoincrement())
  cartId    Int
  cart      Cart     @relation(fields: [cartId], references: [id], onDelete: Cascade)
  productoId Int
  producto   Producto @relation(fields: [productoId], references: [id])
  quantity  Int
  
  @@index([cartId])
  @@index([productoId])
}

Cart Synchronization

When users log in or update their cart, the client syncs with the server to ensure data consistency:
packages/api/src/modules/cart/cart.service.ts
export class CartService {
  async syncCart(userId: number, items: SyncCartItemDto[]) {
    // Upsert cart (create if doesn't exist)
    const cart = await (prisma as any).cart.upsert({
      where: { userId },
      create: { userId },
      update: { abandonedEmailSent: false },
    });

    // Validate products exist and aren't deleted
    let validItems: SyncCartItemDto[] = [];
    if (items.length > 0) {
      const productIds = items.map(i => Number(i.id)).filter(id => !isNaN(id));

      if (productIds.length > 0) {
        const existingProducts = await prisma.producto.findMany({
          where: { id: { in: productIds }, deletedAt: null },
          select: { id: true }
        });

        const existingIds = new Set(existingProducts.map(p => p.id));
        validItems = items.filter(i => existingIds.has(Number(i.id)));
      }
    }

    // Replace all cart items atomically
    return await (prisma as any).$transaction(async (tx: any) => {
      await tx.cartItem.deleteMany({
        where: { cartId: cart.id }
      });

      if (validItems.length > 0) {
        await tx.cartItem.createMany({
          data: validItems.map(item => ({
            cartId: cart.id,
            productoId: Number(item.id),
            quantity: item.quantity
          }))
        });
      }

      return tx.cart.findUnique({
        where: { id: cart.id },
        include: { items: { include: { producto: true } } }
      });
    });
  }

  async getCart(userId: number) {
    return await (prisma as any).cart.findUnique({
      where: { userId },
      include: { items: { include: { producto: true } } }
    });
  }
}
Cart synchronization uses database transactions to ensure data consistency. All cart items are replaced atomically during sync.

Abandoned Cart Recovery

Automated Email Notifications

PC Fix automatically detects abandoned carts and sends recovery emails to encourage customers to complete their purchase:
packages/api/src/shared/services/cron.service.ts
private async checkAbandonedCarts() {
  try {
    // Find carts abandoned between 30 minutes and 24 hours ago
    const thirtyMinutesAgo = new Date(Date.now() - 30 * 60 * 1000);
    const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);

    const carts = await (prisma as any).cart.findMany({
      where: {
        updatedAt: {
          lt: thirtyMinutesAgo,
          gt: twentyFourHoursAgo
        },
        abandonedEmailSent: false,
        items: { some: {} }, // Has at least one item
        userId: { not: undefined } 
      },
      include: {
        user: true,
        items: { include: { producto: true } }
      }
    });

    for (const cart of carts) {
      if (!cart.user?.email) continue;

      const products = cart.items.map((i: any) => i.producto);
      const sent = await emailService.sendAbandonedCartEmail(
        cart.user.email, 
        cart.user.nombre, 
        products
      );

      if (sent) {
        await (prisma as any).cart.update({
          where: { id: cart.id },
          data: { abandonedEmailSent: true }
        });
      }
    }
  } catch (error) {
    console.error('Error en Cron de Carritos Abandonados:', error);
  }
}

Recovery Timeline

1

User adds items to cart

Items are saved both in sessionStorage and synced to the database if user is logged in
2

User leaves without purchasing

Cart remains active in the database with timestamp tracking
3

30 minutes pass

System identifies cart as potentially abandoned
4

Email sent

If cart hasn’t been modified in 30 minutes (but less than 24 hours), recovery email is sent
5

Flag updated

Cart is marked with abandonedEmailSent: true to prevent duplicate emails

Cron Schedule

The abandoned cart check runs every 30 minutes:
packages/api/src/shared/services/cron.service.ts
start() {
  // Check for abandoned carts every 30 minutes
  cron.schedule('*/30 * * * *', async () => {
    await this.checkAbandonedCarts();
  });
}
Abandoned cart emails are only sent once per cart to avoid spam. The abandonedEmailSent flag is reset when the cart is updated.

Cart Validation

Product Validation

The system validates that:
  • Products exist and haven’t been deleted
  • Products have sufficient stock
  • Prices are current from the database

Stock Management

Quantity increases are capped at available stock:
increaseQuantity: (productId) => set((state) => ({
  items: state.items.map((item) =>
    item.id === productId 
      ? { ...item, quantity: Math.min(item.stock, item.quantity + 1) } 
      : item
  ),
}))

Cart Persistence Strategy

  • Cart stored only in browser sessionStorage
  • Lost when browser session ends
  • Synced to database upon login

Cart-to-Order Flow

When users proceed to checkout:
  1. Cart items are retrieved from the store
  2. Stock is validated against current inventory
  3. Prices are recalculated from the database
  4. Order is created with a transaction
  5. Cart is cleared after successful order
  6. Stock is decremented atomically
packages/api/src/modules/sales/sales.service.ts
return await prisma.$transaction(async (tx: any) => {
  const venta = await tx.venta.create({
    data: {
      cliente: { connect: { id: cliente!.id } },
      montoTotal: subtotalReal + costoEnvio,
      costoEnvio, tipoEntrega, medioPago,
      estado: VentaEstado.PENDIENTE_PAGO,
      lineasVenta: { create: lineasParaCrear },
      // ... shipping address
    },
    include: { lineasVenta: true }
  });

  // Decrement stock for each product
  for (const linea of lineasParaCrear) {
    const prod = dbProducts.find((p: any) => p.id === linea.productoId);
    if (prod && prod.stock < 90000) {
      await tx.producto.update({ 
        where: { id: linea.productoId }, 
        data: { stock: { decrement: linea.cantidad } } 
      });
    }
  }
  return venta;
});

Performance Optimizations

Session Storage

Cart state stored in sessionStorage for instant access without API calls

Batch Sync

Cart synced to server in single operation, not per-item

Database Indexes

Indexes on cartId and productoId for fast lookups

Transaction Safety

Cart operations use database transactions for consistency

API Endpoints

EndpointMethodDescription
/api/cart/syncPOSTSync cart items with server
/api/cartGETGet current cart for logged-in user
The cart automatically syncs when users log in, ensuring their cart follows them across devices.

Build docs developers (and LLMs) love