@caldik/client 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,5 @@
1
1
  # Caldik SDK
2
-
3
- The official Caldik SDK for interacting with Caldik storefronts, products, carts, checkouts, and customer accounts.
2
+ TThe official Caldik SDK for interacting with Caldik storefronts, products, carts, checkouts, and customer accounts.
4
3
 
5
4
  ## Installation
6
5
 
@@ -37,9 +36,93 @@ const caldik = new CaldikClient({
37
36
 
38
37
  > **Security:** Private API keys must only be used in trusted server-side environments. Never expose `sk_` keys in client-side code.
39
38
 
39
+
40
40
  ## Orders and Items
41
41
 
42
- ### Categories
42
+ ### List Products
43
+
44
+ Retrieve products associated with the current store.
45
+
46
+ **Basic usage**
47
+
48
+ ```ts
49
+ const { products } = await caldik.products.list()
50
+ ```
51
+
52
+ **Limit results**
53
+
54
+ Pass a number to limit the number of products returned.
55
+
56
+ ```ts
57
+ const { products } = await caldik.products.list(20)
58
+ ```
59
+
60
+ ### Get Product
61
+
62
+ Retrieve a visible product by its ID or slug. Product IDs are unambiguous. The
63
+ same slug can be used in different categories, but must be unique within a
64
+ category. Uncategorized products have their own shared slug namespace.
65
+
66
+ **Basic usage**
67
+
68
+ ```ts
69
+ const { product } = await caldik.products.get("PRODUCT_...")
70
+ ```
71
+
72
+ **Get a product by category and slug**
73
+
74
+ Pass a category ID or slug when the product slug is shared across categories.
75
+
76
+ ```ts
77
+ const { product: rustProduct } = await caldik.products.get("stealth", {
78
+ category: "test2",
79
+ })
80
+
81
+ const { product: testProduct } = await caldik.products.get("stealth", {
82
+ category: "test",
83
+ })
84
+ ```
85
+
86
+ **Get a product by a unique slug**
87
+
88
+ ```ts
89
+ const { product } = await caldik.products.get("unique-product")
90
+ ```
91
+
92
+ ### Get Store
93
+
94
+ **Get the store profile and statistics**
95
+
96
+ ```ts
97
+ const { store } = await caldik.store.get()
98
+
99
+ console.log(store.name)
100
+ console.log(store.averageRating) // e.g. 4.9; null when there are no reviews
101
+ console.log(store.orders) // integer count of all store order records
102
+ console.log(store.customers) // integer count of all customer records, including guests
103
+ ```
104
+
105
+ **Read the store's social links**
106
+
107
+ ```ts
108
+ const { store } = await caldik.store.get()
109
+
110
+ const { instagram, tiktok, x, youtube, discord, telegram } = store.socials
111
+ ```
112
+
113
+ Socials are included in the same response. Each unconfigured link is `null`.
114
+
115
+ ### Get Category
116
+
117
+ Retrieve a specific category by its ID.
118
+
119
+ **Basic usage**
120
+
121
+ ```ts
122
+ const { category } = await caldik.categories.get("CAT_ID")
123
+ ```
124
+
125
+ ### List Categories
43
126
 
44
127
  Retrieve categories associated with the current store.
45
128
 
@@ -68,6 +151,8 @@ const { categories } = await caldik.categories.list({
68
151
  })
69
152
  ```
70
153
 
154
+ **Optional fields**
155
+
71
156
  The following fields are omitted by default unless explicitly requested using `include`:
72
157
 
73
158
  | Field | Description |
@@ -75,16 +160,23 @@ The following fields are omitted by default unless explicitly requested using `i
75
160
  | `productCount` | Number of products associated with the category. |
76
161
  | `startingPrice` | Lowest available product price in the category, in cents. |
77
162
 
163
+
78
164
  ## Cart and Discounts
79
165
 
80
166
  ### Cart Management
81
167
 
82
- Add products to the cart, update quantities, or remove items.
168
+ **Get the cart**
169
+
170
+ Refresh the current items and applied discount from the server.
171
+
172
+ ```ts
173
+ const { items, discount } = await caldik.cart.get()
174
+ ```
83
175
 
84
176
  **Add an item**
85
177
 
86
178
  ```ts
87
- const cart = caldik.cart.add({
179
+ const cart = await caldik.cart.add({
88
180
  productId: "PRODUCT_...",
89
181
  productVariantId: "PRODUCT_VARIANT_...",
90
182
  quantity: 1,
@@ -93,30 +185,90 @@ const cart = caldik.cart.add({
93
185
 
94
186
  **Update item quantity**
95
187
 
188
+ Set an item's quantity. A quantity of zero removes it.
189
+
96
190
  ```ts
97
- caldik.cart.updateQuantity(cart.items[0].id, 3)
191
+ await caldik.cart.updateQuantity(cart.items[0].id, 3)
98
192
  ```
99
193
 
100
194
  **Remove an item**
101
195
 
102
196
  ```ts
103
- caldik.cart.remove(cart.items[0].id)
197
+ await caldik.cart.remove(cart.items[0].id)
198
+ ```
199
+
200
+ **Clear the cart**
201
+
202
+ Remove all items and the applied discount from the persisted cart.
203
+
204
+ ```ts
205
+ await caldik.cart.clear()
206
+ ```
207
+
208
+ **Check a discount**
209
+
210
+ Get a price and discount quote for the current items without applying the code.
211
+
212
+ ```ts
213
+ const quote = await caldik.cart.checkDiscount("SAVE10")
104
214
  ```
105
215
 
106
- ### Apply Discounts
216
+ **Apply a discount**
107
217
 
108
- Apply a discount code to the current cart.
218
+ Validate the code and save it to the current cart.
109
219
 
110
220
  ```ts
111
- await caldik.cart.applyDiscount("SAVE10")
221
+ const discountedCart = await caldik.cart.applyDiscount("SAVE10")
112
222
  ```
113
223
 
114
- ### Checkout
224
+ **Remove the discount**
115
225
 
116
- Create a checkout session using the current cart or explicitly provided items.
226
+ Remove the applied code while keeping the cart's items.
227
+
228
+ ```ts
229
+ await caldik.cart.removeDiscount()
230
+ ```
231
+
232
+ **Checkout the cart**
233
+
234
+ Create an order using the persisted cart. The API revalidates prices and discounts.
117
235
 
118
236
  ```ts
119
237
  const result = await caldik.cart.checkout({
238
+ paymentMethod: "stripe",
239
+ termsAccepted: acceptedTermsCheckbox,
240
+ successUrl: "https://shop.example/success",
241
+ cancelUrl: "https://shop.example/cart",
242
+ })
243
+ ```
244
+
245
+ ### Server-rendered Storefronts
246
+
247
+ Create a client for each incoming request so shopper sessions stay isolated.
248
+ Pass the request and forward returned session cookies to the outgoing response:
249
+
250
+ ```ts
251
+ const responseHeaders = new Headers()
252
+ const caldik = new CaldikClient({
253
+ request,
254
+ onSetCookie: (cookie) => responseHeaders.append("Set-Cookie", cookie),
255
+ // External servers also supply storeId and their private apiKey.
256
+ })
257
+ const cart = await caldik.cart.get()
258
+ // Include responseHeaders in your framework's HTTP response.
259
+ ```
260
+
261
+ Browser and server calls must share the same cookie context (for example through
262
+ a storefront API proxy). Cookies set only on a separate API hostname are not
263
+ included in requests to the storefront server. A private store key does not
264
+ replace a shopper's customer session.
265
+
266
+ ### Checkout with Explicit Items
267
+
268
+ Use `caldik.checkout.create()` to create an order from explicitly provided items.
269
+
270
+ ```ts
271
+ const result = await caldik.checkout.create({
120
272
  paymentMethod: "stripe",
121
273
  items: [
122
274
  {
package/dist/index.d.mts CHANGED
@@ -70,6 +70,25 @@ interface CategoryResponse {
70
70
  interface Store {
71
71
  name: string;
72
72
  description: string;
73
+ /** Average of all store reviews, rounded to one decimal; null without reviews. */
74
+ averageRating: number | null;
75
+ /** All store order records, including unpaid, failed and refunded orders. */
76
+ orders: number;
77
+ /** All store customer records, including guest customers. */
78
+ customers: number;
79
+ socials: StoreSocials;
80
+ }
81
+ interface StoreSocials {
82
+ instagram: string | null;
83
+ tiktok: string | null;
84
+ x: string | null;
85
+ youtube: string | null;
86
+ discord: string | null;
87
+ telegram: string | null;
88
+ }
89
+ interface ProductGetOptions {
90
+ /** Category ID or slug. Required for a slug used in multiple categories. */
91
+ category?: string;
73
92
  }
74
93
  interface StoreResponse {
75
94
  success: boolean;
@@ -349,6 +368,8 @@ interface RequestOptions {
349
368
  edgeCache?: boolean;
350
369
  /** Internal authorization policy. Protected hosted calls are the default. */
351
370
  access?: "public" | "runtime";
371
+ /** Scoped credentials used by stateful storefront resources. */
372
+ headers?: Record<string, string>;
352
373
  }
353
374
  //#endregion
354
375
  //#region src/internal/client.d.ts
@@ -356,13 +377,22 @@ type HttpClientOptions = {
356
377
  apiKey?: string;
357
378
  apiUrl?: string;
358
379
  storeId?: string;
380
+ request?: Request;
381
+ onSetCookie?: (cookie: string) => void | Promise<void>;
359
382
  };
360
383
  declare class CaldikHttpClient {
361
384
  private readonly apiKey?;
362
385
  private readonly apiUrl;
363
386
  private readonly storeId?;
387
+ private readonly cookies;
388
+ private readonly onSetCookie?;
389
+ private readonly authenticationListeners;
364
390
  constructor(options?: HttpClientOptions);
365
391
  storeContextId(): string | null;
392
+ hasCartSession(): boolean;
393
+ onCustomerAuthentication(listener: () => Promise<unknown>): void;
394
+ customerAuthenticated(): Promise<void>;
395
+ private captureCookies;
366
396
  private baseUrl;
367
397
  private request;
368
398
  get<T>(path: string, params?: Record<string, string | number>, options?: RequestOptions): Promise<T>;
@@ -382,7 +412,7 @@ declare class ProductsApi {
382
412
  private client;
383
413
  constructor(client: CaldikHttpClient);
384
414
  list(): Promise<ProductsResponse>;
385
- get(productIdOrSlug: string): Promise<ProductResponse>;
415
+ get(productIdOrSlug: string, options?: ProductGetOptions): Promise<ProductResponse>;
386
416
  }
387
417
  //#endregion
388
418
  //#region src/reviews.d.ts
@@ -476,10 +506,8 @@ declare class DiscountsApi {
476
506
  }
477
507
  //#endregion
478
508
  //#region src/cart.d.ts
479
- interface CartItem {
509
+ interface CartItem extends ProductCommerceItem {
480
510
  id: string;
481
- productId: string;
482
- productVariantId?: string;
483
511
  quantity: number;
484
512
  }
485
513
  interface CartDiscount {
@@ -494,24 +522,35 @@ interface CartSnapshot {
494
522
  items: CartItem[];
495
523
  discount: CartDiscount | null;
496
524
  }
497
- type CartCheckoutInput = Omit<CreateOrderInput, "items" | "discountCode">;
525
+ type CartCheckoutInput = Omit<CreateOrderInput, "items" | "discountCode" | "customerId">;
526
+ /** One persisted cart, automatically scoped to the current shopper session. */
498
527
  declare class CartApi {
499
528
  private client;
500
- private memory;
529
+ private cache;
530
+ private queue;
531
+ private initialized;
501
532
  constructor(client: CaldikHttpClient);
502
- private storageKey;
503
- private read;
533
+ private storage;
534
+ private key;
535
+ private readCache;
536
+ private persistCache;
504
537
  private write;
505
- get(): CartSnapshot;
506
- add(item: ProductCommerceItem): CartSnapshot;
507
- updateQuantity(itemId: string, nextQuantity: number): CartSnapshot;
508
- remove(itemId: string): CartSnapshot;
509
- clear(): CartSnapshot;
510
- private commerceItems;
538
+ private enqueue;
539
+ private options;
540
+ private resolve;
541
+ private reset;
542
+ private mutate;
543
+ private save;
544
+ private quote;
545
+ get(): Promise<CartSnapshot>;
546
+ add(item: ProductCommerceItem): Promise<CartSnapshot>;
547
+ updateQuantity(itemId: string, nextQuantity: number): Promise<CartSnapshot>;
548
+ remove(itemId: string): Promise<CartSnapshot>;
549
+ clear(): Promise<CartSnapshot>;
511
550
  checkDiscount(code: string): Promise<DiscountQuoteResponse>;
512
551
  applyDiscount(code: string): Promise<CartSnapshot>;
513
- removeDiscount(): CartSnapshot;
514
- checkout(input: CartCheckoutInput): Promise<OrderResponse>;
552
+ removeDiscount(): Promise<CartSnapshot>;
553
+ checkout(input?: CartCheckoutInput): Promise<OrderResponse>;
515
554
  }
516
555
  //#endregion
517
556
  //#region src/index.d.ts
@@ -521,6 +560,10 @@ interface CaldikClientOptions {
521
560
  /** Private API key for external server integrations. Never use in browsers. */
522
561
  apiKey?: string;
523
562
  apiUrl?: string;
563
+ /** Incoming storefront request. Create one client per server request to isolate shoppers. */
564
+ request?: Request;
565
+ /** Forward session cookies to the outgoing storefront response in server code. */
566
+ onSetCookie?: (cookie: string) => void | Promise<void>;
524
567
  }
525
568
  declare class CaldikClient {
526
569
  readonly categories: CategoriesApi;
@@ -535,4 +578,4 @@ declare class CaldikClient {
535
578
  constructor(options?: CaldikClientOptions);
536
579
  }
537
580
  //#endregion
538
- export { AppliedDiscount, CaldikClient, CaldikClientOptions, CartApi, CartCheckoutInput, CartDiscount, CartItem, CartSnapshot, CategoriesResponse, Category, CategoryDetails, CategoryInclude, CategoryListOptions, CategoryMetadata, CategoryProduct, CategoryResponse, CheckDiscountInput, CheckoutPayment, CheckoutPaymentMethod, CheckoutPaymentMethodsResponse, CheckoutPaymentProviderId, CheckoutSettings, CheckoutSettingsResponse, CommerceItem, CreateOrderInput, CreateSubscriptionInput, CustomerEmailLoginInput, CustomerLoginResponse, CustomerLoginSettings, CustomerLoginSettingsResponse, CustomerLoginVerifyResponse, CustomerPasswordInput, CustomerPasswordResetInput, CustomerPasswordResetResponse, CustomerPasswordResetVerifyResponse, CustomerPasswordResponse, CustomerProfileResponse, CustomerRegistrationInput, CustomerRegistrationResponse, CustomerSessionResponse, DiscountQuoteResponse, DiscountsApi, ManualCommerceItem, OrderResponse, PaymentProviderId, Product, ProductCommerceItem, ProductCustomField, ProductCustomFieldInputType, ProductCustomFieldOption, ProductMetadata, ProductResponse, ProductTag, ProductVariant, ProductsResponse, Review, ReviewsResponse, Store, StoreResponse, StoreWebhookEvent, StoreWebhookEventName, StorefrontMetadata, StorefrontOrder, SubscriptionPaymentProviderId, SubscriptionResponse, checkoutPaymentProviderIds, paymentProviderIds, storeWebhookEventNames, subscriptionPaymentProviderIds };
581
+ export { AppliedDiscount, CaldikClient, CaldikClientOptions, CartApi, CartCheckoutInput, CartDiscount, CartItem, CartSnapshot, CategoriesResponse, Category, CategoryDetails, CategoryInclude, CategoryListOptions, CategoryMetadata, CategoryProduct, CategoryResponse, CheckDiscountInput, CheckoutPayment, CheckoutPaymentMethod, CheckoutPaymentMethodsResponse, CheckoutPaymentProviderId, CheckoutSettings, CheckoutSettingsResponse, CommerceItem, CreateOrderInput, CreateSubscriptionInput, CustomerEmailLoginInput, CustomerLoginResponse, CustomerLoginSettings, CustomerLoginSettingsResponse, CustomerLoginVerifyResponse, CustomerPasswordInput, CustomerPasswordResetInput, CustomerPasswordResetResponse, CustomerPasswordResetVerifyResponse, CustomerPasswordResponse, CustomerProfileResponse, CustomerRegistrationInput, CustomerRegistrationResponse, CustomerSessionResponse, DiscountQuoteResponse, DiscountsApi, ManualCommerceItem, OrderResponse, PaymentProviderId, Product, ProductCommerceItem, ProductCustomField, ProductCustomFieldInputType, ProductCustomFieldOption, ProductGetOptions, ProductMetadata, ProductResponse, ProductTag, ProductVariant, ProductsResponse, Review, ReviewsResponse, Store, StoreResponse, StoreSocials, StoreWebhookEvent, StoreWebhookEventName, StorefrontMetadata, StorefrontOrder, SubscriptionPaymentProviderId, SubscriptionResponse, checkoutPaymentProviderIds, paymentProviderIds, storeWebhookEventNames, subscriptionPaymentProviderIds };
package/dist/index.mjs CHANGED
@@ -26,6 +26,14 @@ function defaultApiUrl(browser) {
26
26
  //#region src/internal/client.ts
27
27
  const STORE_ID_PATTERN = /^STORE_[a-f0-9]{32}$/i;
28
28
  const STOREFRONT_STORE_ID_ATTRIBUTE = "data-caldik-store-id";
29
+ var CaldikHttpError = class extends Error {
30
+ status;
31
+ constructor(message, status) {
32
+ super(message);
33
+ this.status = status;
34
+ this.name = "CaldikHttpError";
35
+ }
36
+ };
29
37
  function isBrowserRuntime() {
30
38
  return typeof window !== "undefined" && typeof document !== "undefined";
31
39
  }
@@ -48,9 +56,17 @@ var CaldikHttpClient = class {
48
56
  apiKey;
49
57
  apiUrl;
50
58
  storeId;
59
+ cookies = /* @__PURE__ */ new Map();
60
+ onSetCookie;
61
+ authenticationListeners = /* @__PURE__ */ new Set();
51
62
  constructor(options = {}) {
52
63
  this.apiKey = options.apiKey?.trim() || void 0;
53
64
  this.apiUrl = options.apiUrl || defaultApiUrl(isBrowserRuntime());
65
+ this.onSetCookie = options.onSetCookie;
66
+ if (!isBrowserRuntime()) for (const part of (options.request?.headers.get("Cookie") || "").split(";")) {
67
+ const separator = part.indexOf("=");
68
+ if (separator > 0) this.cookies.set(part.slice(0, separator).trim(), part.slice(separator + 1).trim());
69
+ }
54
70
  const configuredStoreId = options.storeId?.trim() || void 0;
55
71
  if (this.apiKey && !this.apiKey.startsWith("sk_")) throw new Error("Caldik external integrations require a private sk_ API key");
56
72
  if (configuredStoreId && !STORE_ID_PATTERN.test(configuredStoreId)) throw new Error("Caldik storeId is invalid");
@@ -60,6 +76,31 @@ var CaldikHttpClient = class {
60
76
  storeContextId() {
61
77
  return this.storeId || boundRuntimeStoreId();
62
78
  }
79
+ hasCartSession() {
80
+ return this.cookies.has(`caldik_cart_session_${this.storeContextId()}`);
81
+ }
82
+ onCustomerAuthentication(listener) {
83
+ this.authenticationListeners.add(listener);
84
+ }
85
+ async customerAuthenticated() {
86
+ await Promise.allSettled([...this.authenticationListeners].map((listener) => listener()));
87
+ }
88
+ async captureCookies(response) {
89
+ for (const cookie of response.headers.getSetCookie()) {
90
+ const [pair = "", ...attributes] = cookie.split(";");
91
+ const separator = pair.indexOf("=");
92
+ if (separator < 1) continue;
93
+ const name = pair.slice(0, separator).trim();
94
+ if (attributes.some((attribute) => {
95
+ const [key, ...value] = attribute.trim().split("=");
96
+ if (key?.toLowerCase() === "max-age") return Number(value.join("=")) <= 0;
97
+ if (key?.toLowerCase() === "expires") return Date.parse(value.join("=")) <= Date.now();
98
+ return false;
99
+ })) this.cookies.delete(name);
100
+ else this.cookies.set(name, pair.slice(separator + 1).trim());
101
+ await this.onSetCookie?.(cookie);
102
+ }
103
+ }
63
104
  baseUrl() {
64
105
  if (isBrowserRuntime()) {
65
106
  const storeId = this.storeId;
@@ -76,8 +117,9 @@ var CaldikHttpClient = class {
76
117
  async request(method, path, input, params, options) {
77
118
  const url = new URL(`${this.baseUrl()}${path}`);
78
119
  if (params) for (const [key, value] of Object.entries(params)) url.searchParams.set(key, String(value));
79
- const headers = {};
120
+ const headers = { ...options?.headers };
80
121
  if (!isBrowserRuntime()) headers["User-Agent"] = "CaldikClient/0.0.2";
122
+ if (!isBrowserRuntime() && options?.access === "public" && this.cookies.size) headers.Cookie = [...this.cookies].map(([name, value]) => `${name}=${value}`).join("; ");
81
123
  if (this.apiKey) headers.Authorization = `Bearer ${this.apiKey}`;
82
124
  else if (options?.access === "public" && (!isBrowserRuntime() || isLocalBrowserRuntime())) headers["X-Caldik-Public-Request"] = "1";
83
125
  if (input !== void 0) headers["Content-Type"] = "application/json";
@@ -89,10 +131,10 @@ var CaldikHttpClient = class {
89
131
  cache: "default",
90
132
  credentials: "include"
91
133
  });
134
+ if (!isBrowserRuntime() && options?.access === "public") await this.captureCookies(response);
92
135
  if (!response.ok) {
93
136
  const error = await response.clone().json().catch(() => null);
94
- const message = typeof error?.message === "string" && error.message ? error.message : `${response.status} ${response.statusText}`.trim();
95
- throw new Error(message);
137
+ throw new CaldikHttpError(typeof error?.message === "string" && error.message ? error.message : `${response.status} ${response.statusText}`.trim(), response.status);
96
138
  }
97
139
  return response.json();
98
140
  }
@@ -131,8 +173,8 @@ var ProductsApi = class {
131
173
  list() {
132
174
  return this.client.get("/products", void 0, { access: "public" });
133
175
  }
134
- get(productIdOrSlug) {
135
- return this.client.get(`/products/${encodeURIComponent(productIdOrSlug)}`, void 0, { access: "public" });
176
+ get(productIdOrSlug, options) {
177
+ return this.client.get(`/products/${encodeURIComponent(productIdOrSlug)}`, options?.category === void 0 ? void 0 : { category: options.category }, { access: "public" });
136
178
  }
137
179
  };
138
180
  //#endregion
@@ -207,7 +249,11 @@ var CustomerApi = class {
207
249
  };
208
250
  let pendingChallengeId;
209
251
  async function login(input, options) {
210
- if (typeof input !== "string" && "password" in input) return client.post("/customers/login/password", input, { access: "public" });
252
+ if (typeof input !== "string" && "password" in input) {
253
+ const response = await client.post("/customers/login/password", input, { access: "public" });
254
+ await client.customerAuthenticated();
255
+ return response;
256
+ }
211
257
  const response = await client.post("/customers/login", typeof input === "string" ? {
212
258
  email: input,
213
259
  ...options
@@ -225,6 +271,7 @@ verify: async (code, challengeId = pendingChallengeId) => {
225
271
  challengeId
226
272
  }, { access: "public" });
227
273
  if (pendingChallengeId === challengeId) pendingChallengeId = void 0;
274
+ await client.customerAuthenticated();
228
275
  return response;
229
276
  } });
230
277
  let pendingRegistrationId;
@@ -243,6 +290,7 @@ verify: async (code, challengeId = pendingRegistrationId) => {
243
290
  challengeId
244
291
  }, { access: "public" });
245
292
  if (pendingRegistrationId === challengeId) pendingRegistrationId = void 0;
293
+ await client.customerAuthenticated();
246
294
  return response;
247
295
  } });
248
296
  }
@@ -279,20 +327,18 @@ var DiscountsApi = class {
279
327
  };
280
328
  //#endregion
281
329
  //#region src/cart.ts
282
- const CART_VERSION = 1;
283
330
  const MAX_QUANTITY = 999999;
284
- function cartItemId(item) {
285
- return item.productVariantId ? `${item.productId}:${item.productVariantId}` : item.productId;
286
- }
287
- function quantity(value, allowZero = false) {
288
- const normalized = value ?? 1;
331
+ function quantity(value = 1, allowZero = false) {
289
332
  const minimum = allowZero ? 0 : 1;
290
- if (!Number.isInteger(normalized) || normalized < minimum || normalized > MAX_QUANTITY) throw new Error(`Cart quantity must be an integer between ${minimum} and ${MAX_QUANTITY}`);
291
- return normalized;
333
+ if (!Number.isInteger(value) || value < minimum || value > MAX_QUANTITY) throw new Error(`Cart quantity must be an integer between ${minimum} and ${MAX_QUANTITY}`);
334
+ return value;
292
335
  }
293
336
  function cloneSnapshot(state) {
294
337
  return {
295
- items: state.items.map((item) => ({ ...item })),
338
+ items: state.items.map((item) => ({
339
+ ...item,
340
+ ...item.customFields ? { customFields: { ...item.customFields } } : {}
341
+ })),
296
342
  discount: state.discount ? { ...state.discount } : null
297
343
  };
298
344
  }
@@ -303,140 +349,267 @@ function isCartItem(value) {
303
349
  }
304
350
  function isCartDiscount(value) {
305
351
  if (!value || typeof value !== "object") return false;
306
- const discount = value;
307
- return typeof discount.code === "string" && typeof discount.percentage === "string" && typeof discount.subtotal === "string" && typeof discount.discountAmount === "string" && typeof discount.total === "string" && typeof discount.currency === "string";
352
+ return [
353
+ "code",
354
+ "percentage",
355
+ "subtotal",
356
+ "discountAmount",
357
+ "total",
358
+ "currency"
359
+ ].every((key) => typeof value[key] === "string");
308
360
  }
361
+ function isReference(value) {
362
+ if (!value || typeof value !== "object") return false;
363
+ const reference = value;
364
+ return typeof reference.id === "string" && /^CART_[a-f0-9]{32}$/.test(reference.id) && (reference.token == null || typeof reference.token === "string");
365
+ }
366
+ function commerceItems(items) {
367
+ return items.map(({ id: _id, ...item }) => item);
368
+ }
369
+ function discountFromQuote(quote) {
370
+ return {
371
+ ...quote.discount,
372
+ subtotal: quote.subtotal,
373
+ discountAmount: quote.discountAmount,
374
+ total: quote.total,
375
+ currency: quote.currency
376
+ };
377
+ }
378
+ /** One persisted cart, automatically scoped to the current shopper session. */
309
379
  var CartApi = class {
310
380
  client;
311
- memory = {
312
- version: CART_VERSION,
313
- items: [],
314
- discount: null
381
+ cache = {
382
+ version: 2,
383
+ reference: null,
384
+ snapshot: {
385
+ items: [],
386
+ discount: null
387
+ }
315
388
  };
389
+ queue = Promise.resolve();
390
+ initialized = false;
316
391
  constructor(client) {
317
392
  this.client = client;
393
+ client.onCustomerAuthentication(() => {
394
+ this.readCache();
395
+ return this.initialized || this.cache.reference || this.cache.snapshot.items.length || client.hasCartSession() ? this.enqueue(async () => {
396
+ await this.resolve();
397
+ }) : Promise.resolve();
398
+ });
399
+ }
400
+ storage() {
401
+ try {
402
+ return typeof window !== "undefined" && typeof document !== "undefined" && typeof localStorage !== "undefined" ? localStorage : null;
403
+ } catch {
404
+ return null;
405
+ }
318
406
  }
319
- storageKey() {
320
- const storeId = this.client.storeContextId();
321
- return storeId ? `caldik:cart:${storeId}` : null;
322
- }
323
- read() {
324
- const key = this.storageKey();
325
- if (key && typeof localStorage !== "undefined") try {
326
- const value = localStorage.getItem(key);
327
- if (value) {
328
- const parsed = JSON.parse(value);
329
- if (parsed.version === CART_VERSION && Array.isArray(parsed.items) && parsed.items.every(isCartItem)) this.memory = {
330
- version: CART_VERSION,
407
+ key() {
408
+ return `caldik:cart:${this.client.storeContextId()}`;
409
+ }
410
+ readCache() {
411
+ const storage = this.storage();
412
+ if (!storage) return;
413
+ try {
414
+ const parsed = JSON.parse(storage.getItem(this.key()) || "null");
415
+ if (parsed?.version === 2) {
416
+ const snapshot = parsed.snapshot;
417
+ if (snapshot && Array.isArray(snapshot.items) && snapshot.items.every(isCartItem) && (parsed.reference === null || isReference(parsed.reference))) this.cache = {
418
+ version: 2,
419
+ reference: parsed.reference,
420
+ snapshot: cloneSnapshot({
421
+ items: snapshot.items,
422
+ discount: isCartDiscount(snapshot.discount) ? snapshot.discount : null
423
+ })
424
+ };
425
+ } else if (parsed?.version === 1 && Array.isArray(parsed.items) && parsed.items.every(isCartItem)) this.cache = {
426
+ version: 2,
427
+ reference: null,
428
+ snapshot: cloneSnapshot({
331
429
  items: parsed.items,
332
430
  discount: isCartDiscount(parsed.discount) ? parsed.discount : null
333
- };
431
+ })
432
+ };
433
+ if (!this.cache.reference) {
434
+ const legacy = JSON.parse(storage.getItem(`caldik:server-cart:${this.client.storeContextId()}`) || "null");
435
+ if (isReference(legacy)) this.cache.reference = legacy;
334
436
  }
335
437
  } catch {}
336
- return this.memory;
337
438
  }
338
- write(state) {
339
- this.memory = {
340
- version: CART_VERSION,
341
- ...cloneSnapshot(state)
342
- };
343
- const key = this.storageKey();
344
- if (key && typeof localStorage !== "undefined") try {
345
- localStorage.setItem(key, JSON.stringify(this.memory));
439
+ persistCache() {
440
+ try {
441
+ const storage = this.storage();
442
+ storage?.setItem(this.key(), JSON.stringify(this.cache));
443
+ storage?.removeItem(`caldik:server-cart:${this.client.storeContextId()}`);
346
444
  } catch {}
347
- return this.get();
445
+ }
446
+ write(cart, token = this.cache.reference?.token, discount = null) {
447
+ this.cache = {
448
+ version: 2,
449
+ reference: {
450
+ id: cart.id,
451
+ token: cart.customerId ? null : token
452
+ },
453
+ snapshot: cloneSnapshot({
454
+ items: cart.items,
455
+ discount
456
+ })
457
+ };
458
+ this.persistCache();
459
+ return cloneSnapshot(this.cache.snapshot);
460
+ }
461
+ enqueue(operation) {
462
+ const run = () => {
463
+ const locks = this.storage() && typeof navigator !== "undefined" ? navigator.locks : void 0;
464
+ return locks ? locks.request(this.key(), operation) : operation();
465
+ };
466
+ const result = this.queue.then(run, run);
467
+ this.queue = result.catch(() => void 0);
468
+ return result;
469
+ }
470
+ options() {
471
+ return {
472
+ access: "public",
473
+ ...this.cache.reference?.token ? { headers: { "X-Caldik-Cart-Token": this.cache.reference.token } } : {}
474
+ };
475
+ }
476
+ async resolve(checkout = false, seed = true) {
477
+ this.readCache();
478
+ const input = this.cache.reference ? { cartId: this.cache.reference.id } : seed && this.cache.snapshot.items.length ? {
479
+ items: commerceItems(this.cache.snapshot.items),
480
+ discountCode: this.cache.snapshot.discount?.code
481
+ } : {};
482
+ const response = await this.client.post("/carts/session", {
483
+ ...input,
484
+ ...checkout ? { checkout: true } : {}
485
+ }, this.options());
486
+ this.initialized = true;
487
+ this.write(response.cart, response.token ?? null);
488
+ if (!checkout && response.cart.status === "checked_out") {
489
+ this.reset();
490
+ return this.resolve(false, false);
491
+ }
492
+ return response.cart;
493
+ }
494
+ reset() {
495
+ this.cache = {
496
+ version: 2,
497
+ reference: null,
498
+ snapshot: {
499
+ items: [],
500
+ discount: null
501
+ }
502
+ };
503
+ this.persistCache();
504
+ }
505
+ async mutate(operation, seed = true) {
506
+ let cart = await this.resolve(false, seed);
507
+ let response;
508
+ try {
509
+ response = await operation(cart);
510
+ } catch (error) {
511
+ if (!(error instanceof CaldikHttpError) || error.status !== 409) throw error;
512
+ const refreshed = await this.resolve();
513
+ if (refreshed.id !== cart.id || refreshed.status !== "active") throw error;
514
+ cart = refreshed;
515
+ response = await operation(cart);
516
+ }
517
+ return this.write(response.cart);
518
+ }
519
+ save(cart, items, discountCode) {
520
+ return this.client.post(`/carts/${cart.id}`, {
521
+ items: commerceItems(items),
522
+ version: cart.version,
523
+ discountCode
524
+ }, this.options());
525
+ }
526
+ quote(cart, code) {
527
+ if (!cart.items.length) throw new Error("Cannot check a discount on an empty cart");
528
+ return this.client.post("/discounts/check", {
529
+ code,
530
+ items: commerceItems(cart.items)
531
+ }, { access: "public" });
348
532
  }
349
533
  get() {
350
- return cloneSnapshot(this.read());
534
+ return this.enqueue(async () => {
535
+ let cart = await this.resolve();
536
+ let discount = null;
537
+ if (cart.discountCode) try {
538
+ discount = discountFromQuote(await this.quote(cart, cart.discountCode));
539
+ } catch (error) {
540
+ if (!(error instanceof CaldikHttpError) || ![
541
+ 400,
542
+ 404,
543
+ 422
544
+ ].includes(error.status)) throw error;
545
+ cart = (await this.save(cart, cart.items, null)).cart;
546
+ }
547
+ return this.write(cart, this.cache.reference?.token, discount);
548
+ });
351
549
  }
352
550
  add(item) {
353
- const productId = item.productId.trim();
354
- const productVariantId = item.productVariantId?.trim() || "";
355
- if (!productId) throw new Error("Cart productId is required");
356
- const state = this.get();
357
- const id = cartItemId({
358
- productId,
359
- productVariantId
551
+ return this.enqueue(async () => {
552
+ if (!item.productId.trim()) throw new Error("Cart productId is required");
553
+ const normalized = {
554
+ ...item,
555
+ productId: item.productId.trim(),
556
+ quantity: quantity(item.quantity)
557
+ };
558
+ return this.mutate((cart) => this.client.post(`/carts/${cart.id}/items`, {
559
+ item: normalized,
560
+ version: cart.version
561
+ }, this.options()));
360
562
  });
361
- const existing = state.items.find((entry) => entry.id === id);
362
- const addedQuantity = quantity(item.quantity);
363
- if (existing) existing.quantity = quantity(existing.quantity + addedQuantity);
364
- else state.items.push({
365
- id,
366
- productId,
367
- ...productVariantId ? { productVariantId } : {},
368
- quantity: addedQuantity
369
- });
370
- state.discount = null;
371
- return this.write(state);
372
563
  }
373
564
  updateQuantity(itemId, nextQuantity) {
374
- const state = this.get();
375
- const normalized = quantity(nextQuantity, true);
376
- const index = state.items.findIndex((item) => item.id === itemId);
377
- if (index === -1) throw new Error("Cart item not found");
378
- if (normalized === 0) state.items.splice(index, 1);
379
- else state.items[index].quantity = normalized;
380
- state.discount = null;
381
- return this.write(state);
565
+ return this.enqueue(async () => {
566
+ const normalized = quantity(nextQuantity, true);
567
+ return this.mutate((cart) => {
568
+ if (!cart.items.some((item) => item.id === itemId)) throw new Error("Cart item not found");
569
+ const items = cart.items.map((item) => item.id === itemId ? {
570
+ ...item,
571
+ quantity: normalized
572
+ } : item).filter((item) => item.quantity > 0);
573
+ return this.save(cart, items, null);
574
+ });
575
+ });
382
576
  }
383
577
  remove(itemId) {
384
- const state = this.get();
385
- const items = state.items.filter((item) => item.id !== itemId);
386
- if (items.length === state.items.length) throw new Error("Cart item not found");
387
- return this.write({
388
- items,
389
- discount: null
390
- });
578
+ return this.updateQuantity(itemId, 0);
391
579
  }
392
580
  clear() {
393
- return this.write({
394
- items: [],
395
- discount: null
581
+ return this.enqueue(() => this.mutate((cart) => this.save(cart, [], null), false));
582
+ }
583
+ checkDiscount(code) {
584
+ return this.enqueue(async () => this.quote(await this.resolve(), code));
585
+ }
586
+ applyDiscount(code) {
587
+ return this.enqueue(async () => {
588
+ let quote;
589
+ const snapshot = await this.mutate(async (cart) => {
590
+ quote = await this.quote(cart, code);
591
+ return this.save(cart, cart.items, quote.discount.code);
592
+ });
593
+ snapshot.discount = discountFromQuote(quote);
594
+ this.cache.snapshot = cloneSnapshot(snapshot);
595
+ this.persistCache();
596
+ return snapshot;
396
597
  });
397
598
  }
398
- commerceItems() {
399
- return this.get().items.map(({ productId, productVariantId, quantity }) => ({
400
- productId,
401
- ...productVariantId ? { productVariantId } : {},
402
- quantity
403
- }));
404
- }
405
- async checkDiscount(code) {
406
- const items = this.commerceItems();
407
- if (items.length === 0) throw new Error("Cannot check a discount on an empty cart");
408
- return this.client.post("/discounts/check", {
409
- code,
410
- items
411
- }, { access: "public" });
412
- }
413
- async applyDiscount(code) {
414
- const quote = await this.checkDiscount(code);
415
- const state = this.get();
416
- state.discount = {
417
- ...quote.discount,
418
- subtotal: quote.subtotal,
419
- discountAmount: quote.discountAmount,
420
- total: quote.total,
421
- currency: quote.currency
422
- };
423
- return this.write(state);
424
- }
425
599
  removeDiscount() {
426
- const state = this.get();
427
- state.discount = null;
428
- return this.write(state);
429
- }
430
- async checkout(input) {
431
- const state = this.get();
432
- if (state.items.length === 0) throw new Error("Cannot checkout an empty cart");
433
- const response = await this.client.post("/checkouts", {
434
- ...input,
435
- items: this.commerceItems(),
436
- ...state.discount ? { discountCode: state.discount.code } : {}
437
- }, { access: "public" });
438
- this.clear();
439
- return response;
600
+ return this.enqueue(() => this.mutate((cart) => this.save(cart, cart.items, null)));
601
+ }
602
+ checkout(input = {}) {
603
+ return this.enqueue(async () => {
604
+ const cart = await this.resolve(true);
605
+ if (!cart.items.length) throw new Error("Cannot checkout an empty cart");
606
+ const response = await this.client.post(`/carts/${cart.id}/checkout`, {
607
+ ...input,
608
+ version: cart.version
609
+ }, this.options());
610
+ this.reset();
611
+ return response;
612
+ });
440
613
  }
441
614
  };
442
615
  //#endregion
@@ -497,7 +670,9 @@ var CaldikClient = class {
497
670
  const client = new CaldikHttpClient({
498
671
  apiKey: options.apiKey,
499
672
  apiUrl: options.apiUrl,
500
- storeId: options.storeId
673
+ storeId: options.storeId,
674
+ request: options.request,
675
+ onSetCookie: options.onSetCookie
501
676
  });
502
677
  this.categories = new CategoriesApi(client);
503
678
  this.products = new ProductsApi(client);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@caldik/client",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "main": "./dist/index.mjs",
6
6
  "types": "./dist/index.d.mts",