@replohq/sdk 1.5.0 → 1.7.0

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.
Files changed (46) hide show
  1. package/_vendor/replo-utils/lib/url.d.ts +5 -1
  2. package/cart/cart-actions.js +2 -1
  3. package/cart/cart-actions.js.map +2 -2
  4. package/cart/cart-provider.d.ts +39 -3
  5. package/cart/cart-provider.js +187 -119
  6. package/cart/cart-provider.js.map +3 -3
  7. package/cart/cart-types.d.ts +15 -0
  8. package/cart/cart-types.js.map +1 -1
  9. package/cart/gateways/cart-gateway.d.ts +7 -0
  10. package/cart/gateways/cart-gateway.js.map +2 -2
  11. package/cart/gateways/cookie-cart-gateway.d.ts +9 -0
  12. package/cart/gateways/cookie-cart-gateway.js +54 -3
  13. package/cart/gateways/cookie-cart-gateway.js.map +2 -2
  14. package/cart/gateways/resolver-cart-gateway.js +4 -0
  15. package/cart/gateways/resolver-cart-gateway.js.map +2 -2
  16. package/cart/gateways/stripe-cart-gateway.d.ts +14 -0
  17. package/cart/gateways/stripe-cart-gateway.js +6 -1
  18. package/cart/gateways/stripe-cart-gateway.js.map +2 -2
  19. package/cart/hooks/use-add-to-cart.d.ts +22 -3
  20. package/cart/hooks/use-add-to-cart.js +8 -1
  21. package/cart/hooks/use-add-to-cart.js.map +2 -2
  22. package/cart/utils/cart-adjustments.d.ts +29 -0
  23. package/cart/utils/cart-adjustments.js +81 -0
  24. package/cart/utils/cart-adjustments.js.map +7 -0
  25. package/cart/utils/cart-error.d.ts +5 -0
  26. package/cart/utils/cart-error.js +20 -0
  27. package/cart/utils/cart-error.js.map +7 -0
  28. package/cart/utils/cart-utils.d.ts +14 -0
  29. package/cart/utils/cart-utils.js +43 -0
  30. package/cart/utils/cart-utils.js.map +2 -2
  31. package/cart/utils/cookie-cart-persistence.d.ts +4 -2
  32. package/cart/utils/cookie-cart-persistence.js +35 -21
  33. package/cart/utils/cookie-cart-persistence.js.map +3 -3
  34. package/gateways/canopy-api.d.ts +25 -0
  35. package/gateways/canopy-api.js +40 -0
  36. package/gateways/canopy-api.js.map +2 -2
  37. package/lib/buildMetadata.js +3 -3
  38. package/loaders/loader-schemas.d.ts +5 -0
  39. package/loaders/loader-schemas.js +2 -0
  40. package/loaders/loader-schemas.js.map +2 -2
  41. package/loaders/rebuy-recommendations-loader.d.ts +1 -0
  42. package/next/config.d.ts +18 -0
  43. package/next/config.js.map +2 -2
  44. package/package.json +4 -4
  45. package/providers/replo-provider.d.ts +19 -0
  46. package/providers/replo-provider.js.map +2 -2
@@ -64,6 +64,10 @@ export declare function fileExtensionFromUrl(url: string): string;
64
64
  * Registration inputs are attacker-typeable, and the pipeline fetches them
65
65
  * server-side — so only plain public https(+http) origins pass: no
66
66
  * credentials, no ports, no IP literals, no localhost/RFC-6762 style names.
67
+ * Upgrades http to https and strips the fragment by default; `preserveProtocol`
68
+ * keeps the requested scheme.
67
69
  */
68
- export declare function sanitizePublicHttpUrl(value: string | null): string | null;
70
+ export declare function sanitizePublicHttpUrl(value: string | null, { preserveProtocol }?: {
71
+ preserveProtocol?: boolean;
72
+ }): string | null;
69
73
  export declare function safeHost(url: string): string | null;
@@ -14,7 +14,8 @@ const SHOPIFY_CART_COOKIE_NAME = "replo_cart_id";
14
14
  async function getOrCreateCartAction() {
15
15
  const existingCart = await getCartAction();
16
16
  if (existingCart) {
17
- return existingCart;
17
+ const cartGateway = await getCartGateway();
18
+ return await cartGateway.reconcileStoredCart(existingCart);
18
19
  }
19
20
  return await createCartAction();
20
21
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../cart/cart-actions.ts"],
4
- "sourcesContent": ["\"use server\";\n\nimport type {\n AddToCartPayload,\n Cart,\n CartLinePayload,\n RemoveFromCartPayload,\n UpdateCartLineItemPayload,\n} from \"./cart-types\";\n\nimport { cookies } from \"next/headers\";\n\nimport { getCheckoutProvider } from \"../lib/integration-utils\";\nimport {\n getCartCookieOptions,\n REPLO_CART_COOKIE_NAME,\n} from \"./gateways/cart-constants\";\nimport { getCartGateway } from \"./gateways/cart-gateway\";\nimport {\n loadCartFromCookie,\n saveCartToCookie,\n} from \"./utils/cookie-cart-persistence\";\n\nconst SHOPIFY_CART_COOKIE_NAME = \"replo_cart_id\";\n\n/**\n * Gets the existing cart or creates a new one if none exists. This is necessary\n * because we may not have cookies in the server context during initial load.\n *\n * See: https://nextjs.org/docs/app/api-reference/functions/cookies#understanding-cookie-behavior-in-server-components\n *\n * @returns The existing cart or a newly created cart\n */\nexport async function getOrCreateCartAction(): Promise<Cart | null> {\n const existingCart = await getCartAction();\n if (existingCart) {\n return existingCart;\n }\n return await createCartAction();\n}\n\n/**\n * Retrieves the current cart from cookies. Returns null if no cart exists or\n * if the cart has expired/been deleted.\n *\n * @returns The current cart or null if none exists\n */\nexport async function getCartAction(): Promise<Cart | null> {\n const checkoutProvider = await getCheckoutProvider();\n\n if (checkoutProvider === \"stripe\") {\n return await loadCartFromCookie(REPLO_CART_COOKIE_NAME);\n }\n\n const cookieStore = await cookies();\n const existingCartId = cookieStore.get(SHOPIFY_CART_COOKIE_NAME)?.value;\n\n if (!existingCartId) {\n return null;\n }\n\n const cartGateway = await getCartGateway();\n const cart = await cartGateway.getCart(existingCartId);\n\n // If we had a cart ID in the cookie but couldn't fetch the cart,\n // it means the cart is invalid/expired - clear the cookie\n if (!cart) {\n try {\n cookieStore.set(SHOPIFY_CART_COOKIE_NAME, \"\", {\n ...getCartCookieOptions(),\n maxAge: 0,\n });\n } catch (error) {\n console.warn(\"Failed to delete cart cookie:\", error);\n }\n }\n\n return cart;\n}\n\n/**\n * Creates a new cart with optional initial line items. The cart ID is stored\n * in a cookie for future retrieval.\n *\n * @param options.lines - Optional initial cart line items\n * @param options.skipStoringCart - If true, skips storing the cart in cookies (used for buy-now flows)\n * @param options.discountCodes - Optional discount codes to apply on cart creation\n * @returns The newly created cart\n */\nexport async function createCartAction(options?: {\n lines?: CartLinePayload[];\n skipStoringCart?: boolean;\n discountCodes?: string[];\n}): Promise<Cart | null> {\n const { lines, skipStoringCart = false, discountCodes } = options ?? {};\n const checkoutProvider = await getCheckoutProvider();\n const cartGateway = await getCartGateway();\n const cart = await cartGateway.createCart(lines, discountCodes);\n\n if (cart && !skipStoringCart) {\n // Set the cart ID in a cookie - this works in Server Action context\n try {\n if (checkoutProvider === \"stripe\") {\n await saveCartToCookie(cart, REPLO_CART_COOKIE_NAME);\n } else {\n const cookieStore = await cookies();\n cookieStore.set(\n SHOPIFY_CART_COOKIE_NAME,\n cart.id,\n getCartCookieOptions(),\n );\n }\n } catch (cookieError) {\n console.warn(\"Failed to set cart cookie:\", cookieError);\n }\n }\n\n return cart;\n}\n\n/**\n * Adds line items to an existing cart.\n *\n * @param lines - Array of cart line items to add\n * @param cartId - The ID of the cart to add items to\n * @returns The updated cart\n */\nexport async function addToCartAction(\n lines: AddToCartPayload[\"lines\"],\n cartId: string,\n): Promise<Cart | null> {\n const cartGateway = await getCartGateway();\n return await cartGateway.addToCart({ lines, cartId });\n}\n\n/**\n * Updates a cart line item (typically quantity).\n *\n * @param line - The cart line item with updated values\n * @param cartId - The ID of the cart containing the line item\n * @returns The updated cart\n */\nexport async function updateCartLineItemAction(\n line: UpdateCartLineItemPayload[\"line\"],\n cartId: string,\n): Promise<Cart | null> {\n const cartGateway = await getCartGateway();\n return await cartGateway.updateCartLineItem({ line, cartId });\n}\n\n/**\n * Removes one or more line items from the cart.\n *\n * @param lineIds - Array of line item IDs to remove\n * @param cartId - The ID of the cart to remove items from\n * @returns The updated cart\n */\nexport async function removeCartLineItemsAction(\n lineIds: RemoveFromCartPayload[\"lineIds\"],\n cartId: string,\n): Promise<Cart | null> {\n const cartGateway = await getCartGateway();\n return await cartGateway.removeCartLineItems({ lineIds, cartId });\n}\n\n/**\n * Updates discount codes for a cart.\n *\n * @param discountCodes - Array of discount code strings to apply\n * @param cartId - The ID of the cart to update\n * @returns The updated cart\n */\nexport async function updateCartDiscountCodesAction(\n discountCodes: string[],\n cartId: string,\n): Promise<Cart | null> {\n const cartGateway = await getCartGateway();\n return await cartGateway.updateCartDiscountCodes({ discountCodes, cartId });\n}\n"],
5
- "mappings": ";AAUA,SAAS,eAAe;AAExB,SAAS,2BAA2B;AACpC;AAAA,EACE;AAAA,EACA;AAAA,OACK;AACP,SAAS,sBAAsB;AAC/B;AAAA,EACE;AAAA,EACA;AAAA,OACK;AAEP,MAAM,2BAA2B;AAUjC,eAAsB,wBAA8C;AAClE,QAAM,eAAe,MAAM,cAAc;AACzC,MAAI,cAAc;AAChB,WAAO;AAAA,EACT;AACA,SAAO,MAAM,iBAAiB;AAChC;AAQA,eAAsB,gBAAsC;AAC1D,QAAM,mBAAmB,MAAM,oBAAoB;AAEnD,MAAI,qBAAqB,UAAU;AACjC,WAAO,MAAM,mBAAmB,sBAAsB;AAAA,EACxD;AAEA,QAAM,cAAc,MAAM,QAAQ;AAClC,QAAM,iBAAiB,YAAY,IAAI,wBAAwB,GAAG;AAElE,MAAI,CAAC,gBAAgB;AACnB,WAAO;AAAA,EACT;AAEA,QAAM,cAAc,MAAM,eAAe;AACzC,QAAM,OAAO,MAAM,YAAY,QAAQ,cAAc;AAIrD,MAAI,CAAC,MAAM;AACT,QAAI;AACF,kBAAY,IAAI,0BAA0B,IAAI;AAAA,QAC5C,GAAG,qBAAqB;AAAA,QACxB,QAAQ;AAAA,MACV,CAAC;AAAA,IACH,SAAS,OAAO;AACd,cAAQ,KAAK,iCAAiC,KAAK;AAAA,IACrD;AAAA,EACF;AAEA,SAAO;AACT;AAWA,eAAsB,iBAAiB,SAId;AACvB,QAAM,EAAE,OAAO,kBAAkB,OAAO,cAAc,IAAI,WAAW,CAAC;AACtE,QAAM,mBAAmB,MAAM,oBAAoB;AACnD,QAAM,cAAc,MAAM,eAAe;AACzC,QAAM,OAAO,MAAM,YAAY,WAAW,OAAO,aAAa;AAE9D,MAAI,QAAQ,CAAC,iBAAiB;AAE5B,QAAI;AACF,UAAI,qBAAqB,UAAU;AACjC,cAAM,iBAAiB,MAAM,sBAAsB;AAAA,MACrD,OAAO;AACL,cAAM,cAAc,MAAM,QAAQ;AAClC,oBAAY;AAAA,UACV;AAAA,UACA,KAAK;AAAA,UACL,qBAAqB;AAAA,QACvB;AAAA,MACF;AAAA,IACF,SAAS,aAAa;AACpB,cAAQ,KAAK,8BAA8B,WAAW;AAAA,IACxD;AAAA,EACF;AAEA,SAAO;AACT;AASA,eAAsB,gBACpB,OACA,QACsB;AACtB,QAAM,cAAc,MAAM,eAAe;AACzC,SAAO,MAAM,YAAY,UAAU,EAAE,OAAO,OAAO,CAAC;AACtD;AASA,eAAsB,yBACpB,MACA,QACsB;AACtB,QAAM,cAAc,MAAM,eAAe;AACzC,SAAO,MAAM,YAAY,mBAAmB,EAAE,MAAM,OAAO,CAAC;AAC9D;AASA,eAAsB,0BACpB,SACA,QACsB;AACtB,QAAM,cAAc,MAAM,eAAe;AACzC,SAAO,MAAM,YAAY,oBAAoB,EAAE,SAAS,OAAO,CAAC;AAClE;AASA,eAAsB,8BACpB,eACA,QACsB;AACtB,QAAM,cAAc,MAAM,eAAe;AACzC,SAAO,MAAM,YAAY,wBAAwB,EAAE,eAAe,OAAO,CAAC;AAC5E;",
4
+ "sourcesContent": ["\"use server\";\n\nimport type {\n AddToCartPayload,\n Cart,\n CartLinePayload,\n RemoveFromCartPayload,\n UpdateCartLineItemPayload,\n} from \"./cart-types\";\n\nimport { cookies } from \"next/headers\";\n\nimport { getCheckoutProvider } from \"../lib/integration-utils\";\nimport {\n getCartCookieOptions,\n REPLO_CART_COOKIE_NAME,\n} from \"./gateways/cart-constants\";\nimport { getCartGateway } from \"./gateways/cart-gateway\";\nimport {\n loadCartFromCookie,\n saveCartToCookie,\n} from \"./utils/cookie-cart-persistence\";\n\nconst SHOPIFY_CART_COOKIE_NAME = \"replo_cart_id\";\n\n/**\n * Gets the existing cart or creates a new one if none exists. This is necessary\n * because we may not have cookies in the server context during initial load.\n *\n * See: https://nextjs.org/docs/app/api-reference/functions/cookies#understanding-cookie-behavior-in-server-components\n *\n * @returns The existing cart or a newly created cart\n */\nexport async function getOrCreateCartAction(): Promise<Cart | null> {\n const existingCart = await getCartAction();\n if (existingCart) {\n // The cookie cart snapshots catalog facts at add-to-cart time; a cart\n // that outlives a price change or a product removal corrects itself here,\n // on the mount round-trip, instead of lying until checkout.\n const cartGateway = await getCartGateway();\n return await cartGateway.reconcileStoredCart(existingCart);\n }\n return await createCartAction();\n}\n\n/**\n * Retrieves the current cart from cookies. Returns null if no cart exists or\n * if the cart has expired/been deleted.\n *\n * @returns The current cart or null if none exists\n */\nexport async function getCartAction(): Promise<Cart | null> {\n const checkoutProvider = await getCheckoutProvider();\n\n if (checkoutProvider === \"stripe\") {\n return await loadCartFromCookie(REPLO_CART_COOKIE_NAME);\n }\n\n const cookieStore = await cookies();\n const existingCartId = cookieStore.get(SHOPIFY_CART_COOKIE_NAME)?.value;\n\n if (!existingCartId) {\n return null;\n }\n\n const cartGateway = await getCartGateway();\n const cart = await cartGateway.getCart(existingCartId);\n\n // If we had a cart ID in the cookie but couldn't fetch the cart,\n // it means the cart is invalid/expired - clear the cookie\n if (!cart) {\n try {\n cookieStore.set(SHOPIFY_CART_COOKIE_NAME, \"\", {\n ...getCartCookieOptions(),\n maxAge: 0,\n });\n } catch (error) {\n console.warn(\"Failed to delete cart cookie:\", error);\n }\n }\n\n return cart;\n}\n\n/**\n * Creates a new cart with optional initial line items. The cart ID is stored\n * in a cookie for future retrieval.\n *\n * @param options.lines - Optional initial cart line items\n * @param options.skipStoringCart - If true, skips storing the cart in cookies (used for buy-now flows)\n * @param options.discountCodes - Optional discount codes to apply on cart creation\n * @returns The newly created cart\n */\nexport async function createCartAction(options?: {\n lines?: CartLinePayload[];\n skipStoringCart?: boolean;\n discountCodes?: string[];\n}): Promise<Cart | null> {\n const { lines, skipStoringCart = false, discountCodes } = options ?? {};\n const checkoutProvider = await getCheckoutProvider();\n const cartGateway = await getCartGateway();\n const cart = await cartGateway.createCart(lines, discountCodes);\n\n if (cart && !skipStoringCart) {\n // Set the cart ID in a cookie - this works in Server Action context\n try {\n if (checkoutProvider === \"stripe\") {\n await saveCartToCookie(cart, REPLO_CART_COOKIE_NAME);\n } else {\n const cookieStore = await cookies();\n cookieStore.set(\n SHOPIFY_CART_COOKIE_NAME,\n cart.id,\n getCartCookieOptions(),\n );\n }\n } catch (cookieError) {\n console.warn(\"Failed to set cart cookie:\", cookieError);\n }\n }\n\n return cart;\n}\n\n/**\n * Adds line items to an existing cart.\n *\n * @param lines - Array of cart line items to add\n * @param cartId - The ID of the cart to add items to\n * @returns The updated cart\n */\nexport async function addToCartAction(\n lines: AddToCartPayload[\"lines\"],\n cartId: string,\n): Promise<Cart | null> {\n const cartGateway = await getCartGateway();\n return await cartGateway.addToCart({ lines, cartId });\n}\n\n/**\n * Updates a cart line item (typically quantity).\n *\n * @param line - The cart line item with updated values\n * @param cartId - The ID of the cart containing the line item\n * @returns The updated cart\n */\nexport async function updateCartLineItemAction(\n line: UpdateCartLineItemPayload[\"line\"],\n cartId: string,\n): Promise<Cart | null> {\n const cartGateway = await getCartGateway();\n return await cartGateway.updateCartLineItem({ line, cartId });\n}\n\n/**\n * Removes one or more line items from the cart.\n *\n * @param lineIds - Array of line item IDs to remove\n * @param cartId - The ID of the cart to remove items from\n * @returns The updated cart\n */\nexport async function removeCartLineItemsAction(\n lineIds: RemoveFromCartPayload[\"lineIds\"],\n cartId: string,\n): Promise<Cart | null> {\n const cartGateway = await getCartGateway();\n return await cartGateway.removeCartLineItems({ lineIds, cartId });\n}\n\n/**\n * Updates discount codes for a cart.\n *\n * @param discountCodes - Array of discount code strings to apply\n * @param cartId - The ID of the cart to update\n * @returns The updated cart\n */\nexport async function updateCartDiscountCodesAction(\n discountCodes: string[],\n cartId: string,\n): Promise<Cart | null> {\n const cartGateway = await getCartGateway();\n return await cartGateway.updateCartDiscountCodes({ discountCodes, cartId });\n}\n"],
5
+ "mappings": ";AAUA,SAAS,eAAe;AAExB,SAAS,2BAA2B;AACpC;AAAA,EACE;AAAA,EACA;AAAA,OACK;AACP,SAAS,sBAAsB;AAC/B;AAAA,EACE;AAAA,EACA;AAAA,OACK;AAEP,MAAM,2BAA2B;AAUjC,eAAsB,wBAA8C;AAClE,QAAM,eAAe,MAAM,cAAc;AACzC,MAAI,cAAc;AAIhB,UAAM,cAAc,MAAM,eAAe;AACzC,WAAO,MAAM,YAAY,oBAAoB,YAAY;AAAA,EAC3D;AACA,SAAO,MAAM,iBAAiB;AAChC;AAQA,eAAsB,gBAAsC;AAC1D,QAAM,mBAAmB,MAAM,oBAAoB;AAEnD,MAAI,qBAAqB,UAAU;AACjC,WAAO,MAAM,mBAAmB,sBAAsB;AAAA,EACxD;AAEA,QAAM,cAAc,MAAM,QAAQ;AAClC,QAAM,iBAAiB,YAAY,IAAI,wBAAwB,GAAG;AAElE,MAAI,CAAC,gBAAgB;AACnB,WAAO;AAAA,EACT;AAEA,QAAM,cAAc,MAAM,eAAe;AACzC,QAAM,OAAO,MAAM,YAAY,QAAQ,cAAc;AAIrD,MAAI,CAAC,MAAM;AACT,QAAI;AACF,kBAAY,IAAI,0BAA0B,IAAI;AAAA,QAC5C,GAAG,qBAAqB;AAAA,QACxB,QAAQ;AAAA,MACV,CAAC;AAAA,IACH,SAAS,OAAO;AACd,cAAQ,KAAK,iCAAiC,KAAK;AAAA,IACrD;AAAA,EACF;AAEA,SAAO;AACT;AAWA,eAAsB,iBAAiB,SAId;AACvB,QAAM,EAAE,OAAO,kBAAkB,OAAO,cAAc,IAAI,WAAW,CAAC;AACtE,QAAM,mBAAmB,MAAM,oBAAoB;AACnD,QAAM,cAAc,MAAM,eAAe;AACzC,QAAM,OAAO,MAAM,YAAY,WAAW,OAAO,aAAa;AAE9D,MAAI,QAAQ,CAAC,iBAAiB;AAE5B,QAAI;AACF,UAAI,qBAAqB,UAAU;AACjC,cAAM,iBAAiB,MAAM,sBAAsB;AAAA,MACrD,OAAO;AACL,cAAM,cAAc,MAAM,QAAQ;AAClC,oBAAY;AAAA,UACV;AAAA,UACA,KAAK;AAAA,UACL,qBAAqB;AAAA,QACvB;AAAA,MACF;AAAA,IACF,SAAS,aAAa;AACpB,cAAQ,KAAK,8BAA8B,WAAW;AAAA,IACxD;AAAA,EACF;AAEA,SAAO;AACT;AASA,eAAsB,gBACpB,OACA,QACsB;AACtB,QAAM,cAAc,MAAM,eAAe;AACzC,SAAO,MAAM,YAAY,UAAU,EAAE,OAAO,OAAO,CAAC;AACtD;AASA,eAAsB,yBACpB,MACA,QACsB;AACtB,QAAM,cAAc,MAAM,eAAe;AACzC,SAAO,MAAM,YAAY,mBAAmB,EAAE,MAAM,OAAO,CAAC;AAC9D;AASA,eAAsB,0BACpB,SACA,QACsB;AACtB,QAAM,cAAc,MAAM,eAAe;AACzC,SAAO,MAAM,YAAY,oBAAoB,EAAE,SAAS,OAAO,CAAC;AAClE;AASA,eAAsB,8BACpB,eACA,QACsB;AACtB,QAAM,cAAc,MAAM,eAAe;AACzC,SAAO,MAAM,YAAY,wBAAwB,EAAE,eAAe,OAAO,CAAC;AAC5E;",
6
6
  "names": []
7
7
  }
@@ -1,5 +1,16 @@
1
- import type { Cart, CartLine, CartLinePayload } from "./cart-types";
1
+ import type { Cart, CartLine, CartLineAdjustment, CartLinePayload } from "./cart-types";
2
2
  import React from "react";
3
+ export type CartOperation = "add" | "update" | "remove" | "discount";
4
+ /**
5
+ * The most recent cart change that could not be saved. `message` is written
6
+ * for the shopper, so a cart UI can render it as-is next to a retry.
7
+ */
8
+ export interface CartError {
9
+ /** Which cart change failed */
10
+ operation: CartOperation;
11
+ /** Shopper-facing explanation with a retry prompt */
12
+ message: string;
13
+ }
3
14
  /**
4
15
  * Internal cart context type with full functionality.
5
16
  * This is for internal use only - external code should use the limited CartContextType.
@@ -16,13 +27,19 @@ export interface CartContextTypeInternal {
16
27
  /** Currency code for the cart */
17
28
  currencyCode: string;
18
29
  /** Function to add items to cart */
19
- addToCart: (lines: CartLinePayload[], openCartAfterAdd?: boolean) => Promise<void>;
30
+ addToCart: (lines: CartLinePayload[], openCartAfterAdd?: boolean) => Promise<{
31
+ adjustments: CartLineAdjustment[];
32
+ }>;
20
33
  /** Function to update cart item quantity */
21
- updateCartItem: (line: CartLinePayload) => Promise<void>;
34
+ updateCartItem: (line: CartLinePayload) => Promise<{
35
+ adjustments: CartLineAdjustment[];
36
+ }>;
22
37
  /** Function to remove item from cart */
23
38
  removeCartItem: (lineId: string) => Promise<void>;
24
39
  /** Function to update discount codes on cart */
25
40
  updateDiscountCodes: (discountCodes: string[]) => Promise<void>;
41
+ /** Re-read the persisted cart and adopt it if no mutations are in flight */
42
+ refreshCart: () => Promise<void>;
26
43
  /** Function to create cart and checkout immediately */
27
44
  buyNow: (lines: CartLinePayload[], discountCodes?: string[]) => Promise<Cart | null>;
28
45
  /** URL to redirect to checkout */
@@ -33,6 +50,10 @@ export interface CartContextTypeInternal {
33
50
  openCart: () => void;
34
51
  /** Function to close the cart UI */
35
52
  closeCart: () => void;
53
+ /** The last cart change that failed to save; cleared when the next change starts */
54
+ error: CartError | null;
55
+ /** Function to dismiss the current error */
56
+ clearError: () => void;
36
57
  }
37
58
  /**
38
59
  * Public cart context type for LLM use.
@@ -50,6 +71,21 @@ export interface CartContextType {
50
71
  closeCart: () => void;
51
72
  /** Function to update discount codes on cart */
52
73
  updateDiscountCodes: (discountCodes: string[]) => Promise<void>;
74
+ /**
75
+ * Re-read the persisted cart and adopt it if no mutations are in flight.
76
+ * Call after something outside the provider rewrote the cart — e.g. the
77
+ * checkout route clamped it and bounced back with `?checkout=adjusted`.
78
+ */
79
+ refreshCart: () => Promise<void>;
80
+ /**
81
+ * The last cart change that could not be saved, with a shopper-ready
82
+ * message. Set when an add, quantity change, removal, or discount fails;
83
+ * cleared when the next change starts or via `clearError`. Render it in the
84
+ * cart UI so the shopper knows to retry.
85
+ */
86
+ error: CartError | null;
87
+ /** Function to dismiss the current error */
88
+ clearError: () => void;
53
89
  }
54
90
  /**
55
91
  * Provider component that manages global cart state and operations. Handles both
@@ -3,7 +3,6 @@ import { jsx } from "react/jsx-runtime";
3
3
  import React from "react";
4
4
  import { v4 as uuidv4 } from "uuid";
5
5
  import { useAnalyticsOptional } from "../analytics/analytics-provider";
6
- import { CanopyError } from "../lib/canopy-error";
7
6
  import {
8
7
  addToCartAction,
9
8
  createCartAction,
@@ -13,14 +12,17 @@ import {
13
12
  updateCartDiscountCodesAction,
14
13
  updateCartLineItemAction
15
14
  } from "./cart-actions";
15
+ import {
16
+ deriveAddToCartAdjustments,
17
+ deriveUpdateCartAdjustments
18
+ } from "./utils/cart-adjustments";
16
19
  import {
17
20
  buildCartLineAnalyticsData,
18
21
  buildCartViewAnalyticsData
19
22
  } from "./utils/cart-analytics";
23
+ import { CartContextError, toCartError } from "./utils/cart-error";
20
24
  import { recalculateCartCost } from "./utils/cart-utils";
21
25
  import { createOptimisticSellingPlanAllocation } from "./utils/variant-to-cart-line";
22
- class CartContextError extends CanopyError {
23
- }
24
26
  let cartCreationPromise = null;
25
27
  function getOrCreateSharedCart() {
26
28
  cartCreationPromise ??= getOrCreateCartAction().then((cart) => {
@@ -96,7 +98,19 @@ function CartProvider({
96
98
  []
97
99
  );
98
100
  const [isCartOpen, setIsCartOpen] = React.useState(false);
101
+ const [error, setError] = React.useState(null);
102
+ const clearError = React.useCallback(() => setError(null), []);
99
103
  const isMounted = React.useRef(true);
104
+ const runWithErrorState = async (operation, mutate) => {
105
+ setError(null);
106
+ try {
107
+ return await mutate();
108
+ } catch (caught) {
109
+ console.error(`[Replo] Cart ${operation} failed:`, caught);
110
+ setError(toCartError(operation, caught));
111
+ throw caught;
112
+ }
113
+ };
100
114
  const analytics = useAnalyticsOptional();
101
115
  const serverCart = React.useRef(cart);
102
116
  const needsRefresh = React.useRef(false);
@@ -128,11 +142,11 @@ function CartProvider({
128
142
  message: "Your cart could not be saved. Please try again."
129
143
  });
130
144
  serverCart.current = savedCart;
131
- } catch (error) {
145
+ } catch (error2) {
132
146
  const restoredCart = await getCartAction().catch(() => null);
133
147
  needsRefresh.current = !restoredCart;
134
148
  if (restoredCart) serverCart.current = restoredCart;
135
- throw error;
149
+ throw error2;
136
150
  }
137
151
  });
138
152
  mutationQueue.current = request.catch(() => {
@@ -171,9 +185,9 @@ function CartProvider({
171
185
  }, [updateCartData]);
172
186
  React.useEffect(() => {
173
187
  isMounted.current = true;
174
- void initializeCart().catch((error) => {
188
+ void initializeCart().catch((error2) => {
175
189
  if (isMounted.current) {
176
- console.error("[Replo] Error getting or creating cart:", error);
190
+ console.error("[Replo] Error getting or creating cart:", error2);
177
191
  }
178
192
  });
179
193
  return () => {
@@ -208,130 +222,178 @@ function CartProvider({
208
222
  const closeCart = React.useCallback(() => {
209
223
  setIsCartOpen(false);
210
224
  }, []);
225
+ const refreshCart = React.useCallback(async () => {
226
+ const refreshedCart = await getCartAction();
227
+ if (!isMounted.current || !refreshedCart || pendingMutations.current !== 0) {
228
+ return;
229
+ }
230
+ serverCart.current = refreshedCart;
231
+ updateCartData(refreshedCart);
232
+ }, [updateCartData]);
211
233
  const reportedLineQuantities = React.useRef(/* @__PURE__ */ new Map());
212
- const addToCart = async (lines, openCartAfterAdd = true) => {
213
- const readyCart = cartData ?? serverCart.current ?? await initializeCart();
214
- reportedLineQuantities.current.clear();
215
- const additions = lines.map((line) => {
216
- const lineId = `temp-${uuidv4()}`;
217
- optimisticLines.current.set(lineId, line);
218
- return { line, lineId };
219
- });
220
- const applyLines = (cart2) => {
221
- let updatedCart2 = { ...cart2 };
222
- for (const { line, lineId } of additions) {
223
- updatedCart2 = addLineToCart({ cart: updatedCart2, line, lineId });
234
+ const addToCart = (lines, openCartAfterAdd = true) => {
235
+ return runWithErrorState("add", async () => {
236
+ const readyCart = cartData ?? serverCart.current ?? await initializeCart().catch((caught) => {
237
+ if (openCartAfterAdd) setIsCartOpen(true);
238
+ throw caught;
239
+ });
240
+ reportedLineQuantities.current.clear();
241
+ const additions = lines.map((line) => {
242
+ const lineId = `temp-${uuidv4()}`;
243
+ optimisticLines.current.set(lineId, line);
244
+ return { line, lineId };
245
+ });
246
+ const applyLines = (cart2) => {
247
+ let updatedCart2 = { ...cart2 };
248
+ for (const { line, lineId } of additions) {
249
+ updatedCart2 = addLineToCart({ cart: updatedCart2, line, lineId });
250
+ }
251
+ return updatedCart2;
252
+ };
253
+ const updatedCart = updateCartData((currentCart) => {
254
+ return applyLines(currentCart ?? readyCart);
255
+ });
256
+ if (analytics) {
257
+ for (const line of lines) {
258
+ if (!line.merchandise) {
259
+ continue;
260
+ }
261
+ void analytics.addToCart({
262
+ data: buildCartLineAnalyticsData({
263
+ merchandise: line.merchandise,
264
+ quantity: line.quantity,
265
+ currencyCode: readyCart.cost.currencyCode
266
+ })
267
+ });
268
+ }
224
269
  }
225
- return updatedCart2;
226
- };
227
- const updatedCart = updateCartData((currentCart) => {
228
- return applyLines(currentCart ?? readyCart);
270
+ if (openCartAfterAdd) {
271
+ setIsCartOpen(true);
272
+ emitCartView(updatedCart);
273
+ }
274
+ const adjustments = [];
275
+ await enqueueMutation(async (currentCart) => {
276
+ const savedCart = await addToCartAction(lines, currentCart.id);
277
+ if (savedCart) {
278
+ adjustments.push(
279
+ ...deriveAddToCartAdjustments({
280
+ cartBeforeAdd: currentCart,
281
+ requestedLines: lines,
282
+ savedCart
283
+ })
284
+ );
285
+ }
286
+ return savedCart;
287
+ });
288
+ return { adjustments };
229
289
  });
230
- if (analytics) {
231
- for (const line of lines) {
232
- if (!line.merchandise) {
233
- continue;
290
+ };
291
+ const updateCartItem = (line) => {
292
+ return runWithErrorState("update", async () => {
293
+ if (!cartData) {
294
+ console.warn(
295
+ "[Replo] Cannot update cart item: no cart exists. Reach out to support@replo.app if this persists."
296
+ );
297
+ return { adjustments: [] };
298
+ }
299
+ const currentLine = cartData.lines.find((item) => item.id === line.id);
300
+ const merchandise = currentLine?.merchandise ?? line.merchandise;
301
+ const quantityDelta = line.quantity - (reportedLineQuantities.current.get(line.id) ?? currentLine?.quantity ?? 0);
302
+ reportedLineQuantities.current.set(line.id, line.quantity);
303
+ if (analytics && merchandise && quantityDelta !== 0) {
304
+ const data = buildCartLineAnalyticsData({
305
+ merchandise,
306
+ quantity: Math.abs(quantityDelta),
307
+ currencyCode: cartData.cost.currencyCode
308
+ });
309
+ void (quantityDelta > 0 ? analytics.addToCart({ data }) : analytics.removeFromCart({ data }));
310
+ }
311
+ updateCartData((currentCart) => {
312
+ if (!currentCart) {
313
+ return null;
314
+ }
315
+ return updateLineInCart(currentCart, line);
316
+ });
317
+ const adjustments = [];
318
+ await enqueueMutation(async (currentCart) => {
319
+ const savedLine = findLine({
320
+ currentCart,
321
+ lineId: line.id,
322
+ originalLine: currentLine
323
+ });
324
+ if (!savedLine) {
325
+ return currentCart;
326
+ }
327
+ const savedCart = await updateCartLineItemAction(
328
+ { ...line, id: savedLine.id },
329
+ currentCart.id
330
+ );
331
+ if (savedCart) {
332
+ adjustments.push(
333
+ ...deriveUpdateCartAdjustments({
334
+ cartBeforeUpdate: currentCart,
335
+ editedLine: savedLine,
336
+ requestedQuantity: line.quantity,
337
+ requestedSellingPlanId: line.sellingPlanId,
338
+ savedCart
339
+ })
340
+ );
234
341
  }
235
- void analytics.addToCart({
342
+ return savedCart;
343
+ });
344
+ return { adjustments };
345
+ });
346
+ };
347
+ const removeCartItem = (lineId) => {
348
+ return runWithErrorState("remove", async () => {
349
+ if (!cartData) {
350
+ console.warn(
351
+ "[Replo] Cannot remove cart item: no cart exists. Reach out to support@replo.app if this persists."
352
+ );
353
+ return;
354
+ }
355
+ const itemToRemove = cartData.lines.find((item) => item.id === lineId);
356
+ reportedLineQuantities.current.delete(lineId);
357
+ if (analytics && itemToRemove && itemToRemove.merchandise) {
358
+ void analytics.removeFromCart({
236
359
  data: buildCartLineAnalyticsData({
237
- merchandise: line.merchandise,
238
- quantity: line.quantity,
239
- currencyCode: readyCart.cost.currencyCode
360
+ merchandise: itemToRemove.merchandise,
361
+ quantity: itemToRemove.quantity,
362
+ currencyCode: cartData.cost.currencyCode
240
363
  })
241
364
  });
242
365
  }
243
- }
244
- if (openCartAfterAdd) {
245
- setIsCartOpen(true);
246
- emitCartView(updatedCart);
247
- }
248
- await enqueueMutation((currentCart) => {
249
- return addToCartAction(lines, currentCart.id);
250
- });
251
- };
252
- const updateCartItem = async (line) => {
253
- if (!cartData) {
254
- console.warn(
255
- "[Replo] Cannot update cart item: no cart exists. Reach out to support@replo.app if this persists."
256
- );
257
- return;
258
- }
259
- const currentLine = cartData.lines.find((item) => item.id === line.id);
260
- const merchandise = currentLine?.merchandise ?? line.merchandise;
261
- const quantityDelta = line.quantity - (reportedLineQuantities.current.get(line.id) ?? currentLine?.quantity ?? 0);
262
- reportedLineQuantities.current.set(line.id, line.quantity);
263
- if (analytics && merchandise && quantityDelta !== 0) {
264
- const data = buildCartLineAnalyticsData({
265
- merchandise,
266
- quantity: Math.abs(quantityDelta),
267
- currencyCode: cartData.cost.currencyCode
366
+ updateCartData((currentCart) => {
367
+ if (!currentCart) {
368
+ return null;
369
+ }
370
+ return removeLineFromCart(currentCart, lineId);
268
371
  });
269
- void (quantityDelta > 0 ? analytics.addToCart({ data }) : analytics.removeFromCart({ data }));
270
- }
271
- updateCartData((currentCart) => {
272
- if (!currentCart) {
273
- return null;
274
- }
275
- return updateLineInCart(currentCart, line);
276
- });
277
- await enqueueMutation(async (currentCart) => {
278
- const savedLine = findLine({
279
- currentCart,
280
- lineId: line.id,
281
- originalLine: currentLine
372
+ await enqueueMutation(async (currentCart) => {
373
+ const savedLine = findLine({
374
+ currentCart,
375
+ lineId,
376
+ originalLine: itemToRemove
377
+ });
378
+ return savedLine ? await removeCartLineItemsAction([savedLine.id], currentCart.id) : currentCart;
282
379
  });
283
- return savedLine ? await updateCartLineItemAction(
284
- { ...line, id: savedLine.id },
285
- currentCart.id
286
- ) : currentCart;
287
380
  });
288
381
  };
289
- const removeCartItem = async (lineId) => {
290
- if (!cartData) {
291
- console.warn(
292
- "[Replo] Cannot remove cart item: no cart exists. Reach out to support@replo.app if this persists."
293
- );
294
- return;
295
- }
296
- const itemToRemove = cartData.lines.find((item) => item.id === lineId);
297
- reportedLineQuantities.current.delete(lineId);
298
- if (analytics && itemToRemove && itemToRemove.merchandise) {
299
- void analytics.removeFromCart({
300
- data: buildCartLineAnalyticsData({
301
- merchandise: itemToRemove.merchandise,
302
- quantity: itemToRemove.quantity,
303
- currencyCode: cartData.cost.currencyCode
304
- })
382
+ const updateDiscountCodes = (discountCodes) => {
383
+ return runWithErrorState("discount", async () => {
384
+ const readyCart = cartData ?? serverCart.current ?? await initializeCart();
385
+ updateCartData((currentCart) => {
386
+ return {
387
+ ...currentCart ?? readyCart,
388
+ discountCodes: discountCodes.map((code) => ({
389
+ code,
390
+ applicable: true
391
+ }))
392
+ };
305
393
  });
306
- }
307
- updateCartData((currentCart) => {
308
- if (!currentCart) {
309
- return null;
310
- }
311
- return removeLineFromCart(currentCart, lineId);
312
- });
313
- await enqueueMutation(async (currentCart) => {
314
- const savedLine = findLine({
315
- currentCart,
316
- lineId,
317
- originalLine: itemToRemove
394
+ await enqueueMutation((currentCart) => {
395
+ return updateCartDiscountCodesAction(discountCodes, currentCart.id);
318
396
  });
319
- return savedLine ? await removeCartLineItemsAction([savedLine.id], currentCart.id) : currentCart;
320
- });
321
- };
322
- const updateDiscountCodes = async (discountCodes) => {
323
- const readyCart = cartData ?? serverCart.current ?? await initializeCart();
324
- updateCartData((currentCart) => {
325
- return {
326
- ...currentCart ?? readyCart,
327
- discountCodes: discountCodes.map((code) => ({
328
- code,
329
- applicable: true
330
- }))
331
- };
332
- });
333
- await enqueueMutation((currentCart) => {
334
- return updateCartDiscountCodesAction(discountCodes, currentCart.id);
335
397
  });
336
398
  };
337
399
  const buyNow = async (lines, discountCodes) => {
@@ -363,11 +425,14 @@ function CartProvider({
363
425
  updateCartItem,
364
426
  removeCartItem,
365
427
  updateDiscountCodes,
428
+ refreshCart,
366
429
  buyNow,
367
430
  checkoutUrl,
368
431
  isCartOpen,
369
432
  openCart,
370
- closeCart
433
+ closeCart,
434
+ error,
435
+ clearError
371
436
  },
372
437
  children
373
438
  }
@@ -394,7 +459,10 @@ function useCart() {
394
459
  isCartOpen: context.isCartOpen,
395
460
  openCart: context.openCart,
396
461
  closeCart: context.closeCart,
397
- updateDiscountCodes: context.updateDiscountCodes
462
+ updateDiscountCodes: context.updateDiscountCodes,
463
+ refreshCart: context.refreshCart,
464
+ error: context.error,
465
+ clearError: context.clearError
398
466
  };
399
467
  }
400
468
  export {