@weareconceptstudio/cart
v0.1.0
Published
Concept Studio Cart
Readme
@weareconceptstudio/cart
Zustand-based cart state for Concept Studio frontends. Supports simple products, variant products (color/size), guest cookie carts, logged-in server carts, checkout, promotions, gifts, and utensils.
Project-specific behavior (tour bookings, custom API fields, alternate endpoints) is configured outside the package via CartIntegration — the cart package itself stays generic.
Table of contents
- Installation
- Quick start
- Architecture
- Initialization
- Cart flows
- Hooks
- Store API
- Configuration (
CartConfig) - Cart integration (per project)
- Backend API contract
- Types
- Utilities & defaults
- Examples
- Package structure
- Migration from Context API
Installation
npm install @weareconceptstudio/cart zustandPeer dependency: @weareconceptstudio/core (API client, cookies, translations).
Quick start
With @weareconceptstudio/account (recommended)
AccountProvider initializes the cart automatically:
import { AccountProvider } from '@weareconceptstudio/account';
<AccountProvider
globalSettings={{
hasVariants: false,
supportsGuestCart: true,
hasGiftSystem: true,
hasUtensilsSystem: true,
}}
cartIntegration={myCartIntegration} // optional — see below
>
{children}
</AccountProvider>Manual initialization
import { initCartStore } from '@weareconceptstudio/cart';
initCartStore(
{
hasVariants: false,
supportsGuestCart: true,
integration: myCartIntegration, // optional
},
isLoggedIn,
selectedLang,
user
);Add to cart in a component
import { useSimpleCart } from '@weareconceptstudio/cart';
function ProductCard({ productId }) {
const { addProduct, getProductQty } = useSimpleCart();
return (
<button onClick={() => addProduct(productId, 1)}>
Add to cart ({getProductQty(productId)})
</button>
);
}Architecture
┌─────────────────────────────────────────────────────────────┐
│ React components │
│ useCart · useSimpleCart · useVariantCart · useCheckout │
└───────────────────────────┬─────────────────────────────────┘
│
┌───────────────────────────▼─────────────────────────────────┐
│ Zustand store (cartStore) │
│ addItem · removeItem · getCart · checkout · mergeCart … │
└───────────────────────────┬─────────────────────────────────┘
│
┌─────────────┴─────────────┐
│ │
isLoggedIn = true isLoggedIn = false
│ │
Server API (auth) Cookie + guest-cart API
toggle-cart-item (if supportsGuestCart)
delete-cart-item
│ │
└─────────────┬─────────────┘
│
┌───────────────────────────▼─────────────────────────────────┐
│ CartIntegration (project config) │
│ endpoints · buildAddItemRequest · guest.buildCookieItem … │
└─────────────────────────────────────────────────────────────┘Design principle: the store owns flow and state; projects inject how requests are built and how guest cookies behave. Never fork the package for one backend.
Initialization
initCartStore(config, isLoggedIn, selectedLang?, user?)
Call once when the app starts (or when auth/language changes). AccountProvider does this for you.
| Argument | Description |
|----------|-------------|
| config | CartConfig — flags + optional integration |
| isLoggedIn | Whether the user has a valid session |
| selectedLang | Current locale (default 'en') |
| user | User object (payment method, cards, etc.) |
Also sets isCheckoutPage / isCartPage from the URL, hydrates checkoutData from sessionStorage, and handles ?cardSaved=1&cardId= query cleanup.
Cart flows
Logged-in user
| Action | Endpoint (default) | Method |
|--------|-------------------|--------|
| Fetch cart | cart | GET |
| Add / update line | toggle-cart-item | POST |
| Remove line | delete-cart-item | POST |
| Clear cart | clear-cart | POST |
| Checkout | checkout | POST |
| Merge guest cookie on login | merge-cart | POST |
| Sync variant cookie cart | sync-cart | POST |
| Reorder | reorder | POST |
Each request body is built by CartIntegration hooks (defaults merge checkoutData, params, and cartResourceType).
Guest user (supportsGuestCart !== false)
- Add / update / remove → update browser cookie (
config.cookieName, defaultcart). - Fetch cart →
POST guest-cartwith{ items, cart, …checkoutData }. - Checkout →
POST checkoutwith checkout data + guestitems(cookie/session). - On login →
merge-cartwith cookie items, then cookie is cleared.
Set supportsGuestCart: false for backends without guest endpoints (cart stays empty until login).
cartResourceType
Sent on get/add/remove requests:
'cart'on/cartor/checkoutpages'cart-summary'elsewhere (e.g. header mini-cart)
Override via buildGetCartParams / buildAddItemRequest if your API differs.
Hooks
useCart()
Full cart + checkout API. Use in cart pages, checkout, and custom UIs.
State: items, itemsCount, subtotal, total, formatted_total, currency, shippingCost, discount, cashback, appliedPromotions, utensils, messages, loading, actionLoading, checkoutData, promotion_code, promotion_error, useBalance, user, config, isCheckoutPage, isCartPage, isLoggedIn, isRedirecting, error, checkoutBtnDisabled, hasOutOfStockItems, …
Actions:
| Method | Description |
|--------|-------------|
| addItem(params) | Add or update a line |
| removeItem(params) | Remove a line |
| updateItem(params) | Update qty (qty: 0 removes) |
| clearCart() | Empty cart |
| getCart(params?, showLoading?) | Refresh from server / guest-cart |
| mergeCart() | Merge cookie into server cart (after login) |
| syncCart() | Sync cookie cart (variants) |
| checkout() | Submit order |
| updateCheckoutData(partial) | Merge checkout fields (persisted to session) |
| fillCheckoutData(keyOrObject, value?) | Legacy flexible checkout update |
| fillCart(key, value) | Update checkout field + refetch cart |
| setGifts / togglePromotion | Promotion gifts |
| setUtensils / toggleUtensil | Utensils selection |
| reorder(orderId) | Reorder + redirect to cart |
| resetCart() | Reset store + clear cookie/session checkout |
| setUser(user) | Update user on store |
Legacy (backward compatible):
| Method | Maps to |
|--------|---------|
| toggleCartItem({ productId, qty, variantId?, optionId?, itemId? }) | addItem / removeItem / updateItem |
| deleteCartItem(params) | removeItem |
| editCartItem(params) | remove old variant + add new |
| handleCheckout | checkout |
addItem / removeItem params:
// Add
addItem({
productId: 123,
qty: 1,
variantId?: number,
optionId?: number,
itemId?: number, // existing line id when editing
[key: string]: unknown, // extra fields — use CartIntegration to persist/send
});
// Remove
removeItem({
productId: 123,
variantId?: number,
optionId?: number,
itemId?: number,
id?: number,
cartItemId?: number,
});useSimpleCart()
For shops without variants.
addProduct(productId, qty?)removeProduct(productId)updateProductQty(productId, qty)isInCart(productId)getProductQty(productId)items,itemsCount,loading
useVariantCart()
For shops with variants (color, size, …). Requires hasVariants: true.
addVariantProduct(productId, variantId, optionId, qty?)removeVariantProduct(...)updateVariantProductQty(...)isVariantInCart(...)getVariantQty(...)
useCheckout()
Checkout helpers.
checkout()— complete ordersetAddress(addressId)setPaymentMethod(paymentType, cardId?)setNote(note)applyPromoCode(code)isReadyForCheckout()checkoutData,itemsCount,total,subtotal,loading
Store API
useCartStore()
Direct Zustand access. Prefer hooks in components.
import { useCartStore } from '@weareconceptstudio/cart';
const itemsCount = useCartStore((s) => s.itemsCount);
await useCartStore.getState().addItem({ productId: 1, qty: 1 });Configuration (CartConfig)
interface CartConfig {
hasVariants?: boolean; // default false
supportsGuestCart?: boolean; // default true
hasGifts?: boolean; // default true
hasUtensils?: boolean; // default true
cookieName?: string; // default 'cart'
abandonPaymentEndpoint?: string; // e.g. 'checkout/abandon-payment'
integration?: CartIntegration;
guestCart?: GuestCartIntegration; // deprecated → use integration.guest
}| Flag | Effect |
|------|--------|
| hasVariants | Variant matching in add/remove; syncCart vs mergeCart on login |
| supportsGuestCart | Guest cookie + guest-cart API; when false, guest cart ops are no-ops |
| hasGifts | Gift selection in checkout; stripped from requests when false |
| hasUtensils | Utensils in checkout; stripped when false |
| cookieName | Guest cart cookie key |
| abandonPaymentEndpoint | Called when user returns from failed redirect payment |
Cart integration (per project)
Define a CartIntegration object in your app (e.g. src/helpers/cartIntegration.ts) and pass it as cartIntegration on AccountProvider or integration in initCartStore.
defineCartIntegration(overrides)
import { defineCartIntegration } from '@weareconceptstudio/cart';
export const myCartIntegration = defineCartIntegration({
// only override what you need
});Hooks reference
| Hook | When to override |
|------|------------------|
| endpoints | Non-standard API paths |
| guest.buildCookieItem | Extra fields stored in guest cookie |
| guest.matchCookieItems | How guest lines match on upsert/remove |
| guest.upsertCookieItems | Full control of cookie array updates |
| guest.buildGuestCartRequest | Extra POST guest-cart body fields |
| guest.buildRemoveCookieTarget | Shape of remove target for cookie matching |
| findCartItem | Match existing server cart line |
| buildAddItemRequest | Logged-in add/update POST body |
| buildRemoveItemRequest | Logged-in remove POST body |
| buildGetCartParams | GET cart query/body |
| buildMergeCartRequest | Login merge body |
| buildSyncCartRequest | Variant sync body |
| buildCheckoutRequest | Checkout POST body (guest items by default) |
| normalizeCartResponse | Map API JSON → store shape |
| shouldRetryRemoveItem | Backend-specific remove error handling |
CartIntegrationContext
Passed to most builders:
{
config: CartConfig;
isLoggedIn: boolean;
selectedLang: string;
checkoutData: CheckoutData;
items: CartItemWithProduct[];
hasVariants: boolean;
cartResourceType: 'cart' | 'cart-summary';
user: unknown;
}Default endpoints
import { DEFAULT_CART_ENDPOINTS } from '@weareconceptstudio/cart';
// {
// getCart: 'cart',
// addItem: 'toggle-cart-item',
// removeItem: 'delete-cart-item',
// clearCart: 'clear-cart',
// guestCart: 'guest-cart',
// mergeCart: 'merge-cart',
// syncCart: 'sync-cart',
// checkout: 'checkout',
// reorder: 'reorder',
// }Standard shop (no integration)
Defaults work out of the box if your backend matches the contract below.
Custom fields (generic shop)
import {
defineCartIntegration,
defaultBuildAddItemRequest,
defaultBuildCookieItem,
pickCartFields,
} from '@weareconceptstudio/cart';
export const myCartIntegration = defineCartIntegration({
guest: {
buildCookieItem(params, existing, hasVariants) {
return {
...defaultBuildCookieItem(params, existing, hasVariants),
...pickCartFields(params, ['engraving_text']),
};
},
},
buildAddItemRequest(params, ctx, matched) {
return {
...defaultBuildAddItemRequest(params, ctx, matched),
engraving_text: params.engraving_text,
};
},
});Tour / booking project (Silk Road pattern)
Keep all tour-specific logic in the project file — not in the cart package:
import {
defineCartIntegration,
defaultBuildAddItemRequest,
defaultBuildCookieItem,
defaultMatchCookieItems,
pickCartFields,
} from '@weareconceptstudio/cart';
const GUEST_KEYS = ['tour_id', 'adults', 'children', 'start_date', 'addons', 'clientId'] as const;
export const silkCartIntegration = defineCartIntegration({
guest: {
buildCookieItem(params, existing, hasVariants) {
const base = defaultBuildCookieItem(params, existing, hasVariants);
return {
...base,
...pickCartFields(params, GUEST_KEYS),
clientId: params.itemId ?? params.clientId ?? Date.now(),
tour_id: params.tour_id ?? params.productId,
};
},
matchCookieItems(item1, item2, hasVariants) {
const id1 = item1.clientId ?? item1.itemId;
const id2 = item2.clientId ?? item2.itemId;
if (id1 != null && id2 != null) return id1 == id2;
if (!hasVariants) return item1.productId === item2.productId;
return defaultMatchCookieItems(item1, item2, hasVariants);
},
},
buildAddItemRequest(params, ctx, matched) {
return {
...defaultBuildAddItemRequest(params, ctx, matched),
tour_id: params.tour_id ?? params.productId,
};
},
findCartItem(params, items) {
if (params.itemId != null) {
return items.find((i) => i.id === params.itemId || i.itemId === params.itemId);
}
return items.find((i) => Number(i.product?.id) === Number(params.productId));
},
});Wire it:
// account config / AppProviders
cartIntegration: silkCartIntegration,
globalSettings: { supportsGuestCart: true, ... },Backward compatibility
guestCartprop onAccountProvider→ treated asintegration.guestGuestCartHookstype → alias forGuestCartIntegration
Backend API contract
Default integration expects these endpoints (all under your API base + language prefix from @weareconceptstudio/core).
GET cart
Query: checkout-related fields + cartResourceType.
Response (typical):
{
"items": [{ "id": 1, "product": { "id": 10, "name": "..." }, "qty": 2 }],
"itemsCount": 1,
"subtotal": 100,
"total": 100,
"formatted_total": "$100",
"currency": "USD",
"utensils": [],
"messages": []
}POST toggle-cart-item
Body: productId, qty, optional variantId / optionId / itemId, cartResourceType, checkout fields.
Returns updated cart (same shape as GET).
POST delete-cart-item
Body: productId, optional ids, cartResourceType, checkout fields.
POST guest-cart
Body: { items: CookieCartItem[], cart: CookieCartItem[], ...checkoutData }.
Cookie items are minimal lines (productId, qty, + project fields from guest.buildCookieItem).
Returns cart with priced items (same shape as logged-in cart).
POST merge-cart
Body: { items: CookieCartItem[] }. Called after login.
POST checkout
Body: CheckoutData fields + optional items for guests.
Response: { redirect_url? } or { url, form } (Idram) or success payload.
POST clear-cart · POST sync-cart · POST reorder
As used by store actions above.
Override paths via integration.endpoints if your backend differs.
Types
Cookie / request items
interface BaseCartItem {
productId: number;
qty: number;
[key: string]: unknown; // project-specific guest fields
}
interface VariantCartItem extends BaseCartItem {
variantId: number;
optionId: number;
itemId?: number;
}
type CartItem = BaseCartItem | VariantCartItem;API cart line
interface CartItemWithProduct {
id?: number;
product: CartProduct;
qty: number;
variantId?: number;
optionId?: number;
itemId?: number;
is_gift?: boolean;
appliedPromotion?: unknown;
// stock / qty adjustment fields...
}Checkout data (persisted in sessionStorage)
interface CheckoutData {
addressId: number | null;
billingAddressId?: number | null;
shippingAddressId?: number | null;
useBalance: number | string | null;
note: string;
paymentType: string | number;
gifts: GiftItem[];
card_id: number | null;
excludedPromotions: number[];
promotion_code: string | null;
promotion_error: string | null;
checkout_error?: string | null;
utensils: UtensilItem[];
fulfillmentType?: 'delivery' | 'takeaway' | null;
fulfillmentBranchId?: number | null;
scheduledDate?: string | null;
scheduledTime?: string | null;
}Projects may store extra keys in checkoutData via fillCheckoutData (e.g. tour UI state); they are sent to the API when included in buildAddItemRequest / checkout unless stripped by cleanObject.
Utilities & defaults
Exported from @weareconceptstudio/cart:
| Export | Purpose |
|--------|---------|
| resolveCartIntegration(config) | Merge config + defaults into resolved hooks |
| defineCartIntegration(overrides) | Declare project integration |
| DEFAULT_CART_ENDPOINTS | Default API paths |
| defaultBuildAddItemRequest | Standard add payload |
| defaultBuildRemoveItemRequest | Standard remove payload |
| defaultBuildCookieItem | Standard guest cookie line |
| defaultBuildRemoveCookieTarget | Standard guest remove target |
| defaultFindCartItem | Standard server line matching |
| defaultMatchCookieItems | Match by itemId/clientId or product/variant |
| defaultNormalizeCartResponse | itemsCount / items_qty normalization |
| pickCartFields(source, keys) | Pick params for cookies/API |
| getCartCookieName(config) | Resolve cookie name |
| parseCookieCart / serializeCartToCookie | Cookie JSON helpers |
| itemsMatch / addOrUpdateItem / removeItem | Low-level cookie array ops |
| cleanObject | Remove null/empty from request bodies |
Examples
Mini cart
import { useCart } from '@weareconceptstudio/cart';
function MiniCart() {
const { itemsCount, formatted_total, loading } = useCart();
if (loading) return null;
return <span>{itemsCount} · {formatted_total}</span>;
}Checkout with terms gate
import { useCart } from '@weareconceptstudio/cart';
function CheckoutButton({ termsAccepted }) {
const { checkout, checkoutBtnDisabled, itemsCount } = useCart();
const disabled = checkoutBtnDisabled || !termsAccepted;
return (
<button
disabled={disabled}
onClick={() => checkout()}
>
Pay ({itemsCount})
</button>
);
}Pass extra params from product page
const cart = useCart();
await cart.addItem({
productId: tourId,
qty: 1,
tour_id: tourId,
adults: 2,
children: 0,
start_date: '2026-07-01',
addons: { lunch: true, tickets: false, guide_id: 5 },
});Tour fields are persisted only if cartIntegration.guest.buildCookieItem and buildAddItemRequest include them (see Silk example).
Package structure
packages/cart/src/
├── index.ts # Public exports
├── hooks/
│ ├── useCart.ts # useCart, useSimpleCart, useVariantCart, useCheckout
│ └── index.ts
├── store/
│ ├── cartStore.ts # Zustand store + initCartStore
│ ├── helpers.ts # sessionStorage, cartResourceType, checkout cleanup
│ └── index.ts
├── integration/
│ ├── types.ts # CartIntegration, endpoints, context
│ ├── defaults.ts # Default hooks + DEFAULT_CART_ENDPOINTS
│ ├── resolve.ts # resolveCartIntegration, defineCartIntegration
│ └── index.ts
├── types/
│ └── index.ts # CartItem, CartState, CartConfig, …
└── utils/
├── helpers.ts # Cookie/item array utilities
└── guestCart.ts # Deprecated shimsMigration from Context API
| Context API | Zustand |
|-------------|---------|
| toggleCartItem({ productId, qty }) | useSimpleCart().addProduct(id, qty) or useCart().toggleCartItem(...) |
| deleteCartItem(...) | useCart().deleteCartItem(...) |
| fillCheckoutData(...) | useCart().fillCheckoutData(...) |
| Context provider | initCartStore / AccountProvider |
License
ISC © Concept Studio
