@medusa-it/cartlift 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +32 -2
  2. package/dist/client/CartDrawer.d.ts +8 -0
  3. package/dist/client/CartDrawer.js +100 -0
  4. package/dist/client/CartTrigger.d.ts +13 -0
  5. package/dist/client/CartTrigger.js +15 -0
  6. package/dist/client/Provider.d.ts +52 -0
  7. package/dist/client/Provider.js +227 -0
  8. package/dist/client/Rail.d.ts +3 -0
  9. package/dist/client/Rail.js +25 -0
  10. package/dist/client/events.d.ts +8 -0
  11. package/dist/client/events.js +49 -0
  12. package/dist/client/index.d.ts +3 -0
  13. package/dist/client/index.js +5 -0
  14. package/dist/core/actions.d.ts +62 -0
  15. package/dist/core/actions.js +6 -0
  16. package/dist/core/breaks.d.ts +13 -0
  17. package/dist/core/breaks.js +42 -0
  18. package/dist/core/cart.d.ts +82 -0
  19. package/dist/core/cart.js +108 -0
  20. package/dist/core/catalyst.d.ts +75 -0
  21. package/dist/core/catalyst.js +109 -0
  22. package/dist/core/data.d.ts +23 -0
  23. package/dist/core/data.js +30 -0
  24. package/dist/core/defaults.d.ts +8 -0
  25. package/dist/core/defaults.js +54 -0
  26. package/dist/core/index.d.ts +10 -0
  27. package/dist/core/index.js +11 -0
  28. package/dist/core/money.d.ts +6 -0
  29. package/dist/core/money.js +22 -0
  30. package/dist/core/stencil.d.ts +70 -0
  31. package/dist/core/stencil.js +60 -0
  32. package/dist/core/thresholds.d.ts +45 -0
  33. package/dist/core/thresholds.js +72 -0
  34. package/dist/core/types.d.ts +244 -0
  35. package/dist/core/types.js +5 -0
  36. package/dist/index.d.ts +7 -0
  37. package/dist/index.js +6 -0
  38. package/dist/next.d.ts +45 -0
  39. package/dist/next.js +79 -0
  40. package/dist/server/adapter.d.ts +36 -0
  41. package/dist/server/adapter.js +93 -0
  42. package/dist/server/components.d.ts +24 -0
  43. package/dist/server/components.js +14 -0
  44. package/dist/server/config.d.ts +25 -0
  45. package/dist/server/config.js +54 -0
  46. package/dist/server.d.ts +4 -0
  47. package/dist/server.js +4 -0
  48. package/dist/stencil/StencilDrawer.d.ts +10 -0
  49. package/dist/stencil/StencilDrawer.js +37 -0
  50. package/dist/stencil/data.d.ts +12 -0
  51. package/dist/stencil/data.js +39 -0
  52. package/dist/stencil/index.d.ts +4 -0
  53. package/dist/stencil/index.js +6 -0
  54. package/dist/stencil/storefront.d.ts +40 -0
  55. package/dist/stencil/storefront.js +141 -0
  56. package/dist/stencil/theme.d.ts +28 -0
  57. package/dist/stencil/theme.js +143 -0
  58. package/dist/views/index.d.ts +3 -0
  59. package/dist/views/index.js +3 -0
  60. package/dist/views/rail.d.ts +21 -0
  61. package/dist/views/rail.js +23 -0
  62. package/dist/views/styles.d.ts +5 -0
  63. package/dist/views/styles.js +147 -0
  64. package/dist/views/views.d.ts +73 -0
  65. package/dist/views/views.js +99 -0
  66. package/package.json +54 -4
package/README.md CHANGED
@@ -1,3 +1,33 @@
1
- # Temporary Holding Version
1
+ # @medusa-it/cartlift
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Cart drawer and in-cart upsells for Catalyst storefronts on BigCommerce. Configure it from the Cartlift app in the BigCommerce control panel; the full install guide (with your keys filled in) is in its Setup tab.
4
+
5
+ ```bash
6
+ pnpm add @medusa-it/cartlift
7
+ ```
8
+
9
+ ```env
10
+ BIGCOMMERCE_CHANNEL_ID=1
11
+ CARTLIFT_PUBLIC_KEY=cl_pub_…
12
+ CARTLIFT_API_URL=https://…
13
+ CARTLIFT_REVALIDATE_SECRET=cl_rev_…
14
+ ```
15
+
16
+ | Entry | For | Exports |
17
+ |---|---|---|
18
+ | `@medusa-it/cartlift` | Server Components (layout, header) | `CartDrawerProvider`, `CartDrawer`, `CartTrigger`, `useCartDrawer`, `openCartDrawer`, `getCartConfig` |
19
+ | `@medusa-it/cartlift/server` | `'use server'` files | `createCartAdapter`, `wrapAddToCart`, `CARTLIFT_PRODUCTS_QUERY`, `fromCatalystProducts`, `fromCatalystCart` |
20
+ | `@medusa-it/cartlift/next` | Route handlers | `createRecommendationsRoute`, `createRevalidateRoute` |
21
+ | `@medusa-it/cartlift/client` | Your own `'use client'` components | `useCartDrawer`, `openCartDrawer`, `CartTrigger` |
22
+
23
+ ```tsx
24
+ // layout.tsx
25
+ <CartDrawerProvider cart={cart} checkout={checkout} actions={{ addToCart, updateQuantity, removeItem, applyCoupon }} context={{ customerGroupId, country, locale }}>
26
+ {children}
27
+ <CartDrawer expressCheckout={<PayPalButton />} />
28
+ </CartDrawerProvider>
29
+ ```
30
+
31
+ Events: the drawer opens on `cartlift:added` and `cartlift:open` (window `CustomEvent`s); `wrapAddToCart()` triggers the first after a successful add.
32
+
33
+ Styling: every colour defaults to Catalyst's theme tokens (`--primary`, `--foreground`, …); override with `--cl-accent`, `--cl-accent-fg`, `--cl-bg`, `--cl-fg`, `--cl-progress`, `--cl-radius`, `--cl-font` or from the Design tab.
@@ -0,0 +1,8 @@
1
+ import { type ReactNode } from 'react';
2
+ export interface CartDrawerProps {
3
+ /** The merchant's express checkout buttons (PayPal, Apple Pay), when switched on in the control panel. */
4
+ expressCheckout?: ReactNode;
5
+ /** Replaces the drawer's own announcement line (e.g. Nudgekit's <AnnouncementBar />). */
6
+ announcement?: ReactNode;
7
+ }
8
+ export declare function CartDrawer({ expressCheckout, announcement }: CartDrawerProps): import("react").JSX.Element | null;
@@ -0,0 +1,100 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { lazy, Suspense, useEffect, useId, useRef, useState } from 'react';
4
+ import { designVars } from '../views/styles.js';
5
+ import { DrawerView } from '../views/views.js';
6
+ import { giftNames, useCartDrawerContext } from './Provider.js';
7
+ // The drawer itself (SPEC §3.1): a modal dialog sliding in from the side (a bottom sheet on phones),
8
+ // with a focus trap, Escape and overlay to close, and no motion when the shopper asks for less.
9
+ const Rail = lazy(() => import('./Rail.js'));
10
+ const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
11
+ export function CartDrawer({ expressCheckout, announcement }) {
12
+ const d = useCartDrawerContext();
13
+ const titleId = useId();
14
+ const panel = useRef(null);
15
+ const returnFocus = useRef(null);
16
+ const [phase, setPhase] = useState('closed');
17
+ const isOpen = d?.isOpen ?? false;
18
+ // Mounted closed, then flipped on the next frame so the slide-in transition runs.
19
+ useEffect(() => {
20
+ if (isOpen) {
21
+ returnFocus.current = document.activeElement;
22
+ setPhase('opening');
23
+ const f = requestAnimationFrame(() => setPhase('open'));
24
+ return () => cancelAnimationFrame(f);
25
+ }
26
+ setPhase((p) => (p === 'closed' ? p : 'closing'));
27
+ const reduced = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
28
+ const t = setTimeout(() => setPhase('closed'), reduced ? 0 : 260);
29
+ return () => clearTimeout(t);
30
+ }, [isOpen]);
31
+ // Focus into the dialog, lock the page's scroll, give focus back on close.
32
+ useEffect(() => {
33
+ if (phase !== 'open')
34
+ return;
35
+ const first = panel.current?.querySelector('.cl-head button');
36
+ first?.focus();
37
+ const root = document.documentElement;
38
+ const overflow = root.style.overflow;
39
+ root.style.overflow = 'hidden';
40
+ return () => {
41
+ root.style.overflow = overflow;
42
+ };
43
+ }, [phase]);
44
+ // A removed line takes its focused button with it: keep focus in the dialog.
45
+ const lines = d?.cart?.lines.length;
46
+ useEffect(() => {
47
+ if (phase === 'open' && (document.activeElement === document.body || !document.activeElement))
48
+ panel.current?.focus();
49
+ }, [phase, lines]);
50
+ // Keys are watched on the document: focus can fall out of the panel.
51
+ const close = d?.close;
52
+ useEffect(() => {
53
+ if (phase !== 'open' || !close)
54
+ return;
55
+ const onKeyDown = (e) => {
56
+ if (e.key === 'Escape') {
57
+ e.stopPropagation();
58
+ close();
59
+ return;
60
+ }
61
+ if (e.key !== 'Tab' || !panel.current)
62
+ return;
63
+ const items = [...panel.current.querySelectorAll(FOCUSABLE)].filter((el) => el.offsetParent !== null || el === document.activeElement);
64
+ if (!items.length)
65
+ return;
66
+ const first = items[0];
67
+ const last = items[items.length - 1];
68
+ const inside = panel.current.contains(document.activeElement);
69
+ if (!inside || (e.shiftKey && document.activeElement === first)) {
70
+ e.preventDefault();
71
+ (e.shiftKey ? last : first).focus();
72
+ }
73
+ else if (!e.shiftKey && document.activeElement === last) {
74
+ e.preventDefault();
75
+ first.focus();
76
+ }
77
+ };
78
+ document.addEventListener('keydown', onKeyDown);
79
+ return () => document.removeEventListener('keydown', onKeyDown);
80
+ }, [phase, close]);
81
+ useEffect(() => {
82
+ if (phase === 'closed' && returnFocus.current) {
83
+ returnFocus.current.focus?.();
84
+ returnFocus.current = null;
85
+ }
86
+ }, [phase]);
87
+ if (!d?.config?.enabled || phase === 'closed')
88
+ return null;
89
+ const { config, cart, ctx, data } = d;
90
+ const shown = phase === 'open';
91
+ const { layout } = config;
92
+ const railOn = config.modules.includes('recommendations');
93
+ const rail = (title, recs) => recs && (_jsx(Suspense, { fallback: null, children: _jsx(Rail, { title: title }) }));
94
+ return (_jsxs("div", { className: "cl-root", style: designVars(config.design, layout), children: [_jsx("div", { className: "cl-overlay", "data-open": shown, onClick: d.close, "aria-hidden": "true" }), _jsx("div", { ref: panel, role: "dialog", "aria-modal": "true", "aria-labelledby": titleId, className: `cl-panel${layout.position === 'left' ? ' cl-panel--left' : ''}${layout.mobile === 'sheet' ? ' cl-panel--sheet' : ''}`, "data-open": shown, tabIndex: -1, children: _jsx(DrawerView, { config: config, cart: cart, ctx: ctx, titleId: titleId, error: d.error, giftNames: giftNames(data), bulk: data?.bulk, addonProducts: data?.extras, rail: rail(config.copy.railTitle, railOn), emptyRail: rail(config.copy.emptyRailTitle, config.features.emptyFeatured), announcement: announcement, express: expressCheckout, lineHandlers: { onQuantity: d.setQuantity, onRemove: d.remove }, footerHandlers: {
95
+ onCoupon: d.applyCoupon ?? undefined,
96
+ onRemoveCoupon: d.removeCoupon ?? undefined,
97
+ onNote: d.setNote ?? undefined,
98
+ onContinue: d.close,
99
+ }, onAddon: d.toggleAddon, onClose: d.close }) })] }));
100
+ }
@@ -0,0 +1,13 @@
1
+ import type { ReactNode } from 'react';
2
+ export interface CartTriggerProps {
3
+ /** Item count from the header's own data; the drawer's live count replaces it once known. */
4
+ count?: number;
5
+ /** Without the drawer (switched off, outside the provider) the trigger is a plain link here. */
6
+ href?: string;
7
+ label?: string;
8
+ className?: string;
9
+ /** Replaces the bag icon. */
10
+ children?: ReactNode;
11
+ }
12
+ /** The header's cart button (in place of Catalyst's cart icon link). */
13
+ export declare function CartTrigger({ count, href, label, className, children }: CartTriggerProps): import("react").JSX.Element;
@@ -0,0 +1,15 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { BagIcon } from '../views/views.js';
4
+ import { useCartDrawerContext } from './Provider.js';
5
+ /** The header's cart button (in place of Catalyst's cart icon link). */
6
+ export function CartTrigger({ count, href = '/cart', label = 'Cart', className = 'cl-trigger', children }) {
7
+ const d = useCartDrawerContext();
8
+ const n = d?.cart ? d.cart.itemCount : (count ?? 0);
9
+ const badge = n > 0 && (_jsx("span", { className: "cl-badge", "aria-hidden": "true", children: n > 99 ? '99+' : n }));
10
+ const name = n > 0 ? `${label}, ${n} item${n === 1 ? '' : 's'}` : label;
11
+ if (!d?.config?.enabled) {
12
+ return (_jsxs("a", { href: href, className: className, "aria-label": name, children: [children ?? _jsx(BagIcon, {}), badge] }));
13
+ }
14
+ return (_jsxs("button", { type: "button", className: className, "aria-label": name, "aria-haspopup": "dialog", "aria-expanded": d.isOpen, onClick: d.open, children: [children ?? _jsx(BagIcon, {}), badge] }));
15
+ }
@@ -0,0 +1,52 @@
1
+ import { type ReactNode } from 'react';
2
+ import { type AddSource, type CartActions, type DrawerData } from '../core/actions.js';
3
+ import type { AddonOffer, CartLine, CartliftCart, CartliftConfig, RailProduct, ShopperContext } from '../core/types.js';
4
+ export interface DrawerContextValue {
5
+ config: CartliftConfig | null;
6
+ /** The cart as shown: the server's, with pending changes applied. */
7
+ cart: CartliftCart | null;
8
+ ctx: ShopperContext;
9
+ isOpen: boolean;
10
+ /** Opened at least once on this page (the rail loads then). */
11
+ everOpened: boolean;
12
+ open: () => void;
13
+ close: () => void;
14
+ data: DrawerData | null;
15
+ loading: boolean;
16
+ error: string | null;
17
+ setQuantity: (line: CartLine, quantity: number) => void;
18
+ remove: (line: CartLine) => void;
19
+ add: (product: RailProduct, variantId: number | null, source: AddSource, ruleId?: string | null) => void;
20
+ applyCoupon: ((code: string) => Promise<string | null>) | null;
21
+ removeCoupon: ((code: string) => void) | null;
22
+ setNote: ((note: string) => void) | null;
23
+ toggleAddon: (addon: AddonOffer, on: boolean) => void;
24
+ }
25
+ /** The drawer's state, or null outside <CartDrawerProvider>. */
26
+ export declare const useCartDrawerContext: () => DrawerContextValue | null;
27
+ /** For the merchant's own components: open or close the drawer, read the item count. */
28
+ export declare function useCartDrawer(): {
29
+ open: () => void;
30
+ close: () => void;
31
+ isOpen: boolean;
32
+ cart: CartliftCart | null;
33
+ itemCount: number;
34
+ };
35
+ /** Open the drawer from anywhere (also works from code outside React). */
36
+ export declare function openCartDrawer(): void;
37
+ export interface CartDrawerClientProviderProps {
38
+ config: CartliftConfig | null;
39
+ cart: CartliftCart | null;
40
+ actions: CartActions;
41
+ context?: ShopperContext;
42
+ /** The storefront's recommendations route (createRecommendationsRoute); null = no rail, gifts or add-on prices. */
43
+ endpoint?: string | null;
44
+ /** Loads the drawer's data itself in place of `endpoint` (storefronts without a server, such as Stencil). */
45
+ loadData?: DrawerDataLoader | null;
46
+ children?: ReactNode;
47
+ }
48
+ /** Answers the query the drawer would send to `endpoint` (cart=…&extra=…&bulk=1…). */
49
+ export type DrawerDataLoader = (query: URLSearchParams, signal: AbortSignal) => Promise<DrawerData | null>;
50
+ export declare function CartDrawerClientProvider({ config, cart: serverCart, actions, context, endpoint, loadData, children }: CartDrawerClientProviderProps): import("react").JSX.Element;
51
+ /** Gift product names for the progress messages, from the loaded extras. */
52
+ export declare function giftNames(data: DrawerData | null): Record<number, string>;
@@ -0,0 +1,227 @@
1
+ 'use client';
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { createContext, startTransition, useCallback, useContext, useEffect, useMemo, useOptimistic, useRef, useState } from 'react';
4
+ import { ADDED_COOKIE, ADDED_EVENT, DEFAULT_ENDPOINT, OPEN_EVENT } from '../core/actions.js';
5
+ import { applyPatch, cartSignature } from '../core/cart.js';
6
+ import { giftPlan, progressFor, tiersFor } from '../core/thresholds.js';
7
+ import { configureEvents, track, trackOnce } from './events.js';
8
+ // The drawer's state (SPEC §2.1). The cart comes from the layout (already fetched, no request on
9
+ // page load); changes go through the merchant's server actions, shown at once with useOptimistic and
10
+ // replaced by the cart each action answers with.
11
+ const GENERIC_ERROR = 'Something went wrong. Please try again.';
12
+ const DrawerContext = createContext(null);
13
+ /** The drawer's state, or null outside <CartDrawerProvider>. */
14
+ export const useCartDrawerContext = () => useContext(DrawerContext);
15
+ /** For the merchant's own components: open or close the drawer, read the item count. */
16
+ export function useCartDrawer() {
17
+ const c = useContext(DrawerContext);
18
+ return { open: c?.open ?? openCartDrawer, close: c?.close ?? (() => undefined), isOpen: c?.isOpen ?? false, cart: c?.cart ?? null, itemCount: c?.cart?.itemCount ?? 0 };
19
+ }
20
+ /** Open the drawer from anywhere (also works from code outside React). */
21
+ export function openCartDrawer() {
22
+ window.dispatchEvent(new CustomEvent(OPEN_EVENT));
23
+ }
24
+ function readCookie(name) {
25
+ const m = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
26
+ return m ? decodeURIComponent(m[1]) : null;
27
+ }
28
+ /** Gift tiers the shopper removed by hand this session: not added again. */
29
+ const DECLINED_KEY = 'cl_gift_declined';
30
+ function declinedGifts() {
31
+ try {
32
+ return new Set(sessionStorage.getItem(DECLINED_KEY)?.split('|').filter(Boolean) ?? []);
33
+ }
34
+ catch {
35
+ return new Set();
36
+ }
37
+ }
38
+ function declineGift(tierId) {
39
+ try {
40
+ sessionStorage.setItem(DECLINED_KEY, [...declinedGifts(), tierId].join('|'));
41
+ }
42
+ catch {
43
+ // no storage: it may be added again on the next change
44
+ }
45
+ }
46
+ const fetchEndpoint = (endpoint) => (query, signal) => fetch(`${endpoint}?${query}`, { signal, credentials: 'same-origin', headers: { Accept: 'application/json' } }).then((r) => (r.ok ? r.json() : null));
47
+ export function CartDrawerClientProvider({ config, cart: serverCart, actions, context, endpoint = DEFAULT_ENDPOINT, loadData, children }) {
48
+ const [base, setBase] = useState(serverCart);
49
+ // A new cart from the layout (router refresh after an action, navigation) is the truth.
50
+ useEffect(() => setBase(serverCart), [serverCart]);
51
+ const [cart, addPatch] = useOptimistic(base, applyPatch);
52
+ const [isOpen, setOpen] = useState(false);
53
+ const [everOpened, setEverOpened] = useState(false);
54
+ const [error, setError] = useState(null);
55
+ const [data, setData] = useState(null);
56
+ const [loading, setLoading] = useState(false);
57
+ const ctx = useMemo(() => ({ ...context }), [context?.customerGroupId, context?.country, context?.locale]);
58
+ const enabled = !!config?.enabled;
59
+ useEffect(() => configureEvents(enabled ? (config?.events ?? null) : null), [enabled, config?.events]);
60
+ const open = useCallback(() => {
61
+ if (!enabled)
62
+ return;
63
+ setOpen(true);
64
+ setEverOpened(true);
65
+ track({ type: 'open' });
66
+ }, [enabled]);
67
+ const close = useCallback(() => setOpen(false), []);
68
+ // Listening first: effects run in order, and the cookie check below may dispatch at once.
69
+ useEffect(() => {
70
+ window.addEventListener(ADDED_EVENT, open);
71
+ window.addEventListener(OPEN_EVENT, open);
72
+ return () => {
73
+ window.removeEventListener(ADDED_EVENT, open);
74
+ window.removeEventListener(OPEN_EVENT, open);
75
+ };
76
+ }, [open]);
77
+ // Opening on add to cart: wrapAddToCart() leaves a short-lived cookie (a server action cannot
78
+ // dispatch DOM events); it is turned into the `cartlift:added` event, which opens the drawer.
79
+ useEffect(() => {
80
+ if (!enabled || !readCookie(ADDED_COOKIE))
81
+ return;
82
+ document.cookie = `${ADDED_COOKIE}=; Path=/; Max-Age=0; SameSite=Lax`;
83
+ window.dispatchEvent(new CustomEvent(ADDED_EVENT));
84
+ }, [serverCart, enabled]);
85
+ /** Run a server action: the patch shows at once, the answer replaces it. Resolves with the error shown, if any. */
86
+ const run = useCallback((patch, call) => new Promise((resolve) => {
87
+ startTransition(async () => {
88
+ if (patch)
89
+ addPatch(patch);
90
+ let message = null;
91
+ try {
92
+ const res = await call();
93
+ message = res.error ?? null;
94
+ if (!res.error || res.cart)
95
+ startTransition(() => setBase(res.cart));
96
+ }
97
+ catch {
98
+ message = GENERIC_ERROR;
99
+ }
100
+ setError(message);
101
+ resolve(message);
102
+ });
103
+ }), [addPatch]);
104
+ const tiers = useMemo(() => (config && base ? tiersFor(config.thresholds, base.currency, ctx) : []), [config, base, ctx]);
105
+ const setQuantity = useCallback((line, quantity) => {
106
+ if (quantity <= 0)
107
+ return void run({ type: 'remove', lineId: line.id }, () => actions.removeItem({ lineId: line.id }));
108
+ void run({ type: 'quantity', lineId: line.id, quantity }, () => actions.updateQuantity({ lineId: line.id, productId: line.productId, variantId: line.variantId, quantity }));
109
+ }, [actions, run]);
110
+ const remove = useCallback((line) => {
111
+ if (config?.modules.includes('gift')) {
112
+ const tier = tiers.find((t) => t.type === 'gift' && t.giftProductId === line.productId);
113
+ if (tier)
114
+ declineGift(tier.id);
115
+ }
116
+ void run({ type: 'remove', lineId: line.id }, () => actions.removeItem({ lineId: line.id }));
117
+ }, [actions, run, config, tiers]);
118
+ const add = useCallback((product, variantId, source, ruleId = null) => {
119
+ if (source === 'upsell')
120
+ track({ type: 'add', productId: product.id, ruleId });
121
+ void run({ type: 'add', product, variantId, quantity: 1 }, () => actions.addToCart({ productId: product.id, variantId, quantity: 1, source, ruleId }));
122
+ }, [actions, run]);
123
+ const toggleAddon = useCallback((addon, on) => {
124
+ const product = data?.extras[addon.productId];
125
+ if (on) {
126
+ if (product)
127
+ add(product, addon.variantId, 'addon');
128
+ else
129
+ void run(null, () => actions.addToCart({ productId: addon.productId, variantId: addon.variantId, quantity: 1, source: 'addon' }));
130
+ return;
131
+ }
132
+ const line = cart?.lines.find((l) => l.productId === addon.productId);
133
+ if (line)
134
+ void run({ type: 'remove', lineId: line.id }, () => actions.removeItem({ lineId: line.id }));
135
+ }, [actions, add, cart, data, run]);
136
+ const applyCoupon = useMemo(() => (actions.applyCoupon ? (code) => run(null, () => actions.applyCoupon({ code })) : null), [actions, run]);
137
+ const removeCoupon = useMemo(() => (actions.removeCoupon ? (code) => void run(null, () => actions.removeCoupon({ code })) : null), [actions, run]);
138
+ const setNote = useMemo(() => (actions.setNote ? (note) => void run({ type: 'note', note }, () => actions.setNote({ note })) : null), [actions, run]);
139
+ // ---- Free gift automation (SPEC §3.2 module 2): one step per server answer -----------------------
140
+ const giftInFlight = useRef(false);
141
+ const giftFailed = useRef(new Set());
142
+ useEffect(() => {
143
+ if (!enabled || !config.modules.includes('gift') || !base || giftInFlight.current)
144
+ return;
145
+ const plan = giftPlan(base, config.thresholds, ctx, new Set([...declinedGifts(), ...giftFailed.current]));
146
+ const step = plan.add[0] ? { kind: 'add', ...plan.add[0] } : plan.remove[0] ? { kind: 'remove', ...plan.remove[0] } : null;
147
+ if (!step)
148
+ return;
149
+ giftInFlight.current = true;
150
+ const call = step.kind === 'add'
151
+ ? () => actions.addToCart({ productId: step.productId, variantId: step.variantId, quantity: 1, source: 'gift' })
152
+ : step.line.quantity > 1
153
+ ? () => actions.updateQuantity({ lineId: step.line.id, productId: step.line.productId, variantId: step.line.variantId, quantity: step.line.quantity - 1 })
154
+ : () => actions.removeItem({ lineId: step.line.id });
155
+ void run(null, call)
156
+ .then((err) => {
157
+ if (err)
158
+ giftFailed.current.add(step.tierId);
159
+ })
160
+ .finally(() => {
161
+ giftInFlight.current = false;
162
+ });
163
+ }, [enabled, config, base, ctx, actions, run]);
164
+ // Thresholds reached (analytics), once per tier and session.
165
+ useEffect(() => {
166
+ if (!enabled || !base || !config.modules.includes('progress'))
167
+ return;
168
+ for (const s of progressFor(base, config.thresholds, ctx).tiers)
169
+ if (s.reached)
170
+ trackOnce(`thr:${s.tier.id}`, { type: 'threshold', tierId: s.tier.id });
171
+ }, [enabled, config, base, ctx]);
172
+ // ---- Recommendations, gift / add-on products and bulk pricing: loaded on first open ------------
173
+ const signature = cartSignature(base).join(',');
174
+ const total = Math.floor(base?.subtotal ?? 0);
175
+ const lastKey = useRef(null);
176
+ const load = useMemo(() => loadData ?? (endpoint ? fetchEndpoint(endpoint) : null), [loadData, endpoint]);
177
+ useEffect(() => {
178
+ if (!everOpened || !load || !enabled)
179
+ return;
180
+ const c = config;
181
+ const recs = c.modules.includes('recommendations') || c.features.emptyFeatured;
182
+ const extras = [
183
+ ...(c.modules.includes('addons') ? c.addons.map((a) => a.productId) : []),
184
+ ...(c.modules.includes('gift') || c.modules.includes('progress') ? tiers.filter((t) => t.giftProductId).map((t) => t.giftProductId) : []),
185
+ ];
186
+ const bulk = c.modules.includes('quantityBreaks') && signature !== '';
187
+ if (!recs && !extras.length && !bulk)
188
+ return;
189
+ const params = new URLSearchParams();
190
+ if (signature)
191
+ params.set('cart', signature);
192
+ if (!recs)
193
+ params.set('recs', '0');
194
+ if (extras.length)
195
+ params.set('extra', [...new Set(extras)].join(','));
196
+ if (bulk)
197
+ params.set('bulk', '1');
198
+ if (ctx.customerGroupId)
199
+ params.set('group', String(ctx.customerGroupId));
200
+ // Whole units only, so small quantity changes keep the same cache key.
201
+ if (recs && c.rail.cartTotalRules && total)
202
+ params.set('total', String(total));
203
+ const key = params.toString();
204
+ if (lastKey.current === key)
205
+ return;
206
+ const ctrl = new AbortController();
207
+ // The first load at once; later ones once the cart settles.
208
+ const t = setTimeout(() => {
209
+ lastKey.current = key;
210
+ setLoading(true);
211
+ load(params, ctrl.signal)
212
+ .then((d) => d && setData(d))
213
+ .catch(() => undefined)
214
+ .finally(() => !ctrl.signal.aborted && setLoading(false));
215
+ }, lastKey.current === null ? 0 : 400);
216
+ return () => {
217
+ clearTimeout(t);
218
+ ctrl.abort();
219
+ };
220
+ }, [everOpened, load, enabled, config, signature, total, tiers, ctx.customerGroupId]);
221
+ const value = useMemo(() => ({ config, cart, ctx, isOpen, everOpened, open, close, data, loading, error, setQuantity, remove, add, applyCoupon, removeCoupon, setNote, toggleAddon }), [config, cart, ctx, isOpen, everOpened, open, close, data, loading, error, setQuantity, remove, add, applyCoupon, removeCoupon, setNote, toggleAddon]);
222
+ return _jsx(DrawerContext.Provider, { value: value, children: children });
223
+ }
224
+ /** Gift product names for the progress messages, from the loaded extras. */
225
+ export function giftNames(data) {
226
+ return Object.fromEntries(Object.values(data?.extras ?? {}).map((p) => [p.id, p.name]));
227
+ }
@@ -0,0 +1,3 @@
1
+ export default function Rail({ title }: {
2
+ title: string;
3
+ }): import("react").JSX.Element | null;
@@ -0,0 +1,25 @@
1
+ 'use client';
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { useEffect } from 'react';
4
+ import { RailView } from '../views/rail.js';
5
+ import { track, trackOnce } from './events.js';
6
+ import { useCartDrawerContext } from './Provider.js';
7
+ // Recommendations rail (SPEC §3.2 module 3), loaded with the drawer's first open. Products already
8
+ // in the cart are left out; the rest keep the service's order.
9
+ export default function Rail({ title }) {
10
+ const d = useCartDrawerContext();
11
+ const inCart = new Set(d?.cart?.lines.map((l) => l.productId));
12
+ const items = (d?.data?.items ?? []).filter((p) => !inCart.has(p.id)).slice(0, d?.config?.rail.maxItems ?? 4);
13
+ const shown = d?.isOpen && !d.loading;
14
+ const ids = items.map((i) => i.id).join(',');
15
+ useEffect(() => {
16
+ if (!shown)
17
+ return;
18
+ for (const p of items)
19
+ trackOnce(`imp:${p.id}`, { type: 'impression', productId: p.id, ruleId: p.ruleId });
20
+ }, [shown, ids]);
21
+ if (!d?.config)
22
+ return null;
23
+ const byId = new Map(items.map((i) => [i.id, i]));
24
+ return (_jsx(RailView, { title: title, products: items, loading: d.loading && !d.data, showImage: d.config.layout.showImages, picker: d.config.rail.variantPicker, locale: d.ctx.locale, onAdd: (p, variantId) => d.add(p, variantId, 'upsell', byId.get(p.id)?.ruleId ?? null), onClick: (p) => track({ type: 'click', productId: p.id, ruleId: byId.get(p.id)?.ruleId ?? null }) }));
25
+ }
@@ -0,0 +1,8 @@
1
+ import type { CartEvent } from '../core/types.js';
2
+ export declare function configureEvents(events: {
3
+ url: string;
4
+ token: string;
5
+ } | null): void;
6
+ export declare function track(event: CartEvent): void;
7
+ /** Once per browser session (impressions, thresholds), so reopening the drawer does not inflate them. */
8
+ export declare function trackOnce(key: string, event: CartEvent): void;
@@ -0,0 +1,49 @@
1
+ // Analytics (SPEC §3.4): events are batched and sent with navigator.sendBeacon to POST /v1/events
2
+ // (a text/plain body, so no CORS preflight). Lost events are acceptable; a slow page is not.
3
+ const queue = [];
4
+ let target = null;
5
+ let timer;
6
+ let listening = false;
7
+ function flush() {
8
+ clearTimeout(timer);
9
+ timer = undefined;
10
+ if (!target || !queue.length)
11
+ return;
12
+ const body = JSON.stringify({ token: target.token, events: queue.splice(0, 50) });
13
+ try {
14
+ if (!navigator.sendBeacon?.(target.url, body))
15
+ void fetch(target.url, { method: 'POST', body, keepalive: true, mode: 'no-cors' }).catch(() => undefined);
16
+ }
17
+ catch {
18
+ // analytics never break the drawer
19
+ }
20
+ if (queue.length)
21
+ flush();
22
+ }
23
+ export function configureEvents(events) {
24
+ target = events;
25
+ if (!target || listening || typeof document === 'undefined')
26
+ return;
27
+ listening = true;
28
+ document.addEventListener('visibilitychange', () => document.visibilityState === 'hidden' && flush());
29
+ window.addEventListener('pagehide', flush);
30
+ }
31
+ export function track(event) {
32
+ if (!target)
33
+ return;
34
+ queue.push(event);
35
+ timer ??= setTimeout(flush, 4000);
36
+ }
37
+ /** Once per browser session (impressions, thresholds), so reopening the drawer does not inflate them. */
38
+ export function trackOnce(key, event) {
39
+ try {
40
+ const seen = sessionStorage.getItem('cl_seen')?.split('|') ?? [];
41
+ if (seen.includes(key))
42
+ return;
43
+ sessionStorage.setItem('cl_seen', [...seen.slice(-200), key].join('|'));
44
+ }
45
+ catch {
46
+ // no storage: count it
47
+ }
48
+ track(event);
49
+ }
@@ -0,0 +1,3 @@
1
+ export { CartDrawerClientProvider, useCartDrawer, useCartDrawerContext, openCartDrawer, type CartDrawerClientProviderProps, type DrawerContextValue, type DrawerDataLoader } from './Provider.js';
2
+ export { CartDrawer, type CartDrawerProps } from './CartDrawer.js';
3
+ export { CartTrigger, type CartTriggerProps } from './CartTrigger.js';
@@ -0,0 +1,5 @@
1
+ 'use client';
2
+ // Client-only entry: safe to import from your own 'use client' components.
3
+ export { CartDrawerClientProvider, useCartDrawer, useCartDrawerContext, openCartDrawer } from './Provider.js';
4
+ export { CartDrawer } from './CartDrawer.js';
5
+ export { CartTrigger } from './CartTrigger.js';
@@ -0,0 +1,62 @@
1
+ import type { BulkTier, CartliftCart, RailProduct, RecommendationReason } from './types.js';
2
+ export type AddSource = 'upsell' | 'gift' | 'addon' | 'nudge';
3
+ export interface AddItemInput {
4
+ productId: number;
5
+ variantId: number | null;
6
+ quantity: number;
7
+ /** Multiple-choice selections, for products without a variant id (option id → value id). */
8
+ selectedOptions?: {
9
+ optionId: number;
10
+ valueId: number;
11
+ }[];
12
+ /** Why the drawer adds it (analytics attribution). */
13
+ source: AddSource;
14
+ ruleId?: string | null;
15
+ }
16
+ export interface UpdateQuantityInput {
17
+ lineId: string;
18
+ productId: number;
19
+ variantId: number | null;
20
+ quantity: number;
21
+ }
22
+ export interface CartActionResult {
23
+ cart: CartliftCart | null;
24
+ /** A message for the shopper ("Only 2 left"), shown in the drawer. */
25
+ error?: string | null;
26
+ }
27
+ export interface CartActions {
28
+ addToCart: (input: AddItemInput) => Promise<CartActionResult>;
29
+ updateQuantity: (input: UpdateQuantityInput) => Promise<CartActionResult>;
30
+ removeItem: (input: {
31
+ lineId: string;
32
+ }) => Promise<CartActionResult>;
33
+ applyCoupon?: (input: {
34
+ code: string;
35
+ }) => Promise<CartActionResult>;
36
+ removeCoupon?: (input: {
37
+ code: string;
38
+ }) => Promise<CartActionResult>;
39
+ /** Saves the order note (the checkout's customer message). */
40
+ setNote?: (input: {
41
+ note: string;
42
+ }) => Promise<CartActionResult>;
43
+ }
44
+ /** A recommended product, with why it is shown. */
45
+ export type RailItem = RailProduct & {
46
+ reason: RecommendationReason;
47
+ ruleId: string | null;
48
+ };
49
+ /** What the storefront's recommendations route answers (createRecommendationsRoute). */
50
+ export interface DrawerData {
51
+ items: RailItem[];
52
+ /** Gift and add-on products, by id. */
53
+ extras: Record<number, RailProduct>;
54
+ /** Bulk pricing of the cart's products, by id. */
55
+ bulk: Record<number, BulkTier[]>;
56
+ }
57
+ /** The custom DOM events the drawer listens to and dispatches (SPEC §2.1). */
58
+ export declare const ADDED_EVENT = "cartlift:added";
59
+ export declare const OPEN_EVENT = "cartlift:open";
60
+ /** Set by wrapAddToCart() after a successful add; read (and cleared) by the drawer. */
61
+ export declare const ADDED_COOKIE = "cl_added";
62
+ export declare const DEFAULT_ENDPOINT = "/api/cartlift/recommendations";
@@ -0,0 +1,6 @@
1
+ /** The custom DOM events the drawer listens to and dispatches (SPEC §2.1). */
2
+ export const ADDED_EVENT = 'cartlift:added';
3
+ export const OPEN_EVENT = 'cartlift:open';
4
+ /** Set by wrapAddToCart() after a successful add; read (and cleared) by the drawer. */
5
+ export const ADDED_COOKIE = 'cl_added';
6
+ export const DEFAULT_ENDPOINT = '/api/cartlift/recommendations';
@@ -0,0 +1,13 @@
1
+ import type { BulkTier, CartLine } from './types.js';
2
+ export interface QuantityBreak {
3
+ /** Units to add to reach the next tier. */
4
+ more: number;
5
+ tier: BulkTier;
6
+ /** What the shopper saves on the whole line at the new quantity. */
7
+ saving: number;
8
+ message: string;
9
+ }
10
+ /** Unit price at a bulk tier. */
11
+ export declare function tierUnitPrice(tier: BulkTier, unitPrice: number): number;
12
+ /** The next bulk tier above the line's quantity, within `maxMore` units (null when there is none). */
13
+ export declare function quantityBreakFor(line: CartLine, tiers: BulkTier[], template: string, currency: string, locale?: string, maxMore?: number): QuantityBreak | null;