Putiikkipalvelu Docs
SDK

Cart

Manage shopping cart using the Storefront SDK

Overview

The cart resource provides methods for managing the shopping cart stored in Redis. It supports both guest carts (via cartId) and authenticated user carts (via sessionId).

Key Features:

  • Redis-backed with 10-day TTL
  • Supports guest and authenticated users
  • Atomic quantity updates (race-condition safe)
  • Pre-checkout validation with auto-fix

Session Management

Cart operations require session identification:

User TypeIdentificationStorage
GuestcartId (UUID)Cookie or localStorage
Logged-insessionIdAuth session cookie

Guest Cart Flow:

  1. First addItem() call returns a new cartId
  2. Store cartId in cookie/localStorage
  3. Pass cartId in subsequent cart operations

Logged-in User Flow:

  1. Pass sessionId from auth session
  2. Cart is automatically linked to customer account
  3. No need to manage cartId

Methods

cart.get(options?, fetchOptions?)

Fetch the current cart contents.

// Guest user
const cartId = getCookie('cart-id');
const { items, cartId: newCartId } = await storefront.cart.get({ cartId });

// Logged-in user
const { items } = await storefront.cart.get({ sessionId });

// New visitor (empty cart)
const { items } = await storefront.cart.get();

Parameters:

ParamTypeRequiredDescription
options.cartIdstringNoCart ID for guest users
options.sessionIdstringNoSession ID for logged-in users

Returns: Promise<CartResponse>


cart.addItem(params, fetchOptions?)

Add a product to the cart. If the item already exists, quantity is incremented.

// Add product
const { items, cartId } = await storefront.cart.addItem({
  cartId: existingCartId,  // omit for new cart
  productId: 'prod_123',
  quantity: 2
});

// Save cartId for future requests
setCookie('cart-id', cartId);
// Add product with variation
const { items, cartId } = await storefront.cart.addItem({
  cartId,
  productId: 'prod_123',
  variationId: 'var_456',
  quantity: 1
});

Parameters:

ParamTypeRequiredDescription
cartIdstringNoCart ID for guest users
sessionIdstringNoSession ID for logged-in users
productIdstringYesProduct ID to add
variationIdstringNoVariation ID (required if product has variations)
quantitynumberYesQuantity to add (min: 1)

Returns: Promise<CartResponse>

Throws:

  • ValidationError if quantity exceeds available stock
  • NotFoundError if product/variation not found
  • ValidationError if cart limit exceeded (max 50 items)

cart.updateQuantity(params, fetchOptions?)

Update item quantity using a delta (atomic operation).

// Increment quantity by 1
const { items } = await storefront.cart.updateQuantity({
  cartId,
  productId: 'prod_123',
  delta: 1
});

// Decrement quantity by 1
const { items } = await storefront.cart.updateQuantity({
  cartId,
  productId: 'prod_123',
  variationId: 'var_456',
  delta: -1
});

Parameters:

ParamTypeRequiredDescription
cartIdstringNoCart ID for guest users
sessionIdstringNoSession ID for logged-in users
productIdstringYesProduct ID to update
variationIdstringNoVariation ID (if applicable)
deltanumberYesQuantity change (-100 to +100)

Returns: Promise<CartResponse>

Throws: NotFoundError if item not in cart or quantity would go below 1

Note: Minimum quantity is 1. Use removeItem() to delete items completely.


cart.removeItem(params, fetchOptions?)

Remove an item completely from the cart.

// Remove product
const { items } = await storefront.cart.removeItem({
  cartId,
  productId: 'prod_123'
});

// Remove variation
const { items } = await storefront.cart.removeItem({
  cartId,
  productId: 'prod_123',
  variationId: 'var_456'
});

Parameters:

ParamTypeRequiredDescription
cartIdstringNoCart ID for guest users
sessionIdstringNoSession ID for logged-in users
productIdstringYesProduct ID to remove
variationIdstringNoVariation ID (if applicable)

Returns: Promise<CartResponse>


cart.validate(options?, cartItems?, campaigns?, fetchOptions?)

Validate cart before checkout. Checks product availability, stock levels, prices, and discount codes. Auto-fixes issues by removing unavailable items, adjusting quantities, and removing invalid discount codes.

import { getDiscountRemovalMessage } from '@putiikkipalvelu/storefront-sdk';

const { items, hasChanges, changes } = await storefront.cart.validate(
  { cartId },
  cartItems,      // Pass for campaign conflict detection
  campaigns       // Pass for campaign conflict detection
);

if (hasChanges) {
  if (changes.removedItems > 0) {
    toast.warning('Some items were removed (out of stock)');
  }
  if (changes.quantityAdjusted > 0) {
    toast.warning('Some quantities were adjusted');
  }
  if (changes.priceChanged > 0) {
    toast.info('Some prices have been updated');
  }
  if (changes.discountCouponRemoved) {
    const message = getDiscountRemovalMessage(changes.discountRemovalReason, 'fi');
    toast.warning(message);
  }
}

// Proceed to checkout with validated cart

Parameters:

ParamTypeRequiredDescription
options.cartIdstringNoCart ID for guest users
options.sessionIdstringNoSession ID for logged-in users
cartItemsCartItem[]NoCart items for campaign conflict check
campaignsCampaign[]NoActive campaigns for conflict check

Returns: Promise<CartValidationResponse>

Discount Code Behavior:

  • If cartItems and campaigns are provided, the SDK calculates if campaigns apply
  • Sends x-campaigns-apply header to backend for discount code validation
  • If a campaign is active and a discount code is applied, the discount code is removed
  • Returns discountCouponRemoved: true and discountRemovalReason in changes

Fetch Options

All methods accept an optional fetchOptions object:

OptionTypeDescription
signalAbortSignalCancel the request
cacheRequestCacheFetch cache mode
next.revalidatenumber | falseNext.js ISR revalidation time
next.tagsstring[]Next.js cache tags

Note: Cart operations typically use { cache: 'no-store' } to ensure fresh data.


Response Types

CartResponse

Response from get(), addItem(), updateQuantity(), removeItem():

interface CartResponse {
  items: CartItem[];
  cartId?: string;  // Only for guest users
}

CartItem

interface CartItem {
  product: ProductDetail;
  cartQuantity: number;
  variation?: ProductVariation;
}

CartValidationResponse

Response from validate():

interface CartValidationResponse {
  items: CartItem[];
  hasChanges: boolean;
  changes: CartValidationChanges;
}

interface CartValidationChanges {
  removedItems: number;           // Products deleted or out of stock
  quantityAdjusted: number;       // Insufficient stock
  priceChanged: number;           // Price updates
  discountCouponRemoved: boolean; // Discount code was removed
  discountRemovalReason?: DiscountRemovalReason;
}

type DiscountRemovalReason = "CAMPAIGN_ACTIVE" | "MIN_ORDER_NOT_MET" | "CODE_INVALID";

Examples

Cart Context Provider

'use client';

import { createContext, useContext, useState, useCallback } from 'react';
import { storefront, ValidationError } from '@/lib/storefront';
import type { CartItem } from '@putiikkipalvelu/storefront-sdk';

interface CartContextType {
  items: CartItem[];
  cartId: string | null;
  isLoading: boolean;
  addItem: (productId: string, quantity: number, variationId?: string) => Promise<void>;
  updateQuantity: (productId: string, delta: number, variationId?: string) => Promise<void>;
  removeItem: (productId: string, variationId?: string) => Promise<void>;
  refreshCart: () => Promise<void>;
}

const CartContext = createContext<CartContextType | null>(null);

export function CartProvider({ children }: { children: React.ReactNode }) {
  const [items, setItems] = useState<CartItem[]>([]);
  const [cartId, setCartId] = useState<string | null>(() => {
    if (typeof window !== 'undefined') {
      return localStorage.getItem('cart-id');
    }
    return null;
  });
  const [isLoading, setIsLoading] = useState(false);

  const refreshCart = useCallback(async () => {
    if (!cartId) return;

    setIsLoading(true);
    try {
      const { items } = await storefront.cart.get({ cartId });
      setItems(items);
    } finally {
      setIsLoading(false);
    }
  }, [cartId]);

  const addItem = useCallback(async (
    productId: string,
    quantity: number,
    variationId?: string
  ) => {
    setIsLoading(true);
    try {
      const { items, cartId: newCartId } = await storefront.cart.addItem({
        cartId: cartId ?? undefined,
        productId,
        variationId,
        quantity
      });

      if (newCartId && newCartId !== cartId) {
        localStorage.setItem('cart-id', newCartId);
        setCartId(newCartId);
      }
      setItems(items);
    } catch (error) {
      if (error instanceof ValidationError) {
        toast.error(error.message);
      }
      throw error;
    } finally {
      setIsLoading(false);
    }
  }, [cartId]);

  const updateQuantity = useCallback(async (
    productId: string,
    delta: number,
    variationId?: string
  ) => {
    if (!cartId) return;

    setIsLoading(true);
    try {
      const { items } = await storefront.cart.updateQuantity({
        cartId,
        productId,
        variationId,
        delta
      });
      setItems(items);
    } finally {
      setIsLoading(false);
    }
  }, [cartId]);

  const removeItem = useCallback(async (
    productId: string,
    variationId?: string
  ) => {
    if (!cartId) return;

    setIsLoading(true);
    try {
      const { items } = await storefront.cart.removeItem({
        cartId,
        productId,
        variationId
      });
      setItems(items);
    } finally {
      setIsLoading(false);
    }
  }, [cartId]);

  return (
    <CartContext.Provider value={{
      items,
      cartId,
      isLoading,
      addItem,
      updateQuantity,
      removeItem,
      refreshCart
    }}>
      {children}
    </CartContext.Provider>
  );
}

export const useCart = () => {
  const context = useContext(CartContext);
  if (!context) {
    throw new Error('useCart must be used within CartProvider');
  }
  return context;
};

Add to Cart Button

'use client';

import { useState } from 'react';
import { useCart } from '@/providers/cart-provider';
import type { ProductDetail, ProductVariation } from '@putiikkipalvelu/storefront-sdk';

interface AddToCartButtonProps {
  product: ProductDetail;
  selectedVariation?: ProductVariation;
}

export function AddToCartButton({ product, selectedVariation }: AddToCartButtonProps) {
  const { addItem, isLoading } = useCart();
  const [quantity, setQuantity] = useState(1);

  const handleAddToCart = async () => {
    try {
      await addItem(product.id, quantity, selectedVariation?.id);
      toast.success('Added to cart!');
    } catch (error) {
      // Error already handled in context
    }
  };

  // Check stock
  const stock = selectedVariation?.quantity ?? product.quantity;
  const isOutOfStock = stock !== null && stock <= 0;

  return (
    <div className="flex items-center gap-4">
      <input
        type="number"
        min={1}
        max={stock ?? 99}
        value={quantity}
        onChange={(e) => setQuantity(Number(e.target.value))}
        className="w-20 px-3 py-2 border rounded"
        disabled={isOutOfStock}
      />
      <button
        onClick={handleAddToCart}
        disabled={isLoading || isOutOfStock}
        className="px-6 py-2 bg-primary text-white rounded disabled:opacity-50"
      >
        {isOutOfStock ? 'Out of Stock' : isLoading ? 'Adding...' : 'Add to Cart'}
      </button>
    </div>
  );
}

Cart Page with Validation

'use client';

import { useEffect, useState } from 'react';
import { storefront } from '@/lib/storefront';
import { useCart } from '@/providers/cart-provider';
import type { CartValidationChanges } from '@putiikkipalvelu/storefront-sdk';

export function CartPage() {
  const { items, cartId, updateQuantity, removeItem } = useCart();
  const [validationChanges, setValidationChanges] = useState<CartValidationChanges | null>(null);

  // Validate cart on mount (before checkout)
  useEffect(() => {
    async function validate() {
      if (!cartId) return;

      const { hasChanges, changes } = await storefront.cart.validate({ cartId });

      if (hasChanges) {
        setValidationChanges(changes);
      }
    }
    validate();
  }, [cartId]);

  const total = items.reduce((sum, item) => {
    const price = item.variation?.price ?? item.product.price;
    return sum + price * item.cartQuantity;
  }, 0);

  return (
    <div>
      <h1>Shopping Cart</h1>

      {validationChanges && (
        <div className="p-4 bg-yellow-50 border border-yellow-200 rounded mb-4">
          <p className="font-medium">Your cart was updated:</p>
          <ul className="text-sm">
            {validationChanges.removedItems > 0 && (
              <li>{validationChanges.removedItems} item(s) removed (out of stock)</li>
            )}
            {validationChanges.quantityAdjusted > 0 && (
              <li>{validationChanges.quantityAdjusted} item(s) quantity adjusted</li>
            )}
            {validationChanges.priceChanged > 0 && (
              <li>{validationChanges.priceChanged} item(s) price updated</li>
            )}
          </ul>
        </div>
      )}

      {items.length === 0 ? (
        <p>Your cart is empty</p>
      ) : (
        <>
          {items.map((item) => {
            const itemKey = item.variation
              ? `${item.product.id}-${item.variation.id}`
              : item.product.id;

            return (
              <div key={itemKey} className="flex items-center gap-4 py-4 border-b">
                <img
                  src={item.product.images[0]}
                  alt={item.product.name}
                  className="w-20 h-20 object-cover rounded"
                />
                <div className="flex-1">
                  <h3>{item.product.name}</h3>
                  {item.variation && (
                    <p className="text-sm text-gray-500">
                      {item.variation.options.map(o => o.value).join(' / ')}
                    </p>
                  )}
                </div>
                <div className="flex items-center gap-2">
                  <button
                    onClick={() => updateQuantity(
                      item.product.id,
                      -1,
                      item.variation?.id
                    )}
                    disabled={item.cartQuantity <= 1}
                  >
                    -
                  </button>
                  <span>{item.cartQuantity}</span>
                  <button
                    onClick={() => updateQuantity(
                      item.product.id,
                      1,
                      item.variation?.id
                    )}
                  >
                    +
                  </button>
                </div>
                <button
                  onClick={() => removeItem(item.product.id, item.variation?.id)}
                  className="text-red-500"
                >
                  Remove
                </button>
              </div>
            );
          })}

          <div className="mt-4 text-right">
            <p className="text-xl font-bold">
              Total: {(total / 100).toFixed(2)} EUR
            </p>
            <button className="mt-4 px-8 py-3 bg-primary text-white rounded">
              Proceed to Checkout
            </button>
          </div>
        </>
      )}
    </div>
  );
}

Utility Functions

calculateCartWithCampaigns(items, campaigns)

Calculate cart totals with campaign discounts applied. This is a standalone utility function (not a method on the cart resource).

Supported Campaign Types:

  • FREE_SHIPPING: Free shipping when cart total exceeds minimum spend
  • BUY_X_PAY_Y: Buy X items, pay for Y (e.g., Buy 3 Pay 2 = cheapest item free)
import { calculateCartWithCampaigns } from '@putiikkipalvelu/storefront-sdk';

const result = calculateCartWithCampaigns(cartItems, activeCampaigns);

console.log(result.cartTotal);      // 4990 (cents) - after discounts
console.log(result.originalTotal);  // 6980 (cents) - before discounts
console.log(result.totalSavings);   // 1990 (cents) - total saved

// Free shipping status
if (result.freeShipping.isEligible) {
  console.log('Free shipping!');
} else {
  console.log(`Add ${result.freeShipping.remainingAmount / 100} EUR for free shipping`);
}

// Render items with paid/free quantities
result.calculatedItems.forEach(({ item, paidQuantity, freeQuantity }) => {
  console.log(`${item.product.name}: ${paidQuantity} paid, ${freeQuantity} free`);
});

Parameters:

ParamTypeRequiredDescription
itemsCartItem[]YesCart items to calculate
campaignsCampaign[]YesActive campaigns from store config

Returns: CartCalculationResult

interface CartCalculationResult {
  calculatedItems: CalculatedCartItem[];
  cartTotal: number;        // Final total after discounts (cents)
  originalTotal: number;    // Total before discounts (cents)
  totalSavings: number;     // Total saved from campaigns (cents)
  freeShipping: FreeShippingStatus;
}

interface CalculatedCartItem {
  item: CartItem;           // Original cart item
  paidQuantity: number;     // Units customer pays for
  freeQuantity: number;     // Units that are free (from campaign)
  totalQuantity: number;    // Total units in cart
}

interface FreeShippingStatus {
  isEligible: boolean;              // Qualifies for free shipping
  minimumSpend: number;             // Minimum spend required (cents)
  remainingAmount: number;          // Amount needed to qualify (cents)
  campaignName?: string;            // Name of the campaign
  eligibleShipmentMethodIds?: string[]; // Shipping methods eligible for free
}

Buy X Pay Y Logic:

  • Finds products in applicable categories
  • Sorts by price (cheapest first)
  • Makes the cheapest item(s) free
  • Example: Buy 3 Pay 2 with items at 10, 20, 30 EUR = 10 EUR item is free

Complete Cart Calculation Example

'use client';

import { useMemo } from 'react';
import { calculateCartWithCampaigns } from '@putiikkipalvelu/storefront-sdk';
import { useCart } from '@/providers/cart-provider';
import { useStoreConfig } from '@/providers/store-config-provider';

export function CartSummary() {
  const { items } = useCart();
  const { campaigns } = useStoreConfig();

  const calculation = useMemo(() => {
    return calculateCartWithCampaigns(items, campaigns);
  }, [items, campaigns]);

  const formatPrice = (cents: number) => (cents / 100).toFixed(2);

  return (
    <div className="space-y-4 p-4 bg-gray-50 rounded">
      {/* Line items with discounts */}
      {calculation.calculatedItems.map(({ item, paidQuantity, freeQuantity }) => (
        <div key={item.product.id} className="flex justify-between">
          <div>
            <span>{item.product.name}</span>
            {freeQuantity > 0 && (
              <span className="ml-2 text-green-600 text-sm">
                ({freeQuantity} free!)
              </span>
            )}
          </div>
          <span>x{paidQuantity + freeQuantity}</span>
        </div>
      ))}

      <hr />

      {/* Subtotal */}
      <div className="flex justify-between">
        <span>Subtotal</span>
        <span>{formatPrice(calculation.originalTotal)} EUR</span>
      </div>

      {/* Savings */}
      {calculation.totalSavings > 0 && (
        <div className="flex justify-between text-green-600">
          <span>Campaign Discount</span>
          <span>-{formatPrice(calculation.totalSavings)} EUR</span>
        </div>
      )}

      {/* Free shipping progress */}
      {!calculation.freeShipping.isEligible && calculation.freeShipping.minimumSpend > 0 && (
        <div className="text-sm text-gray-600">
          Add {formatPrice(calculation.freeShipping.remainingAmount)} EUR for free shipping
          <div className="w-full bg-gray-200 rounded h-2 mt-1">
            <div
              className="bg-primary h-2 rounded"
              style={{
                width: `${Math.min(100, (calculation.cartTotal / calculation.freeShipping.minimumSpend) * 100)}%`
              }}
            />
          </div>
        </div>
      )}

      {calculation.freeShipping.isEligible && (
        <div className="flex justify-between text-green-600">
          <span>Shipping</span>
          <span>FREE</span>
        </div>
      )}

      {/* Total */}
      <div className="flex justify-between text-xl font-bold">
        <span>Total</span>
        <span>{formatPrice(calculation.cartTotal)} EUR</span>
      </div>
    </div>
  );
}

Error Handling

import {
  NotFoundError,
  ValidationError,
  StorefrontError
} from '@putiikkipalvelu/storefront-sdk';

try {
  await storefront.cart.addItem({
    cartId,
    productId: 'prod_123',
    quantity: 10
  });
} catch (error) {
  if (error instanceof ValidationError) {
    // Insufficient stock, invalid quantity, cart limit
    toast.error(error.message);
  }
  if (error instanceof NotFoundError) {
    // Product or variation not found
    toast.error('Product no longer available');
  }
  if (error instanceof StorefrontError) {
    // Other API error
    console.error(`API Error: ${error.message} (${error.code})`);
  }
  throw error;
}

On this page