@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.
- package/_vendor/replo-utils/lib/url.d.ts +5 -1
- package/cart/cart-actions.js +2 -1
- package/cart/cart-actions.js.map +2 -2
- package/cart/cart-provider.d.ts +39 -3
- package/cart/cart-provider.js +187 -119
- package/cart/cart-provider.js.map +3 -3
- package/cart/cart-types.d.ts +15 -0
- package/cart/cart-types.js.map +1 -1
- package/cart/gateways/cart-gateway.d.ts +7 -0
- package/cart/gateways/cart-gateway.js.map +2 -2
- package/cart/gateways/cookie-cart-gateway.d.ts +9 -0
- package/cart/gateways/cookie-cart-gateway.js +54 -3
- package/cart/gateways/cookie-cart-gateway.js.map +2 -2
- package/cart/gateways/resolver-cart-gateway.js +4 -0
- package/cart/gateways/resolver-cart-gateway.js.map +2 -2
- package/cart/gateways/stripe-cart-gateway.d.ts +14 -0
- package/cart/gateways/stripe-cart-gateway.js +6 -1
- package/cart/gateways/stripe-cart-gateway.js.map +2 -2
- package/cart/hooks/use-add-to-cart.d.ts +22 -3
- package/cart/hooks/use-add-to-cart.js +8 -1
- package/cart/hooks/use-add-to-cart.js.map +2 -2
- package/cart/utils/cart-adjustments.d.ts +29 -0
- package/cart/utils/cart-adjustments.js +81 -0
- package/cart/utils/cart-adjustments.js.map +7 -0
- package/cart/utils/cart-error.d.ts +5 -0
- package/cart/utils/cart-error.js +20 -0
- package/cart/utils/cart-error.js.map +7 -0
- package/cart/utils/cart-utils.d.ts +14 -0
- package/cart/utils/cart-utils.js +43 -0
- package/cart/utils/cart-utils.js.map +2 -2
- package/cart/utils/cookie-cart-persistence.d.ts +4 -2
- package/cart/utils/cookie-cart-persistence.js +35 -21
- package/cart/utils/cookie-cart-persistence.js.map +3 -3
- package/gateways/canopy-api.d.ts +25 -0
- package/gateways/canopy-api.js +40 -0
- package/gateways/canopy-api.js.map +2 -2
- package/lib/buildMetadata.js +3 -3
- package/loaders/loader-schemas.d.ts +5 -0
- package/loaders/loader-schemas.js +2 -0
- package/loaders/loader-schemas.js.map +2 -2
- package/loaders/rebuy-recommendations-loader.d.ts +1 -0
- package/next/config.d.ts +18 -0
- package/next/config.js.map +2 -2
- package/package.json +4 -4
- package/providers/replo-provider.d.ts +19 -0
- package/providers/replo-provider.js.map +2 -2
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../cart/hooks/use-add-to-cart.ts"],
|
|
4
|
-
"sourcesContent": ["/**\n * Hook for adding items to cart using merchandiseId directly (e.g. variant GIDs from data loaders).\n * Merchandise display data for optimistic UI is auto-resolved from the React Query cache.\n * @module\n */\n\"use client\";\n\nimport type { QueryClient } from \"@tanstack/react-query\";\nimport type { Product } from \"../../loaders/loader-schemas\";\nimport type { CartLineAttribute
|
|
5
|
-
"mappings": ";
|
|
4
|
+
"sourcesContent": ["/**\n * Hook for adding items to cart using merchandiseId directly (e.g. variant GIDs from data loaders).\n * Merchandise display data for optimistic UI is auto-resolved from the React Query cache.\n * `addToCart` resolves with `{ adjustments }` describing anything the server\n * changed about the request (a quantity clamped to stock, an unavailable line\n * removed) so the storefront can show a notice.\n * @module\n */\n\"use client\";\n\nimport type { QueryClient } from \"@tanstack/react-query\";\nimport type { Product } from \"../../loaders/loader-schemas\";\nimport type { CartError } from \"../cart-provider\";\nimport type {\n CartLineAdjustment,\n CartLineAttribute,\n CartLineMerchandise,\n} from \"../cart-types\";\n\nimport React from \"react\";\n\nimport { useQueryClient } from \"@tanstack/react-query\";\nimport { fromMajorUnitsToMinorUnits } from \"schemas/money\";\n\nimport { DATA_LOADER_KEYS } from \"../../loaders/loader-keys\";\nimport { useCartInternal } from \"../cart-provider\";\nimport { REPLO_ATTRIBUTION_PROPERTY } from \"../cart-types\";\nimport { toCartError } from \"../utils/cart-error\";\n\ntype ProductListLike = { products: Product[] };\n\n/**\n * Describes a single item to add to the cart. Uses the merchandiseId\n * (e.g. a variant GID) directly from data loader output. Product\n * display data for optimistic UI is resolved automatically from the\n * React Query cache for both single-product loaders (e.g. ProductLoader)\n * and product-list loaders (e.g. CollectionProductsLoader,\n * RebuyRecommendationsLoader). Pass `merchandise` explicitly to bypass\n * the cache lookup when the variant is known but its product was not\n * loaded via a supported loader.\n */\nexport interface AddToCartItem {\n /** The cart-addressable merchandise ID (e.g. `product.variants[N].id` from loader output). */\n merchandiseId: string;\n quantity?: number;\n sellingPlanId?: string | null;\n /** Custom line item properties (appear on the order). */\n properties?: CartLineAttribute[];\n merchandise?: CartLineMerchandise;\n}\n\n/**\n * What an `addToCart` call did. `adjustments` lists what the server changed\n * about a saved request (a quantity clamped to stock, an unavailable line\n * removed). `error` is set instead when the cart could not be created or\n * saved; the same message is on `useCart().error` for the cart UI.\n */\nexport interface AddToCartResult {\n /** What the server changed about a saved add; empty when nothing was */\n adjustments: CartLineAdjustment[];\n /** Why the add was not saved; null on success */\n error: CartError | null;\n}\n\n/**\n * Loaders whose cached value is a single Product. Scanned for variant\n * matches first since they carry the richest product data.\n */\nconst PRODUCT_LOADER_KEYS = [\n DATA_LOADER_KEYS.SHOPIFY_PRODUCT,\n DATA_LOADER_KEYS.REPLO_PRODUCT,\n] as const;\n\nconst PRODUCT_LIST_LOADER_KEYS = [\n DATA_LOADER_KEYS.SHOPIFY_COLLECTION_PRODUCTS,\n DATA_LOADER_KEYS.REBUY_RECOMMENDATIONS,\n] as const;\n\nfunction getMerchandiseFromProduct({\n product,\n merchandiseId,\n}: {\n product: Product;\n merchandiseId: string;\n}): CartLineMerchandise | undefined {\n if (!product.variants) {\n return undefined;\n }\n\n const matchingVariant = product.variants.find(\n (variant) => variant.id === merchandiseId,\n );\n\n if (!matchingVariant) {\n return undefined;\n }\n\n const image = matchingVariant.image ?? product.featuredImage ?? null;\n return {\n id: merchandiseId,\n product: {\n id: product.id,\n title: product.title ?? \"\",\n sellingPlanGroups: product.sellingPlanGroups ?? [],\n },\n title: matchingVariant.title ?? \"\",\n // NOTE (Max, 2026-08-03): loader prices are decimal MoneyV2 strings, but the\n // cart is integer minor units, so convert at this storefront\u2192cart boundary \u2014\n // a raw parseFloat would store \"19.99\" as 19 minor units (~100\u00D7 too cheap).\n price: matchingVariant.price\n ? fromMajorUnitsToMinorUnits(matchingVariant.price)\n : 0,\n compareAtPrice: matchingVariant.compareAtPrice\n ? fromMajorUnitsToMinorUnits(matchingVariant.compareAtPrice)\n : null,\n image: image ? { url: image.url, altText: image.altText } : null,\n selectedOptions: matchingVariant.selectedOptions ?? [],\n };\n}\n\nexport function findMerchandiseInQueryCache({\n queryClient,\n merchandiseId,\n}: {\n queryClient: QueryClient;\n merchandiseId: string;\n}): CartLineMerchandise | undefined {\n for (const loaderKey of PRODUCT_LOADER_KEYS) {\n const productQueries = queryClient.getQueriesData<Product>({\n queryKey: [loaderKey],\n });\n\n for (const [, productData] of productQueries) {\n if (!productData) {\n continue;\n }\n\n const merchandise = getMerchandiseFromProduct({\n product: productData,\n merchandiseId,\n });\n if (merchandise) {\n return merchandise;\n }\n }\n }\n\n for (const loaderKey of PRODUCT_LIST_LOADER_KEYS) {\n const productListQueries = queryClient.getQueriesData<ProductListLike>({\n queryKey: [loaderKey],\n });\n\n for (const [, productListData] of productListQueries) {\n for (const product of productListData?.products ?? []) {\n const merchandise = getMerchandiseFromProduct({\n product,\n merchandiseId,\n });\n if (merchandise) {\n return merchandise;\n }\n }\n }\n }\n\n return undefined;\n}\n\n/**\n * Hook for adding items to cart using merchandise IDs from data loader output.\n *\n * This is the primary hook for adding items to cart in the data-loader\n * architecture. It works with the resolver-backed CartGateway so the scaffold\n * never needs direct integration credentials.\n *\n * Merchandise display data (title, price, image) for optimistic cart UI is\n * auto-resolved from the React Query cache for both single-product loaders\n * (e.g. `ProductLoader`) and product-list loaders (e.g.\n * `CollectionProductsLoader`, `RebuyRecommendationsLoader`). Just pass the\n * merchandiseId. Pass `merchandise` explicitly only when the variant is\n * known but its product was not loaded via a supported loader.\n *\n * `addToCart` never throws. It resolves an `AddToCartResult`: `adjustments`\n * when the add was saved, or `error` when the cart could not be created or\n * saved. On failure the provider also records the error on `useCart().error`\n * and opens the cart UI, which renders it, so a click handler needs no\n * handling of its own.\n *\n * @example\n * ```tsx\n * import { useAddToCart } from \"@replohq/sdk/cart/hooks/use-add-to-cart\";\n *\n * function AddToCartButton({ variant }) {\n * const { addToCart, isAdding } = useAddToCart();\n *\n * return (\n * <button\n * disabled={!variant.availableForSale || isAdding}\n * onClick={() => {\n * void addToCart([{ merchandiseId: variant.id, quantity: 1 }]);\n * }}\n * >\n * {isAdding ? \"Adding...\" : \"Add to Cart\"}\n * </button>\n * );\n * }\n * ```\n */\nexport function useAddToCart() {\n const cart = useCartInternal();\n const queryClient = useQueryClient();\n const [isAdding, setIsAdding] = React.useState(false);\n\n const addToCart = React.useCallback(\n async (\n items: AddToCartItem[],\n openCartAfterAdd = true,\n ): Promise<AddToCartResult> => {\n setIsAdding(true);\n try {\n const cartLines = items.map((item) => {\n const hasReploAttribution = item.properties?.some(\n (prop) => prop.key === REPLO_ATTRIBUTION_PROPERTY.key,\n );\n const properties = hasReploAttribution\n ? item.properties ?? []\n : [REPLO_ATTRIBUTION_PROPERTY, ...(item.properties ?? [])];\n\n const merchandise =\n item.merchandise ??\n findMerchandiseInQueryCache({\n queryClient,\n merchandiseId: item.merchandiseId,\n });\n\n return {\n id: item.merchandiseId,\n quantity: item.quantity ?? 1,\n sellingPlanId: item.sellingPlanId ?? undefined,\n properties,\n merchandise,\n };\n });\n\n const { adjustments } = await cart.addToCart(\n cartLines,\n openCartAfterAdd,\n );\n return { adjustments, error: null };\n } catch (caught) {\n // The provider has already recorded and logged the failure.\n return { adjustments: [], error: toCartError(\"add\", caught) };\n } finally {\n setIsAdding(false);\n }\n },\n [cart, queryClient],\n );\n\n return { addToCart, isAdding };\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Cart, CartLine, CartLineAdjustment, CartLinePayload } from "../cart-types";
|
|
2
|
+
/**
|
|
3
|
+
* Diff what an add intended against the cart the server saved, as
|
|
4
|
+
* Shopify-style adjustment notices. Intent is built from the request's own
|
|
5
|
+
* identity (variant id + selling plan) on top of the pre-mutation server
|
|
6
|
+
* cart — never from the optimistic display cart, whose lines can lack
|
|
7
|
+
* merchandise (query-cache miss) or a resolvable plan allocation and would
|
|
8
|
+
* silently drop or misattribute the comparison. The write reconciles the
|
|
9
|
+
* whole cart, so lines the shopper didn't touch are covered too.
|
|
10
|
+
*/
|
|
11
|
+
export declare function deriveAddToCartAdjustments({ cartBeforeAdd, requestedLines, savedCart, }: {
|
|
12
|
+
cartBeforeAdd: Cart;
|
|
13
|
+
requestedLines: CartLinePayload[];
|
|
14
|
+
savedCart: Cart;
|
|
15
|
+
}): CartLineAdjustment[];
|
|
16
|
+
/**
|
|
17
|
+
* Same diff for a line update. The intended totals move the edited line's
|
|
18
|
+
* quantity to the requested selling plan — `undefined` keeps the line's
|
|
19
|
+
* current plan, explicit `null` removes it (the resolver forwards the
|
|
20
|
+
* requested plan, so a successful A→B switch must not read as a removal).
|
|
21
|
+
* A deliberate removal (quantity 0) intends nothing and reports nothing.
|
|
22
|
+
*/
|
|
23
|
+
export declare function deriveUpdateCartAdjustments({ cartBeforeUpdate, editedLine, requestedQuantity, requestedSellingPlanId, savedCart, }: {
|
|
24
|
+
cartBeforeUpdate: Cart;
|
|
25
|
+
editedLine: CartLine;
|
|
26
|
+
requestedQuantity: number;
|
|
27
|
+
requestedSellingPlanId: string | null | undefined;
|
|
28
|
+
savedCart: Cart;
|
|
29
|
+
}): CartLineAdjustment[];
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
function totalKey(variantId, sellingPlanId) {
|
|
2
|
+
return `${variantId}::${sellingPlanId ?? ""}`;
|
|
3
|
+
}
|
|
4
|
+
function addToTotals(totals, variantId, sellingPlanId, delta) {
|
|
5
|
+
const key = totalKey(variantId, sellingPlanId);
|
|
6
|
+
const entry = totals.get(key);
|
|
7
|
+
if (entry) {
|
|
8
|
+
entry.quantity += delta;
|
|
9
|
+
} else {
|
|
10
|
+
totals.set(key, { variantId, quantity: delta });
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
function totalsFromCart(cart) {
|
|
14
|
+
const totals = /* @__PURE__ */ new Map();
|
|
15
|
+
for (const line of cart.lines) {
|
|
16
|
+
if (!line.merchandise) {
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
addToTotals(
|
|
20
|
+
totals,
|
|
21
|
+
line.merchandise.id,
|
|
22
|
+
line.sellingPlanAllocation?.sellingPlan.id ?? null,
|
|
23
|
+
line.quantity
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
return totals;
|
|
27
|
+
}
|
|
28
|
+
function diffTotals(intended, saved) {
|
|
29
|
+
const adjustments = [];
|
|
30
|
+
for (const [key, entry] of intended) {
|
|
31
|
+
const appliedQuantity = saved.get(key)?.quantity ?? 0;
|
|
32
|
+
if (appliedQuantity >= entry.quantity) {
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
adjustments.push(
|
|
36
|
+
appliedQuantity > 0 ? {
|
|
37
|
+
type: "quantityClampedToStock",
|
|
38
|
+
variantId: entry.variantId,
|
|
39
|
+
requested: entry.quantity,
|
|
40
|
+
applied: appliedQuantity
|
|
41
|
+
} : { type: "unavailableLineRemoved", variantId: entry.variantId }
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
return adjustments;
|
|
45
|
+
}
|
|
46
|
+
function deriveAddToCartAdjustments({
|
|
47
|
+
cartBeforeAdd,
|
|
48
|
+
requestedLines,
|
|
49
|
+
savedCart
|
|
50
|
+
}) {
|
|
51
|
+
const intended = totalsFromCart(cartBeforeAdd);
|
|
52
|
+
for (const line of requestedLines) {
|
|
53
|
+
addToTotals(intended, line.id, line.sellingPlanId ?? null, line.quantity);
|
|
54
|
+
}
|
|
55
|
+
return diffTotals(intended, totalsFromCart(savedCart));
|
|
56
|
+
}
|
|
57
|
+
function deriveUpdateCartAdjustments({
|
|
58
|
+
cartBeforeUpdate,
|
|
59
|
+
editedLine,
|
|
60
|
+
requestedQuantity,
|
|
61
|
+
requestedSellingPlanId,
|
|
62
|
+
savedCart
|
|
63
|
+
}) {
|
|
64
|
+
const variantId = editedLine.merchandise?.id;
|
|
65
|
+
if (!variantId) {
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
const currentPlanId = editedLine.sellingPlanAllocation?.sellingPlan.id ?? null;
|
|
69
|
+
const intended = totalsFromCart(cartBeforeUpdate);
|
|
70
|
+
addToTotals(intended, variantId, currentPlanId, -editedLine.quantity);
|
|
71
|
+
if (requestedQuantity > 0) {
|
|
72
|
+
const intendedPlanId = requestedSellingPlanId === void 0 ? currentPlanId : requestedSellingPlanId;
|
|
73
|
+
addToTotals(intended, variantId, intendedPlanId, requestedQuantity);
|
|
74
|
+
}
|
|
75
|
+
return diffTotals(intended, totalsFromCart(savedCart));
|
|
76
|
+
}
|
|
77
|
+
export {
|
|
78
|
+
deriveAddToCartAdjustments,
|
|
79
|
+
deriveUpdateCartAdjustments
|
|
80
|
+
};
|
|
81
|
+
//# sourceMappingURL=cart-adjustments.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../cart/utils/cart-adjustments.ts"],
|
|
4
|
+
"sourcesContent": ["import type {\n Cart,\n CartLine,\n CartLineAdjustment,\n CartLinePayload,\n} from \"../cart-types\";\n\ntype VariantPlanTotals = Map<string, { variantId: string; quantity: number }>;\n\nfunction totalKey(variantId: string, sellingPlanId: string | null): string {\n return `${variantId}::${sellingPlanId ?? \"\"}`;\n}\n\nfunction addToTotals(\n totals: VariantPlanTotals,\n variantId: string,\n sellingPlanId: string | null,\n delta: number,\n) {\n const key = totalKey(variantId, sellingPlanId);\n const entry = totals.get(key);\n if (entry) {\n entry.quantity += delta;\n } else {\n totals.set(key, { variantId, quantity: delta });\n }\n}\n\n// Shopify separates lines for the same variant + plan by custom properties\n// (two engravings, two lines), so quantities are summed per variant + plan \u2014\n// notices are variant-level, never per property-split line.\nfunction totalsFromCart(cart: Cart): VariantPlanTotals {\n const totals: VariantPlanTotals = new Map();\n for (const line of cart.lines) {\n if (!line.merchandise) {\n continue;\n }\n addToTotals(\n totals,\n line.merchandise.id,\n line.sellingPlanAllocation?.sellingPlan.id ?? null,\n line.quantity,\n );\n }\n return totals;\n}\n\nfunction diffTotals(\n intended: VariantPlanTotals,\n saved: VariantPlanTotals,\n): CartLineAdjustment[] {\n const adjustments: CartLineAdjustment[] = [];\n for (const [key, entry] of intended) {\n const appliedQuantity = saved.get(key)?.quantity ?? 0;\n if (appliedQuantity >= entry.quantity) {\n continue;\n }\n adjustments.push(\n appliedQuantity > 0\n ? {\n type: \"quantityClampedToStock\",\n variantId: entry.variantId,\n requested: entry.quantity,\n applied: appliedQuantity,\n }\n : { type: \"unavailableLineRemoved\", variantId: entry.variantId },\n );\n }\n return adjustments;\n}\n\n/**\n * Diff what an add intended against the cart the server saved, as\n * Shopify-style adjustment notices. Intent is built from the request's own\n * identity (variant id + selling plan) on top of the pre-mutation server\n * cart \u2014 never from the optimistic display cart, whose lines can lack\n * merchandise (query-cache miss) or a resolvable plan allocation and would\n * silently drop or misattribute the comparison. The write reconciles the\n * whole cart, so lines the shopper didn't touch are covered too.\n */\nexport function deriveAddToCartAdjustments({\n cartBeforeAdd,\n requestedLines,\n savedCart,\n}: {\n cartBeforeAdd: Cart;\n requestedLines: CartLinePayload[];\n savedCart: Cart;\n}): CartLineAdjustment[] {\n const intended = totalsFromCart(cartBeforeAdd);\n for (const line of requestedLines) {\n addToTotals(intended, line.id, line.sellingPlanId ?? null, line.quantity);\n }\n return diffTotals(intended, totalsFromCart(savedCart));\n}\n\n/**\n * Same diff for a line update. The intended totals move the edited line's\n * quantity to the requested selling plan \u2014 `undefined` keeps the line's\n * current plan, explicit `null` removes it (the resolver forwards the\n * requested plan, so a successful A\u2192B switch must not read as a removal).\n * A deliberate removal (quantity 0) intends nothing and reports nothing.\n */\nexport function deriveUpdateCartAdjustments({\n cartBeforeUpdate,\n editedLine,\n requestedQuantity,\n requestedSellingPlanId,\n savedCart,\n}: {\n cartBeforeUpdate: Cart;\n editedLine: CartLine;\n requestedQuantity: number;\n requestedSellingPlanId: string | null | undefined;\n savedCart: Cart;\n}): CartLineAdjustment[] {\n const variantId = editedLine.merchandise?.id;\n if (!variantId) {\n return [];\n }\n const currentPlanId =\n editedLine.sellingPlanAllocation?.sellingPlan.id ?? null;\n const intended = totalsFromCart(cartBeforeUpdate);\n addToTotals(intended, variantId, currentPlanId, -editedLine.quantity);\n if (requestedQuantity > 0) {\n const intendedPlanId =\n requestedSellingPlanId === undefined\n ? currentPlanId\n : requestedSellingPlanId;\n addToTotals(intended, variantId, intendedPlanId, requestedQuantity);\n }\n return diffTotals(intended, totalsFromCart(savedCart));\n}\n"],
|
|
5
|
+
"mappings": "AASA,SAAS,SAAS,WAAmB,eAAsC;AACzE,SAAO,GAAG,SAAS,KAAK,iBAAiB,EAAE;AAC7C;AAEA,SAAS,YACP,QACA,WACA,eACA,OACA;AACA,QAAM,MAAM,SAAS,WAAW,aAAa;AAC7C,QAAM,QAAQ,OAAO,IAAI,GAAG;AAC5B,MAAI,OAAO;AACT,UAAM,YAAY;AAAA,EACpB,OAAO;AACL,WAAO,IAAI,KAAK,EAAE,WAAW,UAAU,MAAM,CAAC;AAAA,EAChD;AACF;AAKA,SAAS,eAAe,MAA+B;AACrD,QAAM,SAA4B,oBAAI,IAAI;AAC1C,aAAW,QAAQ,KAAK,OAAO;AAC7B,QAAI,CAAC,KAAK,aAAa;AACrB;AAAA,IACF;AACA;AAAA,MACE;AAAA,MACA,KAAK,YAAY;AAAA,MACjB,KAAK,uBAAuB,YAAY,MAAM;AAAA,MAC9C,KAAK;AAAA,IACP;AAAA,EACF;AACA,SAAO;AACT;AAEA,SAAS,WACP,UACA,OACsB;AACtB,QAAM,cAAoC,CAAC;AAC3C,aAAW,CAAC,KAAK,KAAK,KAAK,UAAU;AACnC,UAAM,kBAAkB,MAAM,IAAI,GAAG,GAAG,YAAY;AACpD,QAAI,mBAAmB,MAAM,UAAU;AACrC;AAAA,IACF;AACA,gBAAY;AAAA,MACV,kBAAkB,IACd;AAAA,QACE,MAAM;AAAA,QACN,WAAW,MAAM;AAAA,QACjB,WAAW,MAAM;AAAA,QACjB,SAAS;AAAA,MACX,IACA,EAAE,MAAM,0BAA0B,WAAW,MAAM,UAAU;AAAA,IACnE;AAAA,EACF;AACA,SAAO;AACT;AAWO,SAAS,2BAA2B;AAAA,EACzC;AAAA,EACA;AAAA,EACA;AACF,GAIyB;AACvB,QAAM,WAAW,eAAe,aAAa;AAC7C,aAAW,QAAQ,gBAAgB;AACjC,gBAAY,UAAU,KAAK,IAAI,KAAK,iBAAiB,MAAM,KAAK,QAAQ;AAAA,EAC1E;AACA,SAAO,WAAW,UAAU,eAAe,SAAS,CAAC;AACvD;AASO,SAAS,4BAA4B;AAAA,EAC1C;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAMyB;AACvB,QAAM,YAAY,WAAW,aAAa;AAC1C,MAAI,CAAC,WAAW;AACd,WAAO,CAAC;AAAA,EACV;AACA,QAAM,gBACJ,WAAW,uBAAuB,YAAY,MAAM;AACtD,QAAM,WAAW,eAAe,gBAAgB;AAChD,cAAY,UAAU,WAAW,eAAe,CAAC,WAAW,QAAQ;AACpE,MAAI,oBAAoB,GAAG;AACzB,UAAM,iBACJ,2BAA2B,SACvB,gBACA;AACN,gBAAY,UAAU,WAAW,gBAAgB,iBAAiB;AAAA,EACpE;AACA,SAAO,WAAW,UAAU,eAAe,SAAS,CAAC;AACvD;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { CartError, CartOperation } from "../cart-provider";
|
|
2
|
+
import { CanopyError } from "../../lib/canopy-error";
|
|
3
|
+
export declare class CartContextError extends CanopyError {
|
|
4
|
+
}
|
|
5
|
+
export declare function toCartError(operation: CartOperation, caught: unknown): CartError;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { CanopyError } from "../../lib/canopy-error";
|
|
2
|
+
class CartContextError extends CanopyError {
|
|
3
|
+
}
|
|
4
|
+
const FALLBACK_MESSAGES = {
|
|
5
|
+
add: "Could not add to your cart. Please try again.",
|
|
6
|
+
update: "Could not update your cart. Please try again.",
|
|
7
|
+
remove: "Could not remove that item. Please try again.",
|
|
8
|
+
discount: "Could not apply that discount. Please try again."
|
|
9
|
+
};
|
|
10
|
+
function toCartError(operation, caught) {
|
|
11
|
+
return {
|
|
12
|
+
operation,
|
|
13
|
+
message: caught instanceof CartContextError ? caught.message : FALLBACK_MESSAGES[operation]
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
export {
|
|
17
|
+
CartContextError,
|
|
18
|
+
toCartError
|
|
19
|
+
};
|
|
20
|
+
//# sourceMappingURL=cart-error.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../cart/utils/cart-error.ts"],
|
|
4
|
+
"sourcesContent": ["import type { CartError, CartOperation } from \"../cart-provider\";\n\nimport { CanopyError } from \"../../lib/canopy-error\";\n\nexport class CartContextError extends CanopyError {}\n\nconst FALLBACK_MESSAGES: Record<CartOperation, string> = {\n add: \"Could not add to your cart. Please try again.\",\n update: \"Could not update your cart. Please try again.\",\n remove: \"Could not remove that item. Please try again.\",\n discount: \"Could not apply that discount. Please try again.\",\n};\n\n// Only the provider's own errors carry shopper-safe text; anything else (a\n// dropped request, a null action result) is technical and gets the fallback.\nexport function toCartError(\n operation: CartOperation,\n caught: unknown,\n): CartError {\n return {\n operation,\n message:\n caught instanceof CartContextError\n ? caught.message\n : FALLBACK_MESSAGES[operation],\n };\n}\n"],
|
|
5
|
+
"mappings": "AAEA,SAAS,mBAAmB;AAErB,MAAM,yBAAyB,YAAY;AAAC;AAEnD,MAAM,oBAAmD;AAAA,EACvD,KAAK;AAAA,EACL,QAAQ;AAAA,EACR,QAAQ;AAAA,EACR,UAAU;AACZ;AAIO,SAAS,YACd,WACA,QACW;AACX,SAAO;AAAA,IACL;AAAA,IACA,SACE,kBAAkB,mBACd,OAAO,UACP,kBAAkB,SAAS;AAAA,EACnC;AACF;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ReconciledCartLine } from "../../gateways/canopy-api";
|
|
1
2
|
import type { Cart, CartLine } from "../cart-types";
|
|
2
3
|
export interface CartTotals {
|
|
3
4
|
subtotal: number;
|
|
@@ -19,6 +20,19 @@ export declare function calculateLineItemsSubtotal(lineItems: CartLine[]): numbe
|
|
|
19
20
|
* checkout analytics) reports $0 for a priced cart.
|
|
20
21
|
*/
|
|
21
22
|
export declare function recalculateCartCost(cart: Cart): Cart;
|
|
23
|
+
/**
|
|
24
|
+
* Rewrite a cookie cart with the catalog's current facts: live prices always,
|
|
25
|
+
* unavailable lines dropped, and — on write paths only — quantities clamped to
|
|
26
|
+
* the purchasable remainder. Reads never clamp (`clampQuantities: false`):
|
|
27
|
+
* a quantity change without messaging is worse than one the mutation or
|
|
28
|
+
* checkout path reports, mirroring Shopify's cart behavior.
|
|
29
|
+
*
|
|
30
|
+
* `reconciledLines` must be the catalog's answer for this cart's
|
|
31
|
+
* merchandise-bearing lines in order — the shape reconcileCartLines returns.
|
|
32
|
+
*/
|
|
33
|
+
export declare function applyCartLineReconciliation(cart: Cart, reconciledLines: ReconciledCartLine[], { clampQuantities }: {
|
|
34
|
+
clampQuantities: boolean;
|
|
35
|
+
}): Cart;
|
|
22
36
|
/**
|
|
23
37
|
* Calculate cart totals including subtotal, discounts, and final total.
|
|
24
38
|
* When cart cost data is provided (from Shopify), uses the actual totalAmount
|
package/cart/utils/cart-utils.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { formatAmount } from "../../_vendor/schemas/money.mjs";
|
|
2
|
+
import { CanopyError } from "../../lib/canopy-error";
|
|
2
3
|
import { getCartLinePricing } from "./variant-to-cart-line";
|
|
4
|
+
class CartReconciliationMismatchError extends CanopyError {
|
|
5
|
+
}
|
|
3
6
|
function calculateLineItemsSubtotal(lineItems) {
|
|
4
7
|
return lineItems.reduce(
|
|
5
8
|
(sum, item) => sum + getCartLinePricing(item).finalPrice * item.quantity,
|
|
@@ -18,6 +21,45 @@ function recalculateCartCost(cart) {
|
|
|
18
21
|
discountAllocations: []
|
|
19
22
|
};
|
|
20
23
|
}
|
|
24
|
+
function applyCartLineReconciliation(cart, reconciledLines, { clampQuantities }) {
|
|
25
|
+
let reconciledIndex = 0;
|
|
26
|
+
const lines = cart.lines.flatMap((line) => {
|
|
27
|
+
if (!line.merchandise) {
|
|
28
|
+
return [line];
|
|
29
|
+
}
|
|
30
|
+
const catalogLine = reconciledLines[reconciledIndex];
|
|
31
|
+
reconciledIndex += 1;
|
|
32
|
+
if (!catalogLine || catalogLine.variantId !== line.merchandise.id) {
|
|
33
|
+
throw new CartReconciliationMismatchError({
|
|
34
|
+
message: "Reconciled lines do not match the cart's lines",
|
|
35
|
+
additionalData: {
|
|
36
|
+
cartId: cart.id,
|
|
37
|
+
variantId: line.merchandise.id,
|
|
38
|
+
reconciledVariantId: catalogLine?.variantId ?? null
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
if (catalogLine.status === "unavailable") {
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
const quantity = clampQuantities ? Math.min(line.quantity, catalogLine.purchasableQuantity) : line.quantity;
|
|
46
|
+
if (quantity <= 0) {
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
return [
|
|
50
|
+
{
|
|
51
|
+
...line,
|
|
52
|
+
quantity,
|
|
53
|
+
merchandise: {
|
|
54
|
+
...line.merchandise,
|
|
55
|
+
price: catalogLine.unitPriceMinor,
|
|
56
|
+
compareAtPrice: catalogLine.compareAtPriceMinor
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
];
|
|
60
|
+
});
|
|
61
|
+
return recalculateCartCost({ ...cart, lines });
|
|
62
|
+
}
|
|
21
63
|
function calculateCartTotals({
|
|
22
64
|
lineItems,
|
|
23
65
|
cart
|
|
@@ -52,6 +94,7 @@ function formatSellingPlanId(sellingPlanId) {
|
|
|
52
94
|
return `gid://shopify/SellingPlan/${sellingPlanId}`;
|
|
53
95
|
}
|
|
54
96
|
export {
|
|
97
|
+
applyCartLineReconciliation,
|
|
55
98
|
calculateCartTotals,
|
|
56
99
|
calculateLineItemsSubtotal,
|
|
57
100
|
formatPrice,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../cart/utils/cart-utils.ts"],
|
|
4
|
-
"sourcesContent": ["import type { Cart, CartLine } from \"../cart-types\";\n\nimport { formatAmount } from \"schemas/money\";\n\nimport { getCartLinePricing } from \"./variant-to-cart-line\";\n\nexport interface CartTotals {\n subtotal: number;\n discountAmount: number;\n totalSavings: number;\n total: number;\n}\n\n/**\n * Sum the final (post selling-plan) price of every line item in the cart.\n * This is the authoritative subtotal for cookie-based carts, which have no\n * server-computed cost to rely on.\n */\nexport function calculateLineItemsSubtotal(lineItems: CartLine[]): number {\n return lineItems.reduce(\n (sum, item) => sum + getCartLinePricing(item).finalPrice * item.quantity,\n 0,\n );\n}\n\n/**\n * Recompute a cart's cost from its line items. Cookie carts are their own source\n * of truth (no server roundtrip) and apply no discounts, so subtotal equals\n * total and there are no discount allocations. Without this the cost stays at\n * the 0 it was created with, and every consumer that trusts it (cart total,\n * checkout analytics) reports $0 for a priced cart.\n */\nexport function recalculateCartCost(cart: Cart): Cart {\n const subtotal = calculateLineItemsSubtotal(cart.lines);\n return {\n ...cart,\n cost: {\n ...cart.cost,\n subtotalAmount: subtotal,\n totalAmount: subtotal,\n },\n discountAllocations: [],\n };\n}\n\n/**\n * Calculate cart totals including subtotal, discounts, and final total.\n * When cart cost data is provided (from Shopify), uses the actual totalAmount\n * and sums the provider's discount allocations (automatic, code, and custom\n * discounts, at both line and cart level) for the discount amount.\n *\n * @param options - Object containing lineItems and cart\n * @param options.lineItems - Array of cart line items\n * @param options.cart - Optional cart data from Shopify (includes discount allocations)\n */\nexport function calculateCartTotals({\n lineItems,\n cart,\n}: {\n lineItems: CartLine[];\n cart: Cart | null;\n}): CartTotals {\n const lineItemSubtotal = calculateLineItemsSubtotal(lineItems);\n const compareAtSubtotal = lineItems.reduce((sum, item) => {\n return sum + getCartLinePricing(item).compareAtPrice * item.quantity;\n }, 0);\n\n // NOTE (Jasmine, 2026-07-22): Trust the provider's authoritative cost when\n // present. For Shopify this is the server-computed cart cost (including a\n // legitimate $0 when items aren't purchasable), which we must not\n // second-guess; cookie carts keep their cost truthful via\n // cookie-cart-persistence.ts.\n const total = cart?.cost.totalAmount ?? lineItemSubtotal;\n\n // Shopify folds line-level discounts into cost.subtotalAmount too, so the\n // cost gap can't be used; the allocations carry every applied discount.\n const discountAmount = (cart?.discountAllocations ?? []).reduce(\n (sum, allocation) => sum + allocation.discountedAmount,\n 0,\n );\n\n const totalSavings = compareAtSubtotal - total;\n\n return {\n subtotal: lineItemSubtotal,\n discountAmount,\n totalSavings,\n total,\n };\n}\n\n/**\n * @deprecated Back-compat shim kept for surface parity with canopy-sdk, so\n * sites migrating off `import { formatPrice } from\n * \"canopy-sdk/cart/utils/cart-utils\"` keep rendering with only the package\n * specifier renamed. It formats INTEGER MINOR UNITS \u2014 the units every cart\n * value uses (`calculateCartTotals`, `getCartLinePricing`, `CartCost`):\n * passing 1999 yields \"$19.99\", not \"$1,999.00\". New code should call\n * `formatAmount({ amount, currencyCode })` from `@replohq/sdk/money` directly.\n */\nexport function formatPrice(price: number, currency: string = \"USD\"): string {\n return formatAmount({ amount: price, currencyCode: currency });\n}\n\n/**\n * Validates and formats a Shopify selling plan ID to ensure it has the correct GID format.\n *\n * @param sellingPlanId - The selling plan ID to validate and format\n * @returns The formatted selling plan ID with GID prefix, or null if no ID provided\n */\nexport function formatSellingPlanId(\n sellingPlanId: string | null | undefined,\n): string | null {\n if (!sellingPlanId) {\n return null;\n }\n\n // Check if it already has the gid format\n if (sellingPlanId.startsWith(\"gid://shopify/SellingPlan/\")) {\n return sellingPlanId;\n }\n\n // If it doesn't have the gid format, prepend it\n return `gid://shopify/SellingPlan/${sellingPlanId}`;\n}\n"],
|
|
5
|
-
"mappings": "
|
|
4
|
+
"sourcesContent": ["import type { ReconciledCartLine } from \"../../gateways/canopy-api\";\nimport type { Cart, CartLine } from \"../cart-types\";\n\nimport { formatAmount } from \"schemas/money\";\n\nimport { CanopyError } from \"../../lib/canopy-error\";\nimport { getCartLinePricing } from \"./variant-to-cart-line\";\n\nclass CartReconciliationMismatchError extends CanopyError {}\n\nexport interface CartTotals {\n subtotal: number;\n discountAmount: number;\n totalSavings: number;\n total: number;\n}\n\n/**\n * Sum the final (post selling-plan) price of every line item in the cart.\n * This is the authoritative subtotal for cookie-based carts, which have no\n * server-computed cost to rely on.\n */\nexport function calculateLineItemsSubtotal(lineItems: CartLine[]): number {\n return lineItems.reduce(\n (sum, item) => sum + getCartLinePricing(item).finalPrice * item.quantity,\n 0,\n );\n}\n\n/**\n * Recompute a cart's cost from its line items. Cookie carts are their own source\n * of truth (no server roundtrip) and apply no discounts, so subtotal equals\n * total and there are no discount allocations. Without this the cost stays at\n * the 0 it was created with, and every consumer that trusts it (cart total,\n * checkout analytics) reports $0 for a priced cart.\n */\nexport function recalculateCartCost(cart: Cart): Cart {\n const subtotal = calculateLineItemsSubtotal(cart.lines);\n return {\n ...cart,\n cost: {\n ...cart.cost,\n subtotalAmount: subtotal,\n totalAmount: subtotal,\n },\n discountAllocations: [],\n };\n}\n\n/**\n * Rewrite a cookie cart with the catalog's current facts: live prices always,\n * unavailable lines dropped, and \u2014 on write paths only \u2014 quantities clamped to\n * the purchasable remainder. Reads never clamp (`clampQuantities: false`):\n * a quantity change without messaging is worse than one the mutation or\n * checkout path reports, mirroring Shopify's cart behavior.\n *\n * `reconciledLines` must be the catalog's answer for this cart's\n * merchandise-bearing lines in order \u2014 the shape reconcileCartLines returns.\n */\nexport function applyCartLineReconciliation(\n cart: Cart,\n reconciledLines: ReconciledCartLine[],\n { clampQuantities }: { clampQuantities: boolean },\n): Cart {\n let reconciledIndex = 0;\n const lines = cart.lines.flatMap((line) => {\n if (!line.merchandise) {\n return [line];\n }\n const catalogLine = reconciledLines[reconciledIndex];\n reconciledIndex += 1;\n if (!catalogLine || catalogLine.variantId !== line.merchandise.id) {\n throw new CartReconciliationMismatchError({\n message: \"Reconciled lines do not match the cart's lines\",\n additionalData: {\n cartId: cart.id,\n variantId: line.merchandise.id,\n reconciledVariantId: catalogLine?.variantId ?? null,\n },\n });\n }\n if (catalogLine.status === \"unavailable\") {\n return [];\n }\n const quantity = clampQuantities\n ? Math.min(line.quantity, catalogLine.purchasableQuantity)\n : line.quantity;\n if (quantity <= 0) {\n return [];\n }\n return [\n {\n ...line,\n quantity,\n merchandise: {\n ...line.merchandise,\n price: catalogLine.unitPriceMinor,\n compareAtPrice: catalogLine.compareAtPriceMinor,\n },\n },\n ];\n });\n return recalculateCartCost({ ...cart, lines });\n}\n\n/**\n * Calculate cart totals including subtotal, discounts, and final total.\n * When cart cost data is provided (from Shopify), uses the actual totalAmount\n * and sums the provider's discount allocations (automatic, code, and custom\n * discounts, at both line and cart level) for the discount amount.\n *\n * @param options - Object containing lineItems and cart\n * @param options.lineItems - Array of cart line items\n * @param options.cart - Optional cart data from Shopify (includes discount allocations)\n */\nexport function calculateCartTotals({\n lineItems,\n cart,\n}: {\n lineItems: CartLine[];\n cart: Cart | null;\n}): CartTotals {\n const lineItemSubtotal = calculateLineItemsSubtotal(lineItems);\n const compareAtSubtotal = lineItems.reduce((sum, item) => {\n return sum + getCartLinePricing(item).compareAtPrice * item.quantity;\n }, 0);\n\n // NOTE (Jasmine, 2026-07-22): Trust the provider's authoritative cost when\n // present. For Shopify this is the server-computed cart cost (including a\n // legitimate $0 when items aren't purchasable), which we must not\n // second-guess; cookie carts keep their cost truthful via\n // cookie-cart-persistence.ts.\n const total = cart?.cost.totalAmount ?? lineItemSubtotal;\n\n // Shopify folds line-level discounts into cost.subtotalAmount too, so the\n // cost gap can't be used; the allocations carry every applied discount.\n const discountAmount = (cart?.discountAllocations ?? []).reduce(\n (sum, allocation) => sum + allocation.discountedAmount,\n 0,\n );\n\n const totalSavings = compareAtSubtotal - total;\n\n return {\n subtotal: lineItemSubtotal,\n discountAmount,\n totalSavings,\n total,\n };\n}\n\n/**\n * @deprecated Back-compat shim kept for surface parity with canopy-sdk, so\n * sites migrating off `import { formatPrice } from\n * \"canopy-sdk/cart/utils/cart-utils\"` keep rendering with only the package\n * specifier renamed. It formats INTEGER MINOR UNITS \u2014 the units every cart\n * value uses (`calculateCartTotals`, `getCartLinePricing`, `CartCost`):\n * passing 1999 yields \"$19.99\", not \"$1,999.00\". New code should call\n * `formatAmount({ amount, currencyCode })` from `@replohq/sdk/money` directly.\n */\nexport function formatPrice(price: number, currency: string = \"USD\"): string {\n return formatAmount({ amount: price, currencyCode: currency });\n}\n\n/**\n * Validates and formats a Shopify selling plan ID to ensure it has the correct GID format.\n *\n * @param sellingPlanId - The selling plan ID to validate and format\n * @returns The formatted selling plan ID with GID prefix, or null if no ID provided\n */\nexport function formatSellingPlanId(\n sellingPlanId: string | null | undefined,\n): string | null {\n if (!sellingPlanId) {\n return null;\n }\n\n // Check if it already has the gid format\n if (sellingPlanId.startsWith(\"gid://shopify/SellingPlan/\")) {\n return sellingPlanId;\n }\n\n // If it doesn't have the gid format, prepend it\n return `gid://shopify/SellingPlan/${sellingPlanId}`;\n}\n"],
|
|
5
|
+
"mappings": "AAGA,SAAS,oBAAoB;AAE7B,SAAS,mBAAmB;AAC5B,SAAS,0BAA0B;AAEnC,MAAM,wCAAwC,YAAY;AAAC;AAcpD,SAAS,2BAA2B,WAA+B;AACxE,SAAO,UAAU;AAAA,IACf,CAAC,KAAK,SAAS,MAAM,mBAAmB,IAAI,EAAE,aAAa,KAAK;AAAA,IAChE;AAAA,EACF;AACF;AASO,SAAS,oBAAoB,MAAkB;AACpD,QAAM,WAAW,2BAA2B,KAAK,KAAK;AACtD,SAAO;AAAA,IACL,GAAG;AAAA,IACH,MAAM;AAAA,MACJ,GAAG,KAAK;AAAA,MACR,gBAAgB;AAAA,MAChB,aAAa;AAAA,IACf;AAAA,IACA,qBAAqB,CAAC;AAAA,EACxB;AACF;AAYO,SAAS,4BACd,MACA,iBACA,EAAE,gBAAgB,GACZ;AACN,MAAI,kBAAkB;AACtB,QAAM,QAAQ,KAAK,MAAM,QAAQ,CAAC,SAAS;AACzC,QAAI,CAAC,KAAK,aAAa;AACrB,aAAO,CAAC,IAAI;AAAA,IACd;AACA,UAAM,cAAc,gBAAgB,eAAe;AACnD,uBAAmB;AACnB,QAAI,CAAC,eAAe,YAAY,cAAc,KAAK,YAAY,IAAI;AACjE,YAAM,IAAI,gCAAgC;AAAA,QACxC,SAAS;AAAA,QACT,gBAAgB;AAAA,UACd,QAAQ,KAAK;AAAA,UACb,WAAW,KAAK,YAAY;AAAA,UAC5B,qBAAqB,aAAa,aAAa;AAAA,QACjD;AAAA,MACF,CAAC;AAAA,IACH;AACA,QAAI,YAAY,WAAW,eAAe;AACxC,aAAO,CAAC;AAAA,IACV;AACA,UAAM,WAAW,kBACb,KAAK,IAAI,KAAK,UAAU,YAAY,mBAAmB,IACvD,KAAK;AACT,QAAI,YAAY,GAAG;AACjB,aAAO,CAAC;AAAA,IACV;AACA,WAAO;AAAA,MACL;AAAA,QACE,GAAG;AAAA,QACH;AAAA,QACA,aAAa;AAAA,UACX,GAAG,KAAK;AAAA,UACR,OAAO,YAAY;AAAA,UACnB,gBAAgB,YAAY;AAAA,QAC9B;AAAA,MACF;AAAA,IACF;AAAA,EACF,CAAC;AACD,SAAO,oBAAoB,EAAE,GAAG,MAAM,MAAM,CAAC;AAC/C;AAYO,SAAS,oBAAoB;AAAA,EAClC;AAAA,EACA;AACF,GAGe;AACb,QAAM,mBAAmB,2BAA2B,SAAS;AAC7D,QAAM,oBAAoB,UAAU,OAAO,CAAC,KAAK,SAAS;AACxD,WAAO,MAAM,mBAAmB,IAAI,EAAE,iBAAiB,KAAK;AAAA,EAC9D,GAAG,CAAC;AAOJ,QAAM,QAAQ,MAAM,KAAK,eAAe;AAIxC,QAAM,kBAAkB,MAAM,uBAAuB,CAAC,GAAG;AAAA,IACvD,CAAC,KAAK,eAAe,MAAM,WAAW;AAAA,IACtC;AAAA,EACF;AAEA,QAAM,eAAe,oBAAoB;AAEzC,SAAO;AAAA,IACL,UAAU;AAAA,IACV;AAAA,IACA;AAAA,IACA;AAAA,EACF;AACF;AAWO,SAAS,YAAY,OAAe,WAAmB,OAAe;AAC3E,SAAO,aAAa,EAAE,QAAQ,OAAO,cAAc,SAAS,CAAC;AAC/D;AAQO,SAAS,oBACd,eACe;AACf,MAAI,CAAC,eAAe;AAClB,WAAO;AAAA,EACT;AAGA,MAAI,cAAc,WAAW,4BAA4B,GAAG;AAC1D,WAAO;AAAA,EACT;AAGA,SAAO,6BAA6B,aAAa;AACnD;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -11,6 +11,7 @@ interface CreateCartOptions {
|
|
|
11
11
|
lines?: CartLinePayload[];
|
|
12
12
|
lineIdGenerator: () => string;
|
|
13
13
|
cartIdGenerator: () => string;
|
|
14
|
+
reconcile?: (cart: Cart) => Promise<Cart>;
|
|
14
15
|
}
|
|
15
16
|
/**
|
|
16
17
|
* Options for adding to cart with persistence
|
|
@@ -19,6 +20,7 @@ interface AddToCartOptions {
|
|
|
19
20
|
cookieName: string;
|
|
20
21
|
mergeFn: LineMergeFn;
|
|
21
22
|
lineIdGenerator: () => string;
|
|
23
|
+
reconcile?: (cart: Cart) => Promise<Cart>;
|
|
22
24
|
}
|
|
23
25
|
/**
|
|
24
26
|
* Save cart to cookie
|
|
@@ -35,7 +37,7 @@ export declare function clearCartCookie(cookieName: string): Promise<void>;
|
|
|
35
37
|
/**
|
|
36
38
|
* Update cart in cookie atomically (load-transform-save pattern)
|
|
37
39
|
*/
|
|
38
|
-
export declare function updateCartInCookie(cartId: string, cookieName: string, updater: (cart: Cart) => Cart): Promise<Cart | null>;
|
|
40
|
+
export declare function updateCartInCookie(cartId: string, cookieName: string, updater: (cart: Cart) => Cart, reconcile?: (cart: Cart) => Promise<Cart>): Promise<Cart | null>;
|
|
39
41
|
/**
|
|
40
42
|
* Create a new cart with persistence
|
|
41
43
|
*/
|
|
@@ -47,7 +49,7 @@ export declare function addToCartWithPersistence(payload: AddToCartPayload, opti
|
|
|
47
49
|
/**
|
|
48
50
|
* Update cart line item
|
|
49
51
|
*/
|
|
50
|
-
export declare function updateCartLineItemWithPersistence(payload: UpdateCartLineItemPayload, cookieName: string): Promise<Cart | null>;
|
|
52
|
+
export declare function updateCartLineItemWithPersistence(payload: UpdateCartLineItemPayload, cookieName: string, reconcile?: (cart: Cart) => Promise<Cart>): Promise<Cart | null>;
|
|
51
53
|
/**
|
|
52
54
|
* Remove cart line items
|
|
53
55
|
*/
|
|
@@ -24,12 +24,15 @@ async function clearCartCookie(cookieName) {
|
|
|
24
24
|
const cookieStore = await cookies();
|
|
25
25
|
cookieStore.set(cookieName, "", { ...getCartCookieOptions(), maxAge: 0 });
|
|
26
26
|
}
|
|
27
|
-
async function updateCartInCookie(cartId, cookieName, updater) {
|
|
27
|
+
async function updateCartInCookie(cartId, cookieName, updater, reconcile) {
|
|
28
28
|
const existingCart = await loadCartFromCookie(cookieName);
|
|
29
29
|
if (!existingCart || existingCart.id !== cartId) {
|
|
30
30
|
return null;
|
|
31
31
|
}
|
|
32
|
-
|
|
32
|
+
let updatedCart = recalculateCartCost(updater(existingCart));
|
|
33
|
+
if (reconcile) {
|
|
34
|
+
updatedCart = await reconcile(updatedCart);
|
|
35
|
+
}
|
|
33
36
|
await saveCartToCookie(updatedCart, cookieName);
|
|
34
37
|
return updatedCart;
|
|
35
38
|
}
|
|
@@ -50,7 +53,10 @@ async function createCartWithPersistence(options) {
|
|
|
50
53
|
convertCartLinePayloadToCartLine(line, options.lineIdGenerator)
|
|
51
54
|
);
|
|
52
55
|
}
|
|
53
|
-
|
|
56
|
+
let cartWithCost = recalculateCartCost(cart);
|
|
57
|
+
if (options.reconcile) {
|
|
58
|
+
cartWithCost = await options.reconcile(cartWithCost);
|
|
59
|
+
}
|
|
54
60
|
await saveCartToCookie(cartWithCost, options.cookieName);
|
|
55
61
|
return cartWithCost;
|
|
56
62
|
}
|
|
@@ -72,29 +78,37 @@ async function addToCartWithPersistence(payload, options) {
|
|
|
72
78
|
}
|
|
73
79
|
}
|
|
74
80
|
return cart;
|
|
75
|
-
}
|
|
81
|
+
},
|
|
82
|
+
options.reconcile
|
|
76
83
|
);
|
|
77
84
|
}
|
|
78
|
-
async function updateCartLineItemWithPersistence(payload, cookieName) {
|
|
79
|
-
return await updateCartInCookie(
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
)
|
|
83
|
-
|
|
85
|
+
async function updateCartLineItemWithPersistence(payload, cookieName, reconcile) {
|
|
86
|
+
return await updateCartInCookie(
|
|
87
|
+
payload.cartId,
|
|
88
|
+
cookieName,
|
|
89
|
+
(cart) => {
|
|
90
|
+
const lineIndex = cart.lines.findIndex(
|
|
91
|
+
(line2) => line2.id === payload.line.id
|
|
92
|
+
);
|
|
93
|
+
if (lineIndex < 0) {
|
|
94
|
+
return cart;
|
|
95
|
+
}
|
|
84
96
|
if (payload.line.quantity <= 0) {
|
|
85
97
|
cart.lines.splice(lineIndex, 1);
|
|
86
|
-
|
|
87
|
-
cart.lines[lineIndex].quantity = payload.line.quantity;
|
|
88
|
-
if (payload.line.properties) {
|
|
89
|
-
cart.lines[lineIndex].attributes = payload.line.properties;
|
|
90
|
-
}
|
|
91
|
-
if (payload.line.merchandise) {
|
|
92
|
-
cart.lines[lineIndex].merchandise = payload.line.merchandise;
|
|
93
|
-
}
|
|
98
|
+
return cart;
|
|
94
99
|
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
100
|
+
const line = cart.lines[lineIndex];
|
|
101
|
+
line.quantity = payload.line.quantity;
|
|
102
|
+
if (payload.line.properties) {
|
|
103
|
+
line.attributes = payload.line.properties;
|
|
104
|
+
}
|
|
105
|
+
if (payload.line.merchandise) {
|
|
106
|
+
line.merchandise = payload.line.merchandise;
|
|
107
|
+
}
|
|
108
|
+
return cart;
|
|
109
|
+
},
|
|
110
|
+
reconcile
|
|
111
|
+
);
|
|
98
112
|
}
|
|
99
113
|
async function removeCartLineItemsWithPersistence(payload, cookieName) {
|
|
100
114
|
return await updateCartInCookie(payload.cartId, cookieName, (cart) => {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../cart/utils/cookie-cart-persistence.ts"],
|
|
4
|
-
"sourcesContent": ["\"use server\";\n\nimport type {\n AddToCartPayload,\n Cart,\n CartLine,\n CartLinePayload,\n RemoveFromCartPayload,\n UpdateCartLineItemPayload,\n} from \"../cart-types\";\n\nimport { cookies } from \"next/headers\";\n\nimport { getCartCookieOptions } from \"../gateways/cart-constants\";\nimport { recalculateCartCost } from \"./cart-utils\";\n\n/**\n * Line merge function type - returns index of existing line to merge with, or -1 to add as new line\n */\nexport type LineMergeFn = (\n existingLines: CartLine[],\n newLine: CartLinePayload,\n) => number;\n\n/**\n * Options for creating a cart with persistence\n */\ninterface CreateCartOptions {\n cookieName: string;\n lines?: CartLinePayload[];\n lineIdGenerator: () => string;\n cartIdGenerator: () => string;\n}\n\n/**\n * Options for adding to cart with persistence\n */\ninterface AddToCartOptions {\n cookieName: string;\n mergeFn: LineMergeFn;\n lineIdGenerator: () => string;\n}\n\n/**\n * Save cart to cookie\n */\nexport async function saveCartToCookie(cart: Cart, cookieName: string) {\n const cookieStore = await cookies();\n cookieStore.set(cookieName, JSON.stringify(cart), getCartCookieOptions());\n}\n\n/**\n * Load cart from cookie\n */\nexport async function loadCartFromCookie(\n cookieName: string,\n): Promise<Cart | null> {\n const cookieStore = await cookies();\n const cartCookie = cookieStore.get(cookieName);\n\n if (!cartCookie?.value) {\n return null;\n }\n\n try {\n // Legacy cast - may or may not be necessary\n // eslint-disable-next-line replo/no-as-cast\n return JSON.parse(cartCookie.value) as Cart;\n } catch (error) {\n console.warn(\"Failed to parse cart cookie:\", error);\n await clearCartCookie(cookieName);\n return null;\n }\n}\n\n/**\n * Clear cart cookie\n */\nexport async function clearCartCookie(cookieName: string) {\n const cookieStore = await cookies();\n // NOTE (Max, 2026-08-25): `delete` omits Partitioned, which leaves the\n // partitioned cookie in place \u2014 expire it with the attributes it was set with.\n cookieStore.set(cookieName, \"\", { ...getCartCookieOptions(), maxAge: 0 });\n}\n\n/**\n * Update cart in cookie atomically (load-transform-save pattern)\n */\nexport async function updateCartInCookie(\n cartId: string,\n cookieName: string,\n updater: (cart: Cart) => Cart,\n): Promise<Cart | null> {\n const existingCart = await loadCartFromCookie(cookieName);\n\n if (!existingCart || existingCart.id !== cartId) {\n return null;\n }\n\n
|
|
5
|
-
"mappings": ";AAWA,SAAS,eAAe;AAExB,SAAS,4BAA4B;AACrC,SAAS,2BAA2B;
|
|
6
|
-
"names": []
|
|
4
|
+
"sourcesContent": ["\"use server\";\n\nimport type {\n AddToCartPayload,\n Cart,\n CartLine,\n CartLinePayload,\n RemoveFromCartPayload,\n UpdateCartLineItemPayload,\n} from \"../cart-types\";\n\nimport { cookies } from \"next/headers\";\n\nimport { getCartCookieOptions } from \"../gateways/cart-constants\";\nimport { recalculateCartCost } from \"./cart-utils\";\n\n/**\n * Line merge function type - returns index of existing line to merge with, or -1 to add as new line\n */\nexport type LineMergeFn = (\n existingLines: CartLine[],\n newLine: CartLinePayload,\n) => number;\n\n/**\n * Options for creating a cart with persistence\n */\ninterface CreateCartOptions {\n cookieName: string;\n lines?: CartLinePayload[];\n lineIdGenerator: () => string;\n cartIdGenerator: () => string;\n reconcile?: (cart: Cart) => Promise<Cart>;\n}\n\n/**\n * Options for adding to cart with persistence\n */\ninterface AddToCartOptions {\n cookieName: string;\n mergeFn: LineMergeFn;\n lineIdGenerator: () => string;\n reconcile?: (cart: Cart) => Promise<Cart>;\n}\n\n/**\n * Save cart to cookie\n */\nexport async function saveCartToCookie(cart: Cart, cookieName: string) {\n const cookieStore = await cookies();\n cookieStore.set(cookieName, JSON.stringify(cart), getCartCookieOptions());\n}\n\n/**\n * Load cart from cookie\n */\nexport async function loadCartFromCookie(\n cookieName: string,\n): Promise<Cart | null> {\n const cookieStore = await cookies();\n const cartCookie = cookieStore.get(cookieName);\n\n if (!cartCookie?.value) {\n return null;\n }\n\n try {\n // Legacy cast - may or may not be necessary\n // eslint-disable-next-line replo/no-as-cast\n return JSON.parse(cartCookie.value) as Cart;\n } catch (error) {\n console.warn(\"Failed to parse cart cookie:\", error);\n await clearCartCookie(cookieName);\n return null;\n }\n}\n\n/**\n * Clear cart cookie\n */\nexport async function clearCartCookie(cookieName: string) {\n const cookieStore = await cookies();\n // NOTE (Max, 2026-08-25): `delete` omits Partitioned, which leaves the\n // partitioned cookie in place \u2014 expire it with the attributes it was set with.\n cookieStore.set(cookieName, \"\", { ...getCartCookieOptions(), maxAge: 0 });\n}\n\n/**\n * Update cart in cookie atomically (load-transform-save pattern)\n */\nexport async function updateCartInCookie(\n cartId: string,\n cookieName: string,\n updater: (cart: Cart) => Cart,\n reconcile?: (cart: Cart) => Promise<Cart>,\n): Promise<Cart | null> {\n const existingCart = await loadCartFromCookie(cookieName);\n\n if (!existingCart || existingCart.id !== cartId) {\n return null;\n }\n\n let updatedCart = recalculateCartCost(updater(existingCart));\n if (reconcile) {\n updatedCart = await reconcile(updatedCart);\n }\n\n await saveCartToCookie(updatedCart, cookieName);\n\n return updatedCart;\n}\n\n/**\n * Create a new cart with persistence\n */\nexport async function createCartWithPersistence(\n options: CreateCartOptions,\n): Promise<Cart | null> {\n const cartId = options.cartIdGenerator();\n\n const cart: Cart = {\n id: cartId,\n lines: [],\n cost: {\n subtotalAmount: 0,\n totalAmount: 0,\n currencyCode: \"USD\",\n },\n checkoutUrl: \"/checkout\",\n };\n\n for (const line of options.lines ?? []) {\n cart.lines.push(\n convertCartLinePayloadToCartLine(line, options.lineIdGenerator),\n );\n }\n\n let cartWithCost = recalculateCartCost(cart);\n if (options.reconcile) {\n cartWithCost = await options.reconcile(cartWithCost);\n }\n await saveCartToCookie(cartWithCost, options.cookieName);\n return cartWithCost;\n}\n\n/**\n * Add lines to cart with configurable line merging strategy\n */\nexport async function addToCartWithPersistence(\n payload: AddToCartPayload,\n options: AddToCartOptions,\n): Promise<Cart | null> {\n return await updateCartInCookie(\n payload.cartId,\n options.cookieName,\n (cart) => {\n for (const linePayload of payload.lines) {\n const existingLineIndex = options.mergeFn(cart.lines, linePayload);\n\n if (existingLineIndex >= 0) {\n cart.lines[existingLineIndex]!.quantity += linePayload.quantity;\n } else {\n const newLine: CartLine = convertCartLinePayloadToCartLine(\n linePayload,\n options.lineIdGenerator,\n );\n cart.lines.push(newLine);\n }\n }\n\n return cart;\n },\n options.reconcile,\n );\n}\n\n/**\n * Update cart line item\n */\nexport async function updateCartLineItemWithPersistence(\n payload: UpdateCartLineItemPayload,\n cookieName: string,\n reconcile?: (cart: Cart) => Promise<Cart>,\n): Promise<Cart | null> {\n return await updateCartInCookie(\n payload.cartId,\n cookieName,\n (cart) => {\n const lineIndex = cart.lines.findIndex(\n (line) => line.id === payload.line.id,\n );\n if (lineIndex < 0) {\n return cart;\n }\n if (payload.line.quantity <= 0) {\n cart.lines.splice(lineIndex, 1);\n return cart;\n }\n const line = cart.lines[lineIndex]!;\n line.quantity = payload.line.quantity;\n if (payload.line.properties) {\n line.attributes = payload.line.properties;\n }\n if (payload.line.merchandise) {\n line.merchandise = payload.line.merchandise;\n }\n return cart;\n },\n reconcile,\n );\n}\n\n/**\n * Remove cart line items\n */\nexport async function removeCartLineItemsWithPersistence(\n payload: RemoveFromCartPayload,\n cookieName: string,\n): Promise<Cart | null> {\n return await updateCartInCookie(payload.cartId, cookieName, (cart) => {\n const removeSet = new Set(payload.lineIds);\n cart.lines = cart.lines.filter((line) => !removeSet.has(line.id));\n return cart;\n });\n}\n\n/**\n * Convert CartLinePayload to CartLine with custom ID generator\n */\nfunction convertCartLinePayloadToCartLine(\n linePayload: CartLinePayload,\n lineIdGenerator: () => string,\n): CartLine {\n // Cookie-persisted carts (Stripe/default) are their own source of truth \u2014\n // there's no server roundtrip to backfill merchandise later. Fall back to a\n // placeholder when the caller didn't pass merchandise so the persisted line\n // is renderable; otherwise an unresolvable line would render as a skeleton\n // forever. Shopify carts hit a different code path and use the skeleton\n // treatment in cart-provider.tsx.\n const fallbackMerchandise = {\n id: linePayload.id,\n product: {\n id: linePayload.merchandise?.product.id ?? linePayload.id,\n title: linePayload.merchandise?.product.title ?? \"Product\",\n },\n title: linePayload.merchandise?.title ?? \"Product Variant\",\n price: linePayload.merchandise?.price ?? 0,\n compareAtPrice: linePayload.merchandise?.compareAtPrice ?? null,\n image: linePayload.merchandise?.image\n ? {\n url: linePayload.merchandise.image.url,\n altText: linePayload.merchandise.image.altText,\n }\n : null,\n selectedOptions: linePayload.merchandise?.selectedOptions ?? [],\n };\n\n return {\n id: lineIdGenerator(),\n quantity: linePayload.quantity,\n merchandise: linePayload.merchandise ?? fallbackMerchandise,\n attributes: linePayload.properties ?? [],\n sellingPlanAllocation: linePayload.sellingPlanId\n ? {\n sellingPlan: {\n id: linePayload.sellingPlanId,\n name: \"Subscription Plan\",\n description: null,\n },\n priceAdjustments: [],\n checkoutChargeAmount: { amount: 0, currencyCode: \"USD\" },\n remainingBalanceChargeAmount: { amount: 0, currencyCode: \"USD\" },\n }\n : null,\n };\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": ["line"]
|
|
7
7
|
}
|
package/gateways/canopy-api.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Cart } from "../cart/cart-schema";
|
|
2
|
+
import { z } from "zod";
|
|
2
3
|
import { CanopyError } from "../lib/canopy-error";
|
|
3
4
|
type CanopyApiGatewayErrorConfig = {
|
|
4
5
|
message: string;
|
|
@@ -10,6 +11,18 @@ export declare class CanopyApiGatewayError extends CanopyError {
|
|
|
10
11
|
statusCode: number;
|
|
11
12
|
constructor(config: CanopyApiGatewayErrorConfig);
|
|
12
13
|
}
|
|
14
|
+
declare const reconciledCartLineSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
15
|
+
variantId: z.ZodString;
|
|
16
|
+
status: z.ZodLiteral<"purchasable">;
|
|
17
|
+
unitPriceMinor: z.ZodNumber;
|
|
18
|
+
compareAtPriceMinor: z.ZodNullable<z.ZodNumber>;
|
|
19
|
+
currencyCode: z.ZodString;
|
|
20
|
+
purchasableQuantity: z.ZodNumber;
|
|
21
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
22
|
+
variantId: z.ZodString;
|
|
23
|
+
status: z.ZodLiteral<"unavailable">;
|
|
24
|
+
}, z.core.$strip>], "status">;
|
|
25
|
+
export type ReconciledCartLine = z.infer<typeof reconciledCartLineSchema>;
|
|
13
26
|
declare class CanopyApiGateway {
|
|
14
27
|
private apiHost;
|
|
15
28
|
private baseUrl;
|
|
@@ -30,6 +43,18 @@ declare class CanopyApiGateway {
|
|
|
30
43
|
};
|
|
31
44
|
} | undefined;
|
|
32
45
|
}>;
|
|
46
|
+
/**
|
|
47
|
+
* The catalog's current answer for a set of cookie-cart lines: per line,
|
|
48
|
+
* the live price and how many units are actually purchasable. Used to
|
|
49
|
+
* reconcile the client-persisted cart, which otherwise never re-reads the
|
|
50
|
+
* catalog facts it snapshotted at add-to-cart time.
|
|
51
|
+
*/
|
|
52
|
+
reconcileCartLines({ lines, }: {
|
|
53
|
+
lines: {
|
|
54
|
+
variantId: string;
|
|
55
|
+
quantity: number;
|
|
56
|
+
}[];
|
|
57
|
+
}): Promise<ReconciledCartLine[]>;
|
|
33
58
|
private _cartPost;
|
|
34
59
|
cartGet({ cartId }: {
|
|
35
60
|
cartId: string;
|
package/gateways/canopy-api.js
CHANGED
|
@@ -32,6 +32,23 @@ const loaderInvokeResponseSchema = z.object({
|
|
|
32
32
|
})
|
|
33
33
|
}).optional()
|
|
34
34
|
});
|
|
35
|
+
const reconciledCartLineSchema = z.discriminatedUnion("status", [
|
|
36
|
+
z.object({
|
|
37
|
+
variantId: z.string(),
|
|
38
|
+
status: z.literal("purchasable"),
|
|
39
|
+
unitPriceMinor: z.number().int(),
|
|
40
|
+
compareAtPriceMinor: z.number().int().nullable(),
|
|
41
|
+
currencyCode: z.string(),
|
|
42
|
+
purchasableQuantity: z.number().int().min(0)
|
|
43
|
+
}),
|
|
44
|
+
z.object({
|
|
45
|
+
variantId: z.string(),
|
|
46
|
+
status: z.literal("unavailable")
|
|
47
|
+
})
|
|
48
|
+
]);
|
|
49
|
+
const reconcileCartLinesResponseSchema = z.object({
|
|
50
|
+
lines: z.array(reconciledCartLineSchema)
|
|
51
|
+
});
|
|
35
52
|
class CanopyApiGateway {
|
|
36
53
|
apiHost;
|
|
37
54
|
baseUrl;
|
|
@@ -112,6 +129,29 @@ If you're running locally, make sure the canopy-api cloudflare worker is running
|
|
|
112
129
|
}
|
|
113
130
|
return loaderInvokeResponseSchema.parse(await response.json());
|
|
114
131
|
}
|
|
132
|
+
/**
|
|
133
|
+
* The catalog's current answer for a set of cookie-cart lines: per line,
|
|
134
|
+
* the live price and how many units are actually purchasable. Used to
|
|
135
|
+
* reconcile the client-persisted cart, which otherwise never re-reads the
|
|
136
|
+
* catalog facts it snapshotted at add-to-cart time.
|
|
137
|
+
*/
|
|
138
|
+
async reconcileCartLines({
|
|
139
|
+
lines
|
|
140
|
+
}) {
|
|
141
|
+
const env = await getEnv();
|
|
142
|
+
const response = await this._post(
|
|
143
|
+
`${this.baseUrl}/api/v1/product-catalog/reconcile-cart-lines`,
|
|
144
|
+
{ projectId: env.PROJECT_ID, lines }
|
|
145
|
+
);
|
|
146
|
+
if (!response.ok) {
|
|
147
|
+
const errorBody = await response.text().catch(() => "");
|
|
148
|
+
throw new CanopyApiGatewayError({
|
|
149
|
+
message: `Failed to reconcile cart lines: ${response.status} ${response.statusText}${errorBody ? ` \u2014 ${errorBody}` : ""}`,
|
|
150
|
+
statusCode: response.status
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
return reconcileCartLinesResponseSchema.parse(await response.json()).lines;
|
|
154
|
+
}
|
|
115
155
|
// ---------------------------------------------------------------------------
|
|
116
156
|
// Cart operations (canopy-api-backed ExternalCart)
|
|
117
157
|
// ---------------------------------------------------------------------------
|