@replohq/sdk 1.4.1 → 1.6.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/_vendor/schemas/loaderKeys.mjs +2 -0
- package/cart/cart-actions.js +2 -1
- package/cart/cart-actions.js.map +2 -2
- package/cart/cart-provider.d.ts +15 -3
- package/cart/cart-provider.js +49 -6
- package/cart/cart-provider.js.map +2 -2
- 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 +3 -1
- package/cart/hooks/use-add-to-cart.js +1 -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-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/judgeme-review-aggregate-loader.d.ts +12 -0
- package/loaders/judgeme-review-aggregate-loader.js +50 -0
- package/loaders/judgeme-review-aggregate-loader.js.map +7 -0
- package/loaders/judgeme-reviews-loader.d.ts +14 -0
- package/loaders/judgeme-reviews-loader.js +65 -0
- package/loaders/judgeme-reviews-loader.js.map +7 -0
- package/loaders/loader-keys.d.ts +2 -0
- package/loaders/loader-keys.js +2 -0
- package/loaders/loader-keys.js.map +2 -2
- package/loaders/loader-schemas.d.ts +30 -0
- package/loaders/loader-schemas.js +22 -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 +12 -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/gateways/cookie-cart-gateway.ts"],
|
|
4
|
-
"sourcesContent": ["import type {\n AddToCartPayload,\n Cart,\n CartLinePayload,\n RemoveFromCartPayload,\n UpdateCartDiscountCodesPayload,\n UpdateCartLineItemPayload,\n} from \"../cart-types\";\nimport type { LineMergeFn } from \"../utils/cookie-cart-persistence\";\nimport type { CartGateway } from \"./cart-gateway\";\n\nimport {\n addToCartWithPersistence,\n createCartWithPersistence,\n loadCartFromCookie,\n removeCartLineItemsWithPersistence,\n updateCartLineItemWithPersistence,\n} from \"../utils/cookie-cart-persistence\";\n\n/**\n * Configuration for cookie-based cart gateways\n */\ninterface CookieCartConfig {\n cookieName: string;\n lineMergeFn: LineMergeFn;\n lineIdGenerator: () => string;\n cartIdGenerator: () => string;\n gatewayName: string; // For logging (e.g., \"Stripe\", \"Default\")\n}\n\n/**\n * Abstract base class for cookie-based cart gateways.\n * Provides common error handling, logging, and cart operations.\n * Subclasses must provide gateway-specific configuration.\n */\nexport abstract class CookieBasedCartGateway implements CartGateway {\n protected abstract readonly config: CookieCartConfig;\n\n async getCart(cartId: string): Promise<Cart | null> {\n try {\n const cart = await loadCartFromCookie(this.config.cookieName);\n return cart?.id === cartId ? cart : null;\n } catch (error) {\n console.warn(\n `${this.config.gatewayName} cart ${cartId} not found, could not fetch cart:`,\n error,\n );\n return null;\n }\n }\n\n async createCart(\n lines?: CartLinePayload[],\n _discountCodes?: string[],\n ): Promise<Cart | null> {\n try {\n const cart = await createCartWithPersistence({\n cookieName: this.config.cookieName,\n lines,\n lineIdGenerator: this.config.lineIdGenerator,\n cartIdGenerator: this.config.cartIdGenerator,\n });\n return cart;\n } catch (createCartError) {\n console.warn(\n `Failed to create ${this.config.gatewayName} cart${lines?.length ? \" with lines\" : \"\"}:`,\n createCartError,\n );\n return null;\n }\n }\n\n async addToCart(cartPayload: AddToCartPayload): Promise<Cart | null> {\n try {\n const cartData = await addToCartWithPersistence(cartPayload, {\n cookieName: this.config.cookieName,\n mergeFn: this.config.lineMergeFn,\n lineIdGenerator: this.config.lineIdGenerator,\n });\n return cartData;\n } catch (error) {\n console.warn(`Failed to add items to ${this.config.gatewayName} cart:`, {\n error: error instanceof Error ? error.message : String(error),\n cartId: cartPayload.cartId,\n itemCount: cartPayload.lines.length,\n });\n return null;\n }\n }\n\n async updateCartLineItem(\n cartPayload: UpdateCartLineItemPayload,\n ): Promise<Cart | null> {\n try {\n const cartData = await updateCartLineItemWithPersistence(\n cartPayload,\n this.config.cookieName,\n );\n return cartData;\n } catch (error) {\n console.warn(\n `Failed to update ${this.config.gatewayName} cart line item:`,\n {\n error: error instanceof Error ? error.message : String(error),\n cartId: cartPayload.cartId,\n lineId: cartPayload.line.id,\n quantity: cartPayload.line.quantity,\n },\n );\n return null;\n }\n }\n\n async removeCartLineItems(\n cartPayload: RemoveFromCartPayload,\n ): Promise<Cart | null> {\n try {\n const cartData = await removeCartLineItemsWithPersistence(\n cartPayload,\n this.config.cookieName,\n );\n return cartData;\n } catch (error) {\n console.warn(\n `Failed to remove ${this.config.gatewayName} cart line items:`,\n {\n error: error instanceof Error ? error.message : String(error),\n cartId: cartPayload.cartId,\n lineIds: cartPayload.lineIds,\n },\n );\n return null;\n }\n }\n\n async updateCartDiscountCodes(\n cartPayload: UpdateCartDiscountCodesPayload,\n ): Promise<Cart | null> {\n console.warn(\n `${this.config.gatewayName} cart gateway does not yet support discount codes`,\n );\n // Return existing cart without changes\n try {\n return await this.getCart(cartPayload.cartId);\n } catch (error) {\n console.warn(\"Failed to fetch cart for discount code update:\", error);\n return null;\n }\n }\n}\n"],
|
|
5
|
-
"mappings": "
|
|
4
|
+
"sourcesContent": ["import type { ReconciledCartLine } from \"../../gateways/canopy-api\";\nimport type {\n AddToCartPayload,\n Cart,\n CartLinePayload,\n RemoveFromCartPayload,\n UpdateCartDiscountCodesPayload,\n UpdateCartLineItemPayload,\n} from \"../cart-types\";\nimport type { LineMergeFn } from \"../utils/cookie-cart-persistence\";\nimport type { CartGateway } from \"./cart-gateway\";\n\nimport { applyCartLineReconciliation } from \"../utils/cart-utils\";\nimport {\n addToCartWithPersistence,\n createCartWithPersistence,\n loadCartFromCookie,\n removeCartLineItemsWithPersistence,\n saveCartToCookie,\n updateCartLineItemWithPersistence,\n} from \"../utils/cookie-cart-persistence\";\n\n/**\n * Configuration for cookie-based cart gateways\n */\ninterface CookieCartConfig {\n cookieName: string;\n lineMergeFn: LineMergeFn;\n lineIdGenerator: () => string;\n cartIdGenerator: () => string;\n gatewayName: string; // For logging (e.g., \"Stripe\", \"Default\")\n // The catalog's per-line facts for this cart's variants. When present,\n // every cart write reconciles against it (reprice, clamp, drop\n // unavailable); absent means the gateway has no catalog to ask.\n reconcileLines?: (\n lines: { variantId: string; quantity: number }[],\n ) => Promise<ReconciledCartLine[]>;\n}\n\n/**\n * Abstract base class for cookie-based cart gateways.\n * Provides common error handling, logging, and cart operations.\n * Subclasses must provide gateway-specific configuration.\n */\nexport abstract class CookieBasedCartGateway implements CartGateway {\n protected abstract readonly config: CookieCartConfig;\n\n // Fails open (null) when the catalog can't answer: a canopy blip must not\n // block cart writes \u2014 checkout still enforces stock and price.\n private async fetchReconciledLines(\n cart: Cart,\n ): Promise<ReconciledCartLine[] | null> {\n const reconcileLines = this.config.reconcileLines;\n if (!reconcileLines) {\n return null;\n }\n const requestLines = cart.lines.flatMap((line) => {\n return line.merchandise\n ? [{ variantId: line.merchandise.id, quantity: line.quantity }]\n : [];\n });\n if (requestLines.length === 0) {\n return null;\n }\n try {\n return await reconcileLines(requestLines);\n } catch (error) {\n console.warn(\n `${this.config.gatewayName} cart reconciliation unavailable, keeping cart as written:`,\n error,\n );\n return null;\n }\n }\n\n // Catalog fetch failures fail open inside fetchReconciledLines; a\n // reconciliation mismatch from the apply still throws.\n private async reconcile(\n cart: Cart,\n { clampQuantities }: { clampQuantities: boolean },\n ): Promise<Cart> {\n const reconciledLines = await this.fetchReconciledLines(cart);\n if (!reconciledLines) {\n return cart;\n }\n return applyCartLineReconciliation(cart, reconciledLines, {\n clampQuantities,\n });\n }\n\n private reconcileForWrite = (cart: Cart): Promise<Cart> => {\n return this.reconcile(cart, { clampQuantities: true });\n };\n\n async reconcileStoredCart(cart: Cart): Promise<Cart> {\n // Reads reprice and drop unavailable lines but never clamp quantities \u2014\n // silent quantity changes are the write and checkout paths' job, where\n // they come with messaging.\n const reconciledCart = await this.reconcile(cart, {\n clampQuantities: false,\n });\n // Lines are the only thing reconciliation can change; cost derives from them.\n if (JSON.stringify(reconciledCart.lines) !== JSON.stringify(cart.lines)) {\n await saveCartToCookie(reconciledCart, this.config.cookieName);\n }\n return reconciledCart;\n }\n\n async getCart(cartId: string): Promise<Cart | null> {\n try {\n const cart = await loadCartFromCookie(this.config.cookieName);\n return cart?.id === cartId ? cart : null;\n } catch (error) {\n console.warn(\n `${this.config.gatewayName} cart ${cartId} not found, could not fetch cart:`,\n error,\n );\n return null;\n }\n }\n\n async createCart(\n lines?: CartLinePayload[],\n _discountCodes?: string[],\n ): Promise<Cart | null> {\n try {\n const cart = await createCartWithPersistence({\n cookieName: this.config.cookieName,\n lines,\n lineIdGenerator: this.config.lineIdGenerator,\n cartIdGenerator: this.config.cartIdGenerator,\n reconcile: this.reconcileForWrite,\n });\n return cart;\n } catch (createCartError) {\n console.warn(\n `Failed to create ${this.config.gatewayName} cart${lines?.length ? \" with lines\" : \"\"}:`,\n createCartError,\n );\n return null;\n }\n }\n\n async addToCart(cartPayload: AddToCartPayload): Promise<Cart | null> {\n try {\n const cartData = await addToCartWithPersistence(cartPayload, {\n cookieName: this.config.cookieName,\n mergeFn: this.config.lineMergeFn,\n lineIdGenerator: this.config.lineIdGenerator,\n reconcile: this.reconcileForWrite,\n });\n return cartData;\n } catch (error) {\n console.warn(`Failed to add items to ${this.config.gatewayName} cart:`, {\n error: error instanceof Error ? error.message : String(error),\n cartId: cartPayload.cartId,\n itemCount: cartPayload.lines.length,\n });\n return null;\n }\n }\n\n async updateCartLineItem(\n cartPayload: UpdateCartLineItemPayload,\n ): Promise<Cart | null> {\n try {\n const cartData = await updateCartLineItemWithPersistence(\n cartPayload,\n this.config.cookieName,\n this.reconcileForWrite,\n );\n return cartData;\n } catch (error) {\n console.warn(\n `Failed to update ${this.config.gatewayName} cart line item:`,\n {\n error: error instanceof Error ? error.message : String(error),\n cartId: cartPayload.cartId,\n lineId: cartPayload.line.id,\n quantity: cartPayload.line.quantity,\n },\n );\n return null;\n }\n }\n\n async removeCartLineItems(\n cartPayload: RemoveFromCartPayload,\n ): Promise<Cart | null> {\n try {\n const cartData = await removeCartLineItemsWithPersistence(\n cartPayload,\n this.config.cookieName,\n );\n return cartData;\n } catch (error) {\n console.warn(\n `Failed to remove ${this.config.gatewayName} cart line items:`,\n {\n error: error instanceof Error ? error.message : String(error),\n cartId: cartPayload.cartId,\n lineIds: cartPayload.lineIds,\n },\n );\n return null;\n }\n }\n\n async updateCartDiscountCodes(\n cartPayload: UpdateCartDiscountCodesPayload,\n ): Promise<Cart | null> {\n console.warn(\n `${this.config.gatewayName} cart gateway does not yet support discount codes`,\n );\n // Return existing cart without changes\n try {\n return await this.getCart(cartPayload.cartId);\n } catch (error) {\n console.warn(\"Failed to fetch cart for discount code update:\", error);\n return null;\n }\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
"use server";
|
|
2
2
|
import { getCanopyApiGateway } from "../../gateways/canopy-api";
|
|
3
3
|
class ResolverCartGateway {
|
|
4
|
+
// Shopify carts are fetched from Shopify on every read — already reconciled.
|
|
5
|
+
async reconcileStoredCart(cart) {
|
|
6
|
+
return cart;
|
|
7
|
+
}
|
|
4
8
|
// Returns the gateway's wire-parsed Cart where the client Cart is expected.
|
|
5
9
|
// Safe because client = wire + widenings only — enforced by the widening
|
|
6
10
|
// assertions in cart-schema.spec.ts.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../cart/gateways/resolver-cart-gateway.ts"],
|
|
4
|
-
"sourcesContent": ["\"use server\";\n\nimport type {\n AddToCartPayload,\n Cart,\n CartLinePayload,\n RemoveFromCartPayload,\n UpdateCartDiscountCodesPayload,\n UpdateCartLineItemPayload,\n} from \"../cart-types\";\nimport type { CartGateway } from \"./cart-gateway\";\n\nimport { getCanopyApiGateway } from \"../../gateways/canopy-api\";\n\n/**\n * @author Ryan\n * CartGateway that delegates all mutations to the canopy-api's cart\n * endpoints. Used for \"externalCart\" integrations (Shopify). canopy-api\n * handles credential resolution and upstream API calls.\n */\nclass ResolverCartGateway implements CartGateway {\n // Returns the gateway's wire-parsed Cart where the client Cart is expected.\n // Safe because client = wire + widenings only \u2014 enforced by the widening\n // assertions in cart-schema.spec.ts.\n async getCart(cartId: string): Promise<Cart | null> {\n try {\n const gateway = await getCanopyApiGateway();\n return await gateway.cartGet({ cartId });\n } catch (error) {\n console.warn(`Cart ${cartId} not found via canopy-api:`, error);\n return null;\n }\n }\n\n async createCart(\n lines?: CartLinePayload[],\n discountCodes?: string[],\n ): Promise<Cart | null> {\n try {\n const gateway = await getCanopyApiGateway();\n return await gateway.cartCreate({\n lines: lines?.map((line) => ({\n merchandiseId: line.id,\n quantity: line.quantity,\n attributes: line.properties?.map((prop) => ({\n key: prop.key,\n value: String(prop.value),\n })),\n sellingPlanId: line.sellingPlanId,\n })),\n discountCodes,\n });\n } catch (error) {\n console.warn(\"Failed to create cart via canopy-api:\", error);\n return null;\n }\n }\n\n async addToCart(cartPayload: AddToCartPayload): Promise<Cart | null> {\n try {\n const gateway = await getCanopyApiGateway();\n return await gateway.cartAddLines({\n cartId: cartPayload.cartId,\n lines: cartPayload.lines.map((line) => ({\n merchandiseId: line.id,\n quantity: line.quantity,\n attributes: line.properties?.map((prop) => ({\n key: prop.key,\n value: String(prop.value),\n })),\n sellingPlanId: line.sellingPlanId,\n })),\n });\n } catch (error) {\n console.warn(\"Failed to add to cart via canopy-api:\", error);\n return null;\n }\n }\n\n async updateCartLineItem(\n cartPayload: UpdateCartLineItemPayload,\n ): Promise<Cart | null> {\n try {\n const gateway = await getCanopyApiGateway();\n return await gateway.cartUpdateLines({\n cartId: cartPayload.cartId,\n lines: [\n {\n lineId: cartPayload.line.id,\n quantity: cartPayload.line.quantity,\n attributes: cartPayload.line.properties?.map((prop) => ({\n key: prop.key,\n value: String(prop.value),\n })),\n sellingPlanId: cartPayload.line.sellingPlanId,\n },\n ],\n });\n } catch (error) {\n console.warn(\"Failed to update cart line via canopy-api:\", error);\n return null;\n }\n }\n\n async removeCartLineItems(\n cartPayload: RemoveFromCartPayload,\n ): Promise<Cart | null> {\n try {\n const gateway = await getCanopyApiGateway();\n return await gateway.cartRemoveLines({\n cartId: cartPayload.cartId,\n lineIds: cartPayload.lineIds,\n });\n } catch (error) {\n console.warn(\"Failed to remove cart lines via canopy-api:\", error);\n return null;\n }\n }\n\n async updateCartDiscountCodes(\n cartPayload: UpdateCartDiscountCodesPayload,\n ): Promise<Cart | null> {\n try {\n const gateway = await getCanopyApiGateway();\n return await gateway.cartUpdateDiscountCodes({\n cartId: cartPayload.cartId,\n discountCodes: cartPayload.discountCodes,\n });\n } catch (error) {\n console.warn(\"Failed to update discount codes via canopy-api:\", error);\n return null;\n }\n }\n}\n\nexport async function createResolverCartGateway(): Promise<CartGateway> {\n return new ResolverCartGateway();\n}\n"],
|
|
5
|
-
"mappings": ";AAYA,SAAS,2BAA2B;AAQpC,MAAM,oBAA2C;AAAA;AAAA;AAAA;AAAA,
|
|
4
|
+
"sourcesContent": ["\"use server\";\n\nimport type {\n AddToCartPayload,\n Cart,\n CartLinePayload,\n RemoveFromCartPayload,\n UpdateCartDiscountCodesPayload,\n UpdateCartLineItemPayload,\n} from \"../cart-types\";\nimport type { CartGateway } from \"./cart-gateway\";\n\nimport { getCanopyApiGateway } from \"../../gateways/canopy-api\";\n\n/**\n * @author Ryan\n * CartGateway that delegates all mutations to the canopy-api's cart\n * endpoints. Used for \"externalCart\" integrations (Shopify). canopy-api\n * handles credential resolution and upstream API calls.\n */\nclass ResolverCartGateway implements CartGateway {\n // Shopify carts are fetched from Shopify on every read \u2014 already reconciled.\n async reconcileStoredCart(cart: Cart): Promise<Cart> {\n return cart;\n }\n\n // Returns the gateway's wire-parsed Cart where the client Cart is expected.\n // Safe because client = wire + widenings only \u2014 enforced by the widening\n // assertions in cart-schema.spec.ts.\n async getCart(cartId: string): Promise<Cart | null> {\n try {\n const gateway = await getCanopyApiGateway();\n return await gateway.cartGet({ cartId });\n } catch (error) {\n console.warn(`Cart ${cartId} not found via canopy-api:`, error);\n return null;\n }\n }\n\n async createCart(\n lines?: CartLinePayload[],\n discountCodes?: string[],\n ): Promise<Cart | null> {\n try {\n const gateway = await getCanopyApiGateway();\n return await gateway.cartCreate({\n lines: lines?.map((line) => ({\n merchandiseId: line.id,\n quantity: line.quantity,\n attributes: line.properties?.map((prop) => ({\n key: prop.key,\n value: String(prop.value),\n })),\n sellingPlanId: line.sellingPlanId,\n })),\n discountCodes,\n });\n } catch (error) {\n console.warn(\"Failed to create cart via canopy-api:\", error);\n return null;\n }\n }\n\n async addToCart(cartPayload: AddToCartPayload): Promise<Cart | null> {\n try {\n const gateway = await getCanopyApiGateway();\n return await gateway.cartAddLines({\n cartId: cartPayload.cartId,\n lines: cartPayload.lines.map((line) => ({\n merchandiseId: line.id,\n quantity: line.quantity,\n attributes: line.properties?.map((prop) => ({\n key: prop.key,\n value: String(prop.value),\n })),\n sellingPlanId: line.sellingPlanId,\n })),\n });\n } catch (error) {\n console.warn(\"Failed to add to cart via canopy-api:\", error);\n return null;\n }\n }\n\n async updateCartLineItem(\n cartPayload: UpdateCartLineItemPayload,\n ): Promise<Cart | null> {\n try {\n const gateway = await getCanopyApiGateway();\n return await gateway.cartUpdateLines({\n cartId: cartPayload.cartId,\n lines: [\n {\n lineId: cartPayload.line.id,\n quantity: cartPayload.line.quantity,\n attributes: cartPayload.line.properties?.map((prop) => ({\n key: prop.key,\n value: String(prop.value),\n })),\n sellingPlanId: cartPayload.line.sellingPlanId,\n },\n ],\n });\n } catch (error) {\n console.warn(\"Failed to update cart line via canopy-api:\", error);\n return null;\n }\n }\n\n async removeCartLineItems(\n cartPayload: RemoveFromCartPayload,\n ): Promise<Cart | null> {\n try {\n const gateway = await getCanopyApiGateway();\n return await gateway.cartRemoveLines({\n cartId: cartPayload.cartId,\n lineIds: cartPayload.lineIds,\n });\n } catch (error) {\n console.warn(\"Failed to remove cart lines via canopy-api:\", error);\n return null;\n }\n }\n\n async updateCartDiscountCodes(\n cartPayload: UpdateCartDiscountCodesPayload,\n ): Promise<Cart | null> {\n try {\n const gateway = await getCanopyApiGateway();\n return await gateway.cartUpdateDiscountCodes({\n cartId: cartPayload.cartId,\n discountCodes: cartPayload.discountCodes,\n });\n } catch (error) {\n console.warn(\"Failed to update discount codes via canopy-api:\", error);\n return null;\n }\n }\n}\n\nexport async function createResolverCartGateway(): Promise<CartGateway> {\n return new ResolverCartGateway();\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -13,6 +13,20 @@ declare class StripeCartGateway extends CookieBasedCartGateway {
|
|
|
13
13
|
lineIdGenerator: typeof stripeLineIdGenerator;
|
|
14
14
|
cartIdGenerator: typeof stripeCartIdGenerator;
|
|
15
15
|
gatewayName: string;
|
|
16
|
+
reconcileLines: (lines: {
|
|
17
|
+
variantId: string;
|
|
18
|
+
quantity: number;
|
|
19
|
+
}[]) => Promise<({
|
|
20
|
+
variantId: string;
|
|
21
|
+
status: "purchasable";
|
|
22
|
+
unitPriceMinor: number;
|
|
23
|
+
compareAtPriceMinor: number | null;
|
|
24
|
+
currencyCode: string;
|
|
25
|
+
purchasableQuantity: number;
|
|
26
|
+
} | {
|
|
27
|
+
variantId: string;
|
|
28
|
+
status: "unavailable";
|
|
29
|
+
})[]>;
|
|
16
30
|
};
|
|
17
31
|
}
|
|
18
32
|
export declare function createStripeCartGateway(): Promise<StripeCartGateway>;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
"use server";
|
|
2
|
+
import { getCanopyApiGateway } from "../../gateways/canopy-api";
|
|
2
3
|
import { REPLO_CART_COOKIE_NAME } from "./cart-constants";
|
|
3
4
|
import { CookieBasedCartGateway } from "./cookie-cart-gateway";
|
|
4
5
|
function stripeLineMergeFn(existingLines, newLine) {
|
|
@@ -16,7 +17,11 @@ class StripeCartGateway extends CookieBasedCartGateway {
|
|
|
16
17
|
lineMergeFn: stripeLineMergeFn,
|
|
17
18
|
lineIdGenerator: stripeLineIdGenerator,
|
|
18
19
|
cartIdGenerator: stripeCartIdGenerator,
|
|
19
|
-
gatewayName: "Stripe"
|
|
20
|
+
gatewayName: "Stripe",
|
|
21
|
+
reconcileLines: async (lines) => {
|
|
22
|
+
const gateway = await getCanopyApiGateway();
|
|
23
|
+
return await gateway.reconcileCartLines({ lines });
|
|
24
|
+
}
|
|
20
25
|
};
|
|
21
26
|
}
|
|
22
27
|
async function createStripeCartGateway() {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../cart/gateways/stripe-cart-gateway.ts"],
|
|
4
|
-
"sourcesContent": ["\"use server\";\n\nimport type { CartLine, CartLinePayload } from \"../cart-types\";\n\nimport { REPLO_CART_COOKIE_NAME } from \"./cart-constants\";\nimport { CookieBasedCartGateway } from \"./cookie-cart-gateway\";\n\n/**\n * Merges lines by merchandise ID only (unlike default which also checks selling plan)\n */\nfunction stripeLineMergeFn(\n existingLines: CartLine[],\n newLine: CartLinePayload,\n): number {\n return existingLines.findIndex((line) => line.merchandise?.id === newLine.id);\n}\n\nfunction stripeLineIdGenerator(): string {\n return crypto.randomUUID();\n}\n\nfunction stripeCartIdGenerator(): string {\n return crypto.randomUUID();\n}\n\nclass StripeCartGateway extends CookieBasedCartGateway {\n protected readonly config = {\n cookieName: REPLO_CART_COOKIE_NAME,\n lineMergeFn: stripeLineMergeFn,\n lineIdGenerator: stripeLineIdGenerator,\n cartIdGenerator: stripeCartIdGenerator,\n gatewayName: \"Stripe\",\n };\n}\n\nexport async function createStripeCartGateway(): Promise<StripeCartGateway> {\n return new StripeCartGateway();\n}\n"],
|
|
5
|
-
"mappings": ";AAIA,SAAS,8BAA8B;AACvC,SAAS,8BAA8B;AAKvC,SAAS,kBACP,eACA,SACQ;AACR,SAAO,cAAc,UAAU,CAAC,SAAS,KAAK,aAAa,OAAO,QAAQ,EAAE;AAC9E;AAEA,SAAS,wBAAgC;AACvC,SAAO,OAAO,WAAW;AAC3B;AAEA,SAAS,wBAAgC;AACvC,SAAO,OAAO,WAAW;AAC3B;AAEA,MAAM,0BAA0B,uBAAuB;AAAA,EAClC,SAAS;AAAA,IAC1B,YAAY;AAAA,IACZ,aAAa;AAAA,IACb,iBAAiB;AAAA,IACjB,iBAAiB;AAAA,IACjB,aAAa;AAAA,
|
|
4
|
+
"sourcesContent": ["\"use server\";\n\nimport type { CartLine, CartLinePayload } from \"../cart-types\";\n\nimport { getCanopyApiGateway } from \"../../gateways/canopy-api\";\nimport { REPLO_CART_COOKIE_NAME } from \"./cart-constants\";\nimport { CookieBasedCartGateway } from \"./cookie-cart-gateway\";\n\n/**\n * Merges lines by merchandise ID only (unlike default which also checks selling plan)\n */\nfunction stripeLineMergeFn(\n existingLines: CartLine[],\n newLine: CartLinePayload,\n): number {\n return existingLines.findIndex((line) => line.merchandise?.id === newLine.id);\n}\n\nfunction stripeLineIdGenerator(): string {\n return crypto.randomUUID();\n}\n\nfunction stripeCartIdGenerator(): string {\n return crypto.randomUUID();\n}\n\nclass StripeCartGateway extends CookieBasedCartGateway {\n protected readonly config = {\n cookieName: REPLO_CART_COOKIE_NAME,\n lineMergeFn: stripeLineMergeFn,\n lineIdGenerator: stripeLineIdGenerator,\n cartIdGenerator: stripeCartIdGenerator,\n gatewayName: \"Stripe\",\n reconcileLines: async (\n lines: { variantId: string; quantity: number }[],\n ) => {\n const gateway = await getCanopyApiGateway();\n return await gateway.reconcileCartLines({ lines });\n },\n };\n}\n\nexport async function createStripeCartGateway(): Promise<StripeCartGateway> {\n return new StripeCartGateway();\n}\n"],
|
|
5
|
+
"mappings": ";AAIA,SAAS,2BAA2B;AACpC,SAAS,8BAA8B;AACvC,SAAS,8BAA8B;AAKvC,SAAS,kBACP,eACA,SACQ;AACR,SAAO,cAAc,UAAU,CAAC,SAAS,KAAK,aAAa,OAAO,QAAQ,EAAE;AAC9E;AAEA,SAAS,wBAAgC;AACvC,SAAO,OAAO,WAAW;AAC3B;AAEA,SAAS,wBAAgC;AACvC,SAAO,OAAO,WAAW;AAC3B;AAEA,MAAM,0BAA0B,uBAAuB;AAAA,EAClC,SAAS;AAAA,IAC1B,YAAY;AAAA,IACZ,aAAa;AAAA,IACb,iBAAiB;AAAA,IACjB,iBAAiB;AAAA,IACjB,aAAa;AAAA,IACb,gBAAgB,OACd,UACG;AACH,YAAM,UAAU,MAAM,oBAAoB;AAC1C,aAAO,MAAM,QAAQ,mBAAmB,EAAE,MAAM,CAAC;AAAA,IACnD;AAAA,EACF;AACF;AAEA,eAAsB,0BAAsD;AAC1E,SAAO,IAAI,kBAAkB;AAC/B;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -58,6 +58,8 @@ export declare function findMerchandiseInQueryCache({ queryClient, merchandiseId
|
|
|
58
58
|
* ```
|
|
59
59
|
*/
|
|
60
60
|
export declare function useAddToCart(): {
|
|
61
|
-
addToCart: (items: AddToCartItem[], openCartAfterAdd?: boolean) => Promise<
|
|
61
|
+
addToCart: (items: AddToCartItem[], openCartAfterAdd?: boolean) => Promise<{
|
|
62
|
+
adjustments: import("../cart-types").CartLineAdjustment[];
|
|
63
|
+
}>;
|
|
62
64
|
isAdding: boolean;
|
|
63
65
|
};
|
|
@@ -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, CartLineMerchandise } 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\";\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 * 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 * @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 * 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 (items: AddToCartItem[], openCartAfterAdd = true) => {\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 await cart.addToCart(cartLines, openCartAfterAdd);\n } finally {\n setIsAdding(false);\n }\n },\n [cart, queryClient],\n );\n\n return { addToCart, isAdding };\n}\n"],
|
|
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 { CartLineAttribute, CartLineMerchandise } 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\";\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 * 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 * @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 * 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 (items: AddToCartItem[], openCartAfterAdd = true) => {\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 return await cart.addToCart(cartLines, openCartAfterAdd);\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
|
+
}
|
|
@@ -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) => {
|