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 Type | Identification | Storage |
|---|---|---|
| Guest | cartId (UUID) | Cookie or localStorage |
| Logged-in | sessionId | Auth session cookie |
Guest Cart Flow:
- First
addItem()call returns a newcartId - Store
cartIdin cookie/localStorage - Pass
cartIdin subsequent cart operations
Logged-in User Flow:
- Pass
sessionIdfrom auth session - Cart is automatically linked to customer account
- 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:
| Param | Type | Required | Description |
|---|---|---|---|
options.cartId | string | No | Cart ID for guest users |
options.sessionId | string | No | Session 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:
| Param | Type | Required | Description |
|---|---|---|---|
cartId | string | No | Cart ID for guest users |
sessionId | string | No | Session ID for logged-in users |
productId | string | Yes | Product ID to add |
variationId | string | No | Variation ID (required if product has variations) |
quantity | number | Yes | Quantity to add (min: 1) |
Returns: Promise<CartResponse>
Throws:
ValidationErrorif quantity exceeds available stockNotFoundErrorif product/variation not foundValidationErrorif 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:
| Param | Type | Required | Description |
|---|---|---|---|
cartId | string | No | Cart ID for guest users |
sessionId | string | No | Session ID for logged-in users |
productId | string | Yes | Product ID to update |
variationId | string | No | Variation ID (if applicable) |
delta | number | Yes | Quantity 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:
| Param | Type | Required | Description |
|---|---|---|---|
cartId | string | No | Cart ID for guest users |
sessionId | string | No | Session ID for logged-in users |
productId | string | Yes | Product ID to remove |
variationId | string | No | Variation 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 cartParameters:
| Param | Type | Required | Description |
|---|---|---|---|
options.cartId | string | No | Cart ID for guest users |
options.sessionId | string | No | Session ID for logged-in users |
cartItems | CartItem[] | No | Cart items for campaign conflict check |
campaigns | Campaign[] | No | Active campaigns for conflict check |
Returns: Promise<CartValidationResponse>
Discount Code Behavior:
- If
cartItemsandcampaignsare provided, the SDK calculates if campaigns apply - Sends
x-campaigns-applyheader to backend for discount code validation - If a campaign is active and a discount code is applied, the discount code is removed
- Returns
discountCouponRemoved: trueanddiscountRemovalReasonin changes
Fetch Options
All methods accept an optional fetchOptions object:
| Option | Type | Description |
|---|---|---|
signal | AbortSignal | Cancel the request |
cache | RequestCache | Fetch cache mode |
next.revalidate | number | false | Next.js ISR revalidation time |
next.tags | string[] | 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:
| Param | Type | Required | Description |
|---|---|---|---|
items | CartItem[] | Yes | Cart items to calculate |
campaigns | Campaign[] | Yes | Active 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;
}