@ikeboy003/cart 0.1.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/README.md ADDED
@@ -0,0 +1,99 @@
1
+ # @ikeboy003/cart
2
+
3
+ Headless cart primitive. **Local-first cart state + a configurable durable sync
4
+ path.** Import the engine, inject config, design your own UI. The library knows
5
+ nothing about any POS, payment provider, store, framework, or item kind — like
6
+ `postgrest-rs`, behaviour comes from config, not baked-in business logic.
7
+
8
+ ## Install
9
+
10
+ ```sh
11
+ npm i @ikeboy003/cart
12
+ ```
13
+
14
+ ## Use
15
+
16
+ ```ts
17
+ import { createCart, httpSync } from "@ikeboy003/cart";
18
+
19
+ const cart = createCart({
20
+ namespace: "plane-things", // scopes storage keys + cart id
21
+ storage: localStorage, // or memory / custom Storage
22
+ sync: httpSync({ endpoint: "/api/cart" }), // your durable write path
23
+ debounceMs: 400,
24
+ });
25
+
26
+ cart.add({ id: "tee:L", quantity: 1, priceCents: 3199, title: "Formation Tee" });
27
+ cart.count; // 1
28
+ cart.subtotalCents; // 3199
29
+ await cart.flush(); // force the durable mirror current (call before checkout)
30
+ ```
31
+
32
+ React (separate entry, optional):
33
+
34
+ ```tsx
35
+ import { CartProvider, useCart } from "@ikeboy003/cart/react";
36
+
37
+ <CartProvider namespace="plane-things" sync={httpSync({ endpoint: "/api/cart" })}>
38
+ <App />
39
+ </CartProvider>;
40
+
41
+ const { items, count, subtotalCents, add, remove, updateQuantity, clear, flush } = useCart();
42
+ ```
43
+
44
+ ## What it does (and only this)
45
+
46
+ local cart state · `add` · `remove` · `updateQuantity` · `count` / `subtotalCents`
47
+ · debounced sync · `flush` · `restore` from storage + durable · the
48
+ `CartSyncAdapter` interface.
49
+
50
+ It does **not** know about Square, Toast, Stripe, Cloudflare/D1, auth,
51
+ fulfillment, or item kind. Those are the app's concern.
52
+
53
+ ## Line shape
54
+
55
+ The minimum for cart mechanics is `{ id, quantity, priceCents }`; `title`/`sku`
56
+ are optional and `metadata` passes through untouched. The engine is generic, so
57
+ map your product into a typed line and keep type-safety end to end:
58
+
59
+ ```ts
60
+ type MerchLine = CartItem & { metadata: { variationId: string; team: string } };
61
+ const cart = createCart<MerchLine>({ namespace: "plane-things", /* … */ });
62
+ ```
63
+
64
+ ## When does sync happen
65
+
66
+ 1. **Every mutation** → `localStorage` synchronously (the live cart, zero latency).
67
+ 2. **Durable push** → coalesced ~`debounceMs` after the last change, **plus** a
68
+ forced flush on `visibilitychange: hidden` / `pagehide` (so the backend is
69
+ current before the tab closes / checkout). `keepalive` lets that final PUT
70
+ land during unload.
71
+ 3. **`restore()`** → pulls from the durable store once when the local cart is
72
+ empty (returning / cross-device). Local wins when both exist.
73
+
74
+ ## The flow it slots into
75
+
76
+ ```text
77
+ frontend cart (this lib)
78
+ -> durable cart row (your sync adapter; usually Cloudflare D1)
79
+ -> checkout / payment
80
+ -> payment webhook
81
+ -> graft pipeline looks up the durable cart
82
+ -> creates merchant/POS records (Square today, Toast tomorrow — swappable)
83
+ ```
84
+
85
+ The durable row is the bridge. The app owns the `/api/cart` route (writes
86
+ whatever store it uses) and the post-payment pipeline. This package owns only
87
+ the left edge.
88
+
89
+ ## Sync adapter
90
+
91
+ `httpSync` posts to an app route:
92
+
93
+ ```text
94
+ PUT {endpoint} body: CartSnapshot -> 2xx (upsert)
95
+ GET {endpoint}?id=&namespace= -> { items } | 404
96
+ ```
97
+
98
+ Implement `CartSyncAdapter` yourself to target anything else (direct DB client,
99
+ KV, IndexedDB, …) — `push(snapshot)` / `pull(id, namespace)`.
@@ -0,0 +1,60 @@
1
+ import type { CartItem, CartSnapshot, CreateCartOptions } from "./types.js";
2
+ export declare class CartEngine<TItem extends CartItem = CartItem> {
3
+ readonly namespace: string;
4
+ private lines;
5
+ private readonly cartId;
6
+ private readonly listeners;
7
+ private readonly storage;
8
+ private readonly sync?;
9
+ private readonly debounceMs;
10
+ private flushTimer;
11
+ private hideHandler?;
12
+ /** Stable reference returned from getSnapshot until lines actually change. */
13
+ private snapshotRef;
14
+ constructor(opts: CreateCartOptions<TItem>);
15
+ get id(): string;
16
+ private loadId;
17
+ private loadLines;
18
+ private persistLocal;
19
+ subscribe: (fn: () => void) => (() => void);
20
+ /** Stable identity between mutations — safe for `useSyncExternalStore`. */
21
+ getSnapshot: () => TItem[];
22
+ private emit;
23
+ private commit;
24
+ /** Add a line. Merges by `id` (quantities sum). Defaults quantity to 1. */
25
+ add(item: TItem): void;
26
+ /** Set an exact quantity. `<= 0` removes the line. */
27
+ updateQuantity(id: string, quantity: number): void;
28
+ remove(id: string): void;
29
+ clear(): void;
30
+ get items(): TItem[];
31
+ get count(): number;
32
+ get subtotalCents(): number;
33
+ snapshot(): CartSnapshot<TItem>;
34
+ /** Seed from the durable store when the local cart is empty (cross-device). */
35
+ restore(): Promise<void>;
36
+ private schedulePush;
37
+ private push;
38
+ /**
39
+ * Force the durable mirror current NOW. Call before checkout so the order
40
+ * reads a definitely-current cart; also runs automatically on tab-hide.
41
+ */
42
+ flush(): Promise<void>;
43
+ /** Detach lifecycle listeners (call when tearing the engine down). */
44
+ dispose(): void;
45
+ }
46
+ /**
47
+ * Create a configured cart engine. The only required option is `namespace`;
48
+ * pass a `sync` adapter to enable the durable mirror.
49
+ *
50
+ * ```ts
51
+ * const cart = createCart({
52
+ * namespace: "plane-things",
53
+ * storage: localStorage,
54
+ * sync: httpSync({ endpoint: "/api/cart" }),
55
+ * debounceMs: 400,
56
+ * });
57
+ * ```
58
+ */
59
+ export declare function createCart<TItem extends CartItem = CartItem>(opts: CreateCartOptions<TItem>): CartEngine<TItem>;
60
+ //# sourceMappingURL=engine.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../src/engine.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EACV,QAAQ,EACR,YAAY,EAEZ,iBAAiB,EAClB,MAAM,YAAY,CAAC;AAuBpB,qBAAa,UAAU,CAAC,KAAK,SAAS,QAAQ,GAAG,QAAQ;IACvD,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAE3B,OAAO,CAAC,KAAK,CAAe;IAC5B,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;IAChC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAyB;IACnD,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAiB;IACzC,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAyB;IAC/C,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAS;IACpC,OAAO,CAAC,UAAU,CAA8C;IAChE,OAAO,CAAC,WAAW,CAAC,CAAa;IACjC,8EAA8E;IAC9E,OAAO,CAAC,WAAW,CAAe;gBAEtB,IAAI,EAAE,iBAAiB,CAAC,KAAK,CAAC;IAyB1C,IAAI,EAAE,IAAI,MAAM,CAEf;IAED,OAAO,CAAC,MAAM;IAQd,OAAO,CAAC,SAAS;IAWjB,OAAO,CAAC,YAAY;IAMpB,SAAS,GAAI,IAAI,MAAM,IAAI,KAAG,CAAC,MAAM,IAAI,CAAC,CAGxC;IAEF,2EAA2E;IAC3E,WAAW,QAAO,KAAK,EAAE,CAAqB;IAE9C,OAAO,CAAC,IAAI;IAOZ,OAAO,CAAC,MAAM;IAOd,2EAA2E;IAC3E,GAAG,CAAC,IAAI,EAAE,KAAK,GAAG,IAAI;IActB,sDAAsD;IACtD,cAAc,CAAC,EAAE,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,IAAI;IAYlD,MAAM,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI;IAKxB,KAAK,IAAI,IAAI;IAMb,IAAI,KAAK,IAAI,KAAK,EAAE,CAEnB;IAED,IAAI,KAAK,IAAI,MAAM,CAElB;IAED,IAAI,aAAa,IAAI,MAAM,CAE1B;IAED,QAAQ,IAAI,YAAY,CAAC,KAAK,CAAC;IAW/B,+EAA+E;IACzE,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IAc9B,OAAO,CAAC,YAAY;YASN,IAAI;IASlB;;;OAGG;IACG,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAQ5B,sEAAsE;IACtE,OAAO,IAAI,IAAI;CAQhB;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,UAAU,CAAC,KAAK,SAAS,QAAQ,GAAG,QAAQ,EAC1D,IAAI,EAAE,iBAAiB,CAAC,KAAK,CAAC,GAC7B,UAAU,CAAC,KAAK,CAAC,CAEnB"}
package/dist/engine.js ADDED
@@ -0,0 +1,231 @@
1
+ // The headless cart engine. Framework-agnostic. Mutations write storage
2
+ // synchronously (the live cart is never behind), then schedule a debounced
3
+ // durable push; a forced flush runs on tab-hide and can be called before
4
+ // checkout. Observable via subscribe/getSnapshot so any UI (React's
5
+ // useSyncExternalStore, or otherwise) can bind to it.
6
+ const itemsKey = (ns) => `cart:${ns}:items`;
7
+ const idKey = (ns) => `cart:${ns}:id`;
8
+ function safeLocalStorage() {
9
+ try {
10
+ return typeof localStorage !== "undefined" ? localStorage : null;
11
+ }
12
+ catch {
13
+ // Access can throw (privacy mode, sandboxed iframe) — degrade to memory.
14
+ return null;
15
+ }
16
+ }
17
+ function defaultNewId() {
18
+ try {
19
+ if (typeof crypto !== "undefined" && crypto.randomUUID)
20
+ return crypto.randomUUID();
21
+ }
22
+ catch {
23
+ /* fall through */
24
+ }
25
+ return `c_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`;
26
+ }
27
+ export class CartEngine {
28
+ constructor(opts) {
29
+ this.lines = [];
30
+ this.listeners = new Set();
31
+ this.flushTimer = null;
32
+ /** Stable reference returned from getSnapshot until lines actually change. */
33
+ this.snapshotRef = [];
34
+ // ---- observation -------------------------------------------------------
35
+ this.subscribe = (fn) => {
36
+ this.listeners.add(fn);
37
+ return () => this.listeners.delete(fn);
38
+ };
39
+ /** Stable identity between mutations — safe for `useSyncExternalStore`. */
40
+ this.getSnapshot = () => this.snapshotRef;
41
+ this.namespace = opts.namespace;
42
+ this.storage = opts.storage === undefined ? safeLocalStorage() : opts.storage;
43
+ this.sync = opts.sync;
44
+ this.debounceMs = opts.debounceMs ?? 400;
45
+ const mint = opts.newId ?? defaultNewId;
46
+ this.cartId = this.loadId(mint);
47
+ this.lines = this.loadLines();
48
+ this.snapshotRef = this.lines;
49
+ // Hard-guarantee the durable copy lands before the tab goes away (close,
50
+ // navigate, mobile background). `visibilitychange→hidden` is the reliable
51
+ // cross-browser signal; `pagehide` covers bfcache.
52
+ if (typeof document !== "undefined" && this.sync) {
53
+ this.hideHandler = () => {
54
+ if (document.visibilityState === "hidden")
55
+ void this.flush();
56
+ };
57
+ document.addEventListener("visibilitychange", this.hideHandler);
58
+ window.addEventListener("pagehide", this.hideHandler);
59
+ }
60
+ }
61
+ // ---- identity / persistence (restore from storage) ---------------------
62
+ get id() {
63
+ return this.cartId;
64
+ }
65
+ loadId(mint) {
66
+ const existing = this.storage?.getItem(idKey(this.namespace));
67
+ if (existing)
68
+ return existing;
69
+ const id = mint();
70
+ this.storage?.setItem(idKey(this.namespace), id);
71
+ return id;
72
+ }
73
+ loadLines() {
74
+ const raw = this.storage?.getItem(itemsKey(this.namespace));
75
+ if (!raw)
76
+ return [];
77
+ try {
78
+ const parsed = JSON.parse(raw);
79
+ return Array.isArray(parsed) ? parsed : [];
80
+ }
81
+ catch {
82
+ return [];
83
+ }
84
+ }
85
+ persistLocal() {
86
+ this.storage?.setItem(itemsKey(this.namespace), JSON.stringify(this.lines));
87
+ }
88
+ emit() {
89
+ this.snapshotRef = this.lines;
90
+ for (const fn of this.listeners)
91
+ fn();
92
+ }
93
+ // ---- mutations ---------------------------------------------------------
94
+ commit(next) {
95
+ this.lines = next;
96
+ this.persistLocal(); // synchronous: the live cart is never behind
97
+ this.emit();
98
+ this.schedulePush(); // durable mirror, coalesced
99
+ }
100
+ /** Add a line. Merges by `id` (quantities sum). Defaults quantity to 1. */
101
+ add(item) {
102
+ const qty = item.quantity ?? 1;
103
+ if (qty <= 0)
104
+ return;
105
+ const i = this.lines.findIndex((l) => l.id === item.id);
106
+ if (i >= 0) {
107
+ const next = this.lines.slice();
108
+ const existing = next[i];
109
+ next[i] = { ...existing, ...item, quantity: existing.quantity + qty };
110
+ this.commit(next);
111
+ }
112
+ else {
113
+ this.commit([...this.lines, { ...item, quantity: qty }]);
114
+ }
115
+ }
116
+ /** Set an exact quantity. `<= 0` removes the line. */
117
+ updateQuantity(id, quantity) {
118
+ if (quantity <= 0) {
119
+ this.remove(id);
120
+ return;
121
+ }
122
+ const i = this.lines.findIndex((l) => l.id === id);
123
+ if (i < 0)
124
+ return;
125
+ const next = this.lines.slice();
126
+ next[i] = { ...next[i], quantity };
127
+ this.commit(next);
128
+ }
129
+ remove(id) {
130
+ const next = this.lines.filter((l) => l.id !== id);
131
+ if (next.length !== this.lines.length)
132
+ this.commit(next);
133
+ }
134
+ clear() {
135
+ if (this.lines.length)
136
+ this.commit([]);
137
+ }
138
+ // ---- derived -----------------------------------------------------------
139
+ get items() {
140
+ return this.lines;
141
+ }
142
+ get count() {
143
+ return this.lines.reduce((n, l) => n + l.quantity, 0);
144
+ }
145
+ get subtotalCents() {
146
+ return this.lines.reduce((n, l) => n + l.priceCents * l.quantity, 0);
147
+ }
148
+ snapshot() {
149
+ return {
150
+ id: this.cartId,
151
+ namespace: this.namespace,
152
+ items: this.lines,
153
+ updatedAt: Date.now(),
154
+ };
155
+ }
156
+ // ---- durable sync ------------------------------------------------------
157
+ /** Seed from the durable store when the local cart is empty (cross-device). */
158
+ async restore() {
159
+ if (!this.sync || this.lines.length > 0)
160
+ return;
161
+ try {
162
+ const remote = await this.sync.pull(this.cartId, this.namespace);
163
+ if (remote && remote.length && this.lines.length === 0) {
164
+ this.lines = remote;
165
+ this.persistLocal();
166
+ this.emit();
167
+ }
168
+ }
169
+ catch {
170
+ // Offline / store down → keep the local cart. Non-fatal by design.
171
+ }
172
+ }
173
+ schedulePush() {
174
+ if (!this.sync)
175
+ return;
176
+ if (this.flushTimer)
177
+ clearTimeout(this.flushTimer);
178
+ this.flushTimer = setTimeout(() => {
179
+ this.flushTimer = null;
180
+ void this.push();
181
+ }, this.debounceMs);
182
+ }
183
+ async push() {
184
+ if (!this.sync)
185
+ return;
186
+ try {
187
+ await this.sync.push(this.snapshot());
188
+ }
189
+ catch {
190
+ // Durable mirror lagged; local is source of truth, next change retries.
191
+ }
192
+ }
193
+ /**
194
+ * Force the durable mirror current NOW. Call before checkout so the order
195
+ * reads a definitely-current cart; also runs automatically on tab-hide.
196
+ */
197
+ async flush() {
198
+ if (this.flushTimer) {
199
+ clearTimeout(this.flushTimer);
200
+ this.flushTimer = null;
201
+ }
202
+ await this.push();
203
+ }
204
+ /** Detach lifecycle listeners (call when tearing the engine down). */
205
+ dispose() {
206
+ if (this.hideHandler && typeof document !== "undefined") {
207
+ document.removeEventListener("visibilitychange", this.hideHandler);
208
+ window.removeEventListener("pagehide", this.hideHandler);
209
+ }
210
+ if (this.flushTimer)
211
+ clearTimeout(this.flushTimer);
212
+ this.listeners.clear();
213
+ }
214
+ }
215
+ /**
216
+ * Create a configured cart engine. The only required option is `namespace`;
217
+ * pass a `sync` adapter to enable the durable mirror.
218
+ *
219
+ * ```ts
220
+ * const cart = createCart({
221
+ * namespace: "plane-things",
222
+ * storage: localStorage,
223
+ * sync: httpSync({ endpoint: "/api/cart" }),
224
+ * debounceMs: 400,
225
+ * });
226
+ * ```
227
+ */
228
+ export function createCart(opts) {
229
+ return new CartEngine(opts);
230
+ }
231
+ //# sourceMappingURL=engine.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"engine.js","sourceRoot":"","sources":["../src/engine.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,18 @@
1
+ import type { CartItem, CartSyncAdapter } from "./types.js";
2
+ export interface HttpSyncOptions {
3
+ /** Route that owns the durable store. Default `/api/cart`. */
4
+ endpoint?: string;
5
+ /** Static extra headers merged into every request. */
6
+ headers?: Record<string, string>;
7
+ /**
8
+ * Optional bearer token, attached ONLY when it resolves to a non-empty value
9
+ * — so the cart is guest-first (no token → anonymous request the route accepts)
10
+ * and authed when a user is signed in (token → route can tag the owner). Can be
11
+ * sync or async (e.g. read a session). Never required.
12
+ */
13
+ getToken?: () => string | null | undefined | Promise<string | null | undefined>;
14
+ /** Injectable fetch (default global). */
15
+ fetch?: typeof fetch;
16
+ }
17
+ export declare function httpSync<TItem extends CartItem = CartItem>(opts?: HttpSyncOptions): CartSyncAdapter<TItem>;
18
+ //# sourceMappingURL=http-sync.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"http-sync.d.ts","sourceRoot":"","sources":["../src/http-sync.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,QAAQ,EAAgB,eAAe,EAAE,MAAM,YAAY,CAAC;AAE1E,MAAM,WAAW,eAAe;IAC9B,8DAA8D;IAC9D,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,sDAAsD;IACtD,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACjC;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,MAAM,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC,CAAC;IAChF,yCAAyC;IACzC,KAAK,CAAC,EAAE,OAAO,KAAK,CAAC;CACtB;AAED,wBAAgB,QAAQ,CAAC,KAAK,SAAS,QAAQ,GAAG,QAAQ,EACxD,IAAI,GAAE,eAAoB,GACzB,eAAe,CAAC,KAAK,CAAC,CAkCxB"}
@@ -0,0 +1,44 @@
1
+ // HTTP sync adapter — the default durable write path. The browser can't touch a
2
+ // server store directly, so it speaks to an app route (which owns the binding:
3
+ // D1, turso, KV, whatever). Keeps the engine platform-agnostic — swap the
4
+ // endpoint's implementation without touching the lib.
5
+ //
6
+ // Wire contract (the app implements both at `endpoint`):
7
+ // PUT {endpoint} body: CartSnapshot -> 2xx (upsert)
8
+ // GET {endpoint}?id={id}&namespace={ns} -> { items: CartItem[] } | 404
9
+ export function httpSync(opts = {}) {
10
+ const endpoint = opts.endpoint ?? "/api/cart";
11
+ const doFetch = opts.fetch ?? globalThis.fetch.bind(globalThis);
12
+ const base = { "content-type": "application/json", ...opts.headers };
13
+ // Resolve per-request so a token that appears mid-session (user logs in) is
14
+ // picked up without recreating the adapter. Guest path: returns `base` as-is.
15
+ async function headers() {
16
+ const token = opts.getToken ? await opts.getToken() : null;
17
+ return token ? { ...base, authorization: `Bearer ${token}` } : base;
18
+ }
19
+ return {
20
+ async push(snapshot) {
21
+ // `keepalive` lets the tab-hide flush finish after the page starts
22
+ // unloading — the browser equivalent of sendBeacon for a PUT with a body.
23
+ const res = await doFetch(endpoint, {
24
+ method: "PUT",
25
+ headers: await headers(),
26
+ body: JSON.stringify(snapshot),
27
+ keepalive: true,
28
+ });
29
+ if (!res.ok)
30
+ throw new Error(`cart push ${res.status}`);
31
+ },
32
+ async pull(id, namespace) {
33
+ const url = `${endpoint}?id=${encodeURIComponent(id)}&namespace=${encodeURIComponent(namespace)}`;
34
+ const res = await doFetch(url, { headers: await headers() });
35
+ if (res.status === 404)
36
+ return null;
37
+ if (!res.ok)
38
+ throw new Error(`cart pull ${res.status}`);
39
+ const body = (await res.json());
40
+ return body.items ?? null;
41
+ },
42
+ };
43
+ }
44
+ //# sourceMappingURL=http-sync.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"http-sync.js","sourceRoot":"","sources":["../src/http-sync.ts"],"names":[],"mappings":"AAAA,gFAAgF;AAChF,+EAA+E;AAC/E,0EAA0E;AAC1E,sDAAsD;AACtD,EAAE;AACF,yDAAyD;AACzD,4EAA4E;AAC5E,0FAA0F;AAoB1F,MAAM,UAAU,QAAQ,CACtB,OAAwB,EAAE;IAE1B,MAAM,QAAQ,GAAG,IAAI,CAAC,QAAQ,IAAI,WAAW,CAAC;IAC9C,MAAM,OAAO,GAAG,IAAI,CAAC,KAAK,IAAI,UAAU,CAAC,KAAK,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC;IAChE,MAAM,IAAI,GAAG,EAAE,cAAc,EAAE,kBAAkB,EAAE,GAAG,IAAI,CAAC,OAAO,EAAE,CAAC;IAErE,4EAA4E;IAC5E,8EAA8E;IAC9E,KAAK,UAAU,OAAO;QACpB,MAAM,KAAK,GAAG,IAAI,CAAC,QAAQ,CAAC,CAAC,CAAC,MAAM,IAAI,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC;QAC3D,OAAO,KAAK,CAAC,CAAC,CAAC,EAAE,GAAG,IAAI,EAAE,aAAa,EAAE,UAAU,KAAK,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC;IACtE,CAAC;IAED,OAAO;QACL,KAAK,CAAC,IAAI,CAAC,QAA6B;YACtC,mEAAmE;YACnE,0EAA0E;YAC1E,MAAM,GAAG,GAAG,MAAM,OAAO,CAAC,QAAQ,EAAE;gBAClC,MAAM,EAAE,KAAK;gBACb,OAAO,EAAE,MAAM,OAAO,EAAE;gBACxB,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,QAAQ,CAAC;gBAC9B,SAAS,EAAE,IAAI;aAChB,CAAC,CAAC;YACH,IAAI,CAAC,GAAG,CAAC,EAAE;gBAAE,MAAM,IAAI,KAAK,CAAC,aAAa,GAAG,CAAC,MAAM,EAAE,CAAC,CAAC;QAC1D,CAAC;QAED,KAAK,CAAC,IAAI,CAAC,EAAU,EAAE,SAAiB;YACtC,MAAM,GAAG,GAAG,GAAG,QAAQ,OAAO,kBAAkB,CAAC,EAAE,CAAC,cAAc,kBAAkB,CAAC,SAAS,CAAC,EAAE,CAAC;YAClG,MAAM,GAAG,GAAG,MAAM,OAAO,CAAC,GAAG,EAAE,EAAE,OAAO,EAAE,MAAM,OAAO,EAAE,EAAE,CAAC,CAAC;YAC7D,IAAI,GAAG,CAAC,MAAM,KAAK,GAAG;gBAAE,OAAO,IAAI,CAAC;YACpC,IAAI,CAAC,GAAG,CAAC,EAAE;gBAAE,MAAM,IAAI,KAAK,CAAC,aAAa,GAAG,CAAC,MAAM,EAAE,CAAC,CAAC;YACxD,MAAM,IAAI,GAAG,CAAC,MAAM,GAAG,CAAC,IAAI,EAAE,CAAwB,CAAC;YACvD,OAAO,IAAI,CAAC,KAAK,IAAI,IAAI,CAAC;QAC5B,CAAC;KACF,CAAC;AACJ,CAAC"}
@@ -0,0 +1,4 @@
1
+ export { CartEngine, createCart } from "./engine.js";
2
+ export { httpSync, type HttpSyncOptions } from "./http-sync.js";
3
+ export type { CartItem, CartSnapshot, CartSyncAdapter, CreateCartOptions, } from "./types.js";
4
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAKA,OAAO,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AACrD,OAAO,EAAE,QAAQ,EAAE,KAAK,eAAe,EAAE,MAAM,gBAAgB,CAAC;AAChE,YAAY,EACV,QAAQ,EACR,YAAY,EACZ,eAAe,EACf,iBAAiB,GAClB,MAAM,YAAY,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,7 @@
1
+ // @ikeboy003/cart — headless cart primitive.
2
+ // Core (framework-agnostic) + the default HTTP sync adapter. The React binding
3
+ // is a separate entry (`@ikeboy003/cart/react`) so server / non-React code can
4
+ // use the engine without React.
5
+ export { CartEngine, createCart } from "./engine.js";
6
+ export { httpSync } from "./http-sync.js";
7
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,6CAA6C;AAC7C,+EAA+E;AAC/E,+EAA+E;AAC/E,gCAAgC;AAEhC,OAAO,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AACrD,OAAO,EAAE,QAAQ,EAAwB,MAAM,gBAAgB,CAAC"}
@@ -0,0 +1,20 @@
1
+ import { type ReactNode } from "react";
2
+ import type { CartItem, CreateCartOptions } from "./types.js";
3
+ export interface CartProviderProps<TItem extends CartItem = CartItem> extends CreateCartOptions<TItem> {
4
+ children: ReactNode;
5
+ }
6
+ export declare function CartProvider<TItem extends CartItem = CartItem>({ children, ...opts }: CartProviderProps<TItem>): import("react").JSX.Element;
7
+ export declare function useCart<TItem extends CartItem = CartItem>(): {
8
+ cartId: string;
9
+ items: TItem[];
10
+ count: number;
11
+ /** Subtotal in cents. */
12
+ subtotalCents: number;
13
+ add: (item: TItem) => void;
14
+ remove: (id: string) => void;
15
+ updateQuantity: (id: string, quantity: number) => void;
16
+ clear: () => void;
17
+ /** Force the durable mirror current — call before navigating to checkout. */
18
+ flush: () => Promise<void>;
19
+ };
20
+ //# sourceMappingURL=react.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"react.d.ts","sourceRoot":"","sources":["../src/react.tsx"],"names":[],"mappings":"AAOA,OAAO,EAML,KAAK,SAAS,EACf,MAAM,OAAO,CAAC;AAEf,OAAO,KAAK,EAAE,QAAQ,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAK9D,MAAM,WAAW,iBAAiB,CAAC,KAAK,SAAS,QAAQ,GAAG,QAAQ,CAClE,SAAQ,iBAAiB,CAAC,KAAK,CAAC;IAChC,QAAQ,EAAE,SAAS,CAAC;CACrB;AAED,wBAAgB,YAAY,CAAC,KAAK,SAAS,QAAQ,GAAG,QAAQ,EAAE,EAC9D,QAAQ,EACR,GAAG,IAAI,EACR,EAAE,iBAAiB,CAAC,KAAK,CAAC,+BAc1B;AAED,wBAAgB,OAAO,CAAC,KAAK,SAAS,QAAQ,GAAG,QAAQ;;;;IAiBrD,yBAAyB;;gBAEb,KAAK;iBACJ,MAAM;yBACE,MAAM,YAAY,MAAM;;IAE7C,6EAA6E;;EAGhF"}
package/dist/react.js ADDED
@@ -0,0 +1,43 @@
1
+ // Optional React binding — `CartProvider` + `useCart`. Headless: it exposes cart
2
+ // state and actions, no markup. You design the drawer, the add button, the
3
+ // checkout page. Imported separately (`@ikeboy003/cart/react`) so non-React /
4
+ // server code can use the engine without pulling React in.
5
+ "use client";
6
+ import { jsx as _jsx } from "react/jsx-runtime";
7
+ import { createContext, useContext, useEffect, useMemo, useSyncExternalStore, } from "react";
8
+ import { CartEngine } from "./engine.js";
9
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
10
+ const EngineContext = createContext(null);
11
+ export function CartProvider({ children, ...opts }) {
12
+ // One engine per mount, keyed by namespace (effectively stable).
13
+ const engine = useMemo(() => new CartEngine(opts),
14
+ // eslint-disable-next-line react-hooks/exhaustive-deps
15
+ [opts.namespace]);
16
+ useEffect(() => {
17
+ void engine.restore();
18
+ return () => engine.dispose();
19
+ }, [engine]);
20
+ return _jsx(EngineContext.Provider, { value: engine, children: children });
21
+ }
22
+ export function useCart() {
23
+ const engine = useContext(EngineContext);
24
+ if (!engine)
25
+ throw new Error("useCart must be used inside <CartProvider>");
26
+ const items = useSyncExternalStore(engine.subscribe, engine.getSnapshot, engine.getSnapshot);
27
+ const count = items.reduce((n, l) => n + l.quantity, 0);
28
+ const subtotalCents = items.reduce((n, l) => n + l.priceCents * l.quantity, 0);
29
+ return {
30
+ cartId: engine.id,
31
+ items,
32
+ count,
33
+ /** Subtotal in cents. */
34
+ subtotalCents,
35
+ add: (item) => engine.add(item),
36
+ remove: (id) => engine.remove(id),
37
+ updateQuantity: (id, quantity) => engine.updateQuantity(id, quantity),
38
+ clear: () => engine.clear(),
39
+ /** Force the durable mirror current — call before navigating to checkout. */
40
+ flush: () => engine.flush(),
41
+ };
42
+ }
43
+ //# sourceMappingURL=react.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"react.js","sourceRoot":"","sources":["../src/react.tsx"],"names":[],"mappings":"AAAA,iFAAiF;AACjF,2EAA2E;AAC3E,8EAA8E;AAC9E,2DAA2D;AAE3D,YAAY,CAAC;;AAEb,OAAO,EACL,aAAa,EACb,UAAU,EACV,SAAS,EACT,OAAO,EACP,oBAAoB,GAErB,MAAM,OAAO,CAAC;AACf,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAGzC,8DAA8D;AAC9D,MAAM,aAAa,GAAG,aAAa,CAAyB,IAAI,CAAC,CAAC;AAOlE,MAAM,UAAU,YAAY,CAAoC,EAC9D,QAAQ,EACR,GAAG,IAAI,EACkB;IACzB,iEAAiE;IACjE,MAAM,MAAM,GAAG,OAAO,CACpB,GAAG,EAAE,CAAC,IAAI,UAAU,CAAQ,IAAI,CAAC;IACjC,uDAAuD;IACvD,CAAC,IAAI,CAAC,SAAS,CAAC,CACjB,CAAC;IAEF,SAAS,CAAC,GAAG,EAAE;QACb,KAAK,MAAM,CAAC,OAAO,EAAE,CAAC;QACtB,OAAO,GAAG,EAAE,CAAC,MAAM,CAAC,OAAO,EAAE,CAAC;IAChC,CAAC,EAAE,CAAC,MAAM,CAAC,CAAC,CAAC;IAEb,OAAO,KAAC,aAAa,CAAC,QAAQ,IAAC,KAAK,EAAE,MAAM,YAAG,QAAQ,GAA0B,CAAC;AACpF,CAAC;AAED,MAAM,UAAU,OAAO;IACrB,MAAM,MAAM,GAAG,UAAU,CAAC,aAAa,CAA6B,CAAC;IACrE,IAAI,CAAC,MAAM;QAAE,MAAM,IAAI,KAAK,CAAC,4CAA4C,CAAC,CAAC;IAE3E,MAAM,KAAK,GAAG,oBAAoB,CAChC,MAAM,CAAC,SAAS,EAChB,MAAM,CAAC,WAAW,EAClB,MAAM,CAAC,WAAW,CACnB,CAAC;IAEF,MAAM,KAAK,GAAG,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC;IACxD,MAAM,aAAa,GAAG,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,UAAU,GAAG,CAAC,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC;IAE/E,OAAO;QACL,MAAM,EAAE,MAAM,CAAC,EAAE;QACjB,KAAK;QACL,KAAK;QACL,yBAAyB;QACzB,aAAa;QACb,GAAG,EAAE,CAAC,IAAW,EAAE,EAAE,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC;QACtC,MAAM,EAAE,CAAC,EAAU,EAAE,EAAE,CAAC,MAAM,CAAC,MAAM,CAAC,EAAE,CAAC;QACzC,cAAc,EAAE,CAAC,EAAU,EAAE,QAAgB,EAAE,EAAE,CAAC,MAAM,CAAC,cAAc,CAAC,EAAE,EAAE,QAAQ,CAAC;QACrF,KAAK,EAAE,GAAG,EAAE,CAAC,MAAM,CAAC,KAAK,EAAE;QAC3B,6EAA6E;QAC7E,KAAK,EAAE,GAAG,EAAE,CAAC,MAAM,CAAC,KAAK,EAAE;KAC5B,CAAC;AACJ,CAAC"}
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The minimum a line needs for cart mechanics: a stable `id`, a `quantity`, and
3
+ * a `priceCents` (so count/subtotal work). Everything else is optional or rides
4
+ * in `metadata` untouched — the app maps its product shape onto this. The engine
5
+ * is generic over the line type, so a site can extend this with typed fields and
6
+ * keep full type-safety end to end.
7
+ */
8
+ export interface CartItem {
9
+ id: string;
10
+ quantity: number;
11
+ priceCents: number;
12
+ title?: string;
13
+ sku?: string;
14
+ /** Opaque passthrough — variationId, options, anything the POS pipeline reads. */
15
+ metadata?: Record<string, unknown>;
16
+ }
17
+ /** The whole cart at a moment — the unit a sync adapter pushes/pulls. */
18
+ export interface CartSnapshot<TItem extends CartItem = CartItem> {
19
+ /** Anonymous cart id (uuid), minted client-side, persisted in storage. */
20
+ id: string;
21
+ /** App-chosen scope (e.g. a site/store name). Opaque to the lib. */
22
+ namespace: string;
23
+ items: TItem[];
24
+ /** Epoch ms of the last local mutation — lets a durable store keep newest-wins. */
25
+ updatedAt: number;
26
+ }
27
+ /**
28
+ * Configurable durable write path — the equivalent of postgrest-rs's connection
29
+ * string. The engine calls `push` (debounced + on flush/tab-hide) and `pull`
30
+ * (on `restore()` when local is empty). The lib ships an HTTP adapter; an app
31
+ * can pass any implementation. Throwing is non-fatal: the local cart is the
32
+ * source of truth and the next change retries.
33
+ */
34
+ export interface CartSyncAdapter<TItem extends CartItem = CartItem> {
35
+ push(snapshot: CartSnapshot<TItem>): Promise<void>;
36
+ pull(id: string, namespace: string): Promise<TItem[] | null>;
37
+ }
38
+ /** Config passed at import — nothing brand-specific is baked in. */
39
+ export interface CreateCartOptions<TItem extends CartItem = CartItem> {
40
+ /** Scopes the storage keys + cart id, and is carried in the snapshot. */
41
+ namespace: string;
42
+ /** Persistence. Defaults to localStorage; pass `null` for in-memory only. */
43
+ storage?: Storage | null;
44
+ /** Durable mirror. Omit for local-only. */
45
+ sync?: CartSyncAdapter<TItem>;
46
+ /** Coalesce window for durable pushes (ms). Default 400. */
47
+ debounceMs?: number;
48
+ /** Id minter. Default `crypto.randomUUID`. */
49
+ newId?: () => string;
50
+ }
51
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAiBA;;;;;;GAMG;AACH,MAAM,WAAW,QAAQ;IACvB,EAAE,EAAE,MAAM,CAAC;IACX,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,kFAAkF;IAClF,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACpC;AAED,yEAAyE;AACzE,MAAM,WAAW,YAAY,CAAC,KAAK,SAAS,QAAQ,GAAG,QAAQ;IAC7D,0EAA0E;IAC1E,EAAE,EAAE,MAAM,CAAC;IACX,oEAAoE;IACpE,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,KAAK,EAAE,CAAC;IACf,mFAAmF;IACnF,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;;;;;GAMG;AACH,MAAM,WAAW,eAAe,CAAC,KAAK,SAAS,QAAQ,GAAG,QAAQ;IAChE,IAAI,CAAC,QAAQ,EAAE,YAAY,CAAC,KAAK,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACnD,IAAI,CAAC,EAAE,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,KAAK,EAAE,GAAG,IAAI,CAAC,CAAC;CAC9D;AAED,oEAAoE;AACpE,MAAM,WAAW,iBAAiB,CAAC,KAAK,SAAS,QAAQ,GAAG,QAAQ;IAClE,yEAAyE;IACzE,SAAS,EAAE,MAAM,CAAC;IAClB,6EAA6E;IAC7E,OAAO,CAAC,EAAE,OAAO,GAAG,IAAI,CAAC;IACzB,2CAA2C;IAC3C,IAAI,CAAC,EAAE,eAAe,CAAC,KAAK,CAAC,CAAC;IAC9B,4DAA4D;IAC5D,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,8CAA8C;IAC9C,KAAK,CAAC,EAAE,MAAM,MAAM,CAAC;CACtB"}
package/dist/types.js ADDED
@@ -0,0 +1,18 @@
1
+ // @ikeboy003/cart — headless cart primitive. "postgrest-rs for carts": import
2
+ // the engine, inject config (namespace, storage, sync), design your own UI.
3
+ //
4
+ // The library knows NOTHING about Square / Toast / Stripe / D1 / Cloudflare /
5
+ // auth / fulfillment / item kind. Those are the app's concern. The proven flow
6
+ // the primitive slots into:
7
+ //
8
+ // frontend cart (this lib)
9
+ // -> durable cart row (app's sync adapter; usually D1)
10
+ // -> checkout / payment
11
+ // -> payment webhook
12
+ // -> graft pipeline looks up the durable cart
13
+ // -> creates merchant/POS records (Square, Toast, ... — swappable sink)
14
+ //
15
+ // The lib owns only the left edge: local cart state + a configurable durable
16
+ // write path. Everything right of the durable row is runtime/config.
17
+ export {};
18
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,8EAA8E;AAC9E,4EAA4E;AAC5E,EAAE;AACF,8EAA8E;AAC9E,+EAA+E;AAC/E,4BAA4B;AAC5B,EAAE;AACF,6BAA6B;AAC7B,2DAA2D;AAC3D,4BAA4B;AAC5B,yBAAyB;AACzB,kDAAkD;AAClD,6EAA6E;AAC7E,EAAE;AACF,6EAA6E;AAC7E,qEAAqE"}
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@ikeboy003/cart",
3
+ "version": "0.1.0",
4
+ "description": "Headless cart primitive: local-first state + a configurable durable sync path. Framework-agnostic core, optional React binding. No POS/payment/store logic baked in.",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./dist/index.d.ts",
9
+ "import": "./dist/index.js"
10
+ },
11
+ "./react": {
12
+ "types": "./dist/react.d.ts",
13
+ "import": "./dist/react.js"
14
+ }
15
+ },
16
+ "files": ["dist", "src"],
17
+ "sideEffects": false,
18
+ "scripts": {
19
+ "build": "tsc -p tsconfig.json",
20
+ "dev": "tsc -p tsconfig.json --watch",
21
+ "test": "node --test test/*.test.mjs"
22
+ },
23
+ "keywords": ["cart", "ecommerce", "headless", "local-first"],
24
+ "license": "MIT",
25
+ "publishConfig": { "access": "public" },
26
+ "peerDependencies": {
27
+ "react": ">=18"
28
+ },
29
+ "peerDependenciesMeta": {
30
+ "react": { "optional": true }
31
+ },
32
+ "devDependencies": {
33
+ "@types/react": "^19",
34
+ "typescript": "^5.5.0"
35
+ }
36
+ }
package/src/engine.ts ADDED
@@ -0,0 +1,260 @@
1
+ // The headless cart engine. Framework-agnostic. Mutations write storage
2
+ // synchronously (the live cart is never behind), then schedule a debounced
3
+ // durable push; a forced flush runs on tab-hide and can be called before
4
+ // checkout. Observable via subscribe/getSnapshot so any UI (React's
5
+ // useSyncExternalStore, or otherwise) can bind to it.
6
+
7
+ import type {
8
+ CartItem,
9
+ CartSnapshot,
10
+ CartSyncAdapter,
11
+ CreateCartOptions,
12
+ } from "./types.js";
13
+
14
+ const itemsKey = (ns: string) => `cart:${ns}:items`;
15
+ const idKey = (ns: string) => `cart:${ns}:id`;
16
+
17
+ function safeLocalStorage(): Storage | null {
18
+ try {
19
+ return typeof localStorage !== "undefined" ? localStorage : null;
20
+ } catch {
21
+ // Access can throw (privacy mode, sandboxed iframe) — degrade to memory.
22
+ return null;
23
+ }
24
+ }
25
+
26
+ function defaultNewId(): string {
27
+ try {
28
+ if (typeof crypto !== "undefined" && crypto.randomUUID) return crypto.randomUUID();
29
+ } catch {
30
+ /* fall through */
31
+ }
32
+ return `c_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`;
33
+ }
34
+
35
+ export class CartEngine<TItem extends CartItem = CartItem> {
36
+ readonly namespace: string;
37
+
38
+ private lines: TItem[] = [];
39
+ private readonly cartId: string;
40
+ private readonly listeners = new Set<() => void>();
41
+ private readonly storage: Storage | null;
42
+ private readonly sync?: CartSyncAdapter<TItem>;
43
+ private readonly debounceMs: number;
44
+ private flushTimer: ReturnType<typeof setTimeout> | null = null;
45
+ private hideHandler?: () => void;
46
+ /** Stable reference returned from getSnapshot until lines actually change. */
47
+ private snapshotRef: TItem[] = [];
48
+
49
+ constructor(opts: CreateCartOptions<TItem>) {
50
+ this.namespace = opts.namespace;
51
+ this.storage = opts.storage === undefined ? safeLocalStorage() : opts.storage;
52
+ this.sync = opts.sync;
53
+ this.debounceMs = opts.debounceMs ?? 400;
54
+ const mint = opts.newId ?? defaultNewId;
55
+
56
+ this.cartId = this.loadId(mint);
57
+ this.lines = this.loadLines();
58
+ this.snapshotRef = this.lines;
59
+
60
+ // Hard-guarantee the durable copy lands before the tab goes away (close,
61
+ // navigate, mobile background). `visibilitychange→hidden` is the reliable
62
+ // cross-browser signal; `pagehide` covers bfcache.
63
+ if (typeof document !== "undefined" && this.sync) {
64
+ this.hideHandler = () => {
65
+ if (document.visibilityState === "hidden") void this.flush();
66
+ };
67
+ document.addEventListener("visibilitychange", this.hideHandler);
68
+ window.addEventListener("pagehide", this.hideHandler);
69
+ }
70
+ }
71
+
72
+ // ---- identity / persistence (restore from storage) ---------------------
73
+
74
+ get id(): string {
75
+ return this.cartId;
76
+ }
77
+
78
+ private loadId(mint: () => string): string {
79
+ const existing = this.storage?.getItem(idKey(this.namespace));
80
+ if (existing) return existing;
81
+ const id = mint();
82
+ this.storage?.setItem(idKey(this.namespace), id);
83
+ return id;
84
+ }
85
+
86
+ private loadLines(): TItem[] {
87
+ const raw = this.storage?.getItem(itemsKey(this.namespace));
88
+ if (!raw) return [];
89
+ try {
90
+ const parsed = JSON.parse(raw);
91
+ return Array.isArray(parsed) ? (parsed as TItem[]) : [];
92
+ } catch {
93
+ return [];
94
+ }
95
+ }
96
+
97
+ private persistLocal(): void {
98
+ this.storage?.setItem(itemsKey(this.namespace), JSON.stringify(this.lines));
99
+ }
100
+
101
+ // ---- observation -------------------------------------------------------
102
+
103
+ subscribe = (fn: () => void): (() => void) => {
104
+ this.listeners.add(fn);
105
+ return () => this.listeners.delete(fn);
106
+ };
107
+
108
+ /** Stable identity between mutations — safe for `useSyncExternalStore`. */
109
+ getSnapshot = (): TItem[] => this.snapshotRef;
110
+
111
+ private emit(): void {
112
+ this.snapshotRef = this.lines;
113
+ for (const fn of this.listeners) fn();
114
+ }
115
+
116
+ // ---- mutations ---------------------------------------------------------
117
+
118
+ private commit(next: TItem[]): void {
119
+ this.lines = next;
120
+ this.persistLocal(); // synchronous: the live cart is never behind
121
+ this.emit();
122
+ this.schedulePush(); // durable mirror, coalesced
123
+ }
124
+
125
+ /** Add a line. Merges by `id` (quantities sum). Defaults quantity to 1. */
126
+ add(item: TItem): void {
127
+ const qty = item.quantity ?? 1;
128
+ if (qty <= 0) return;
129
+ const i = this.lines.findIndex((l) => l.id === item.id);
130
+ if (i >= 0) {
131
+ const next = this.lines.slice();
132
+ const existing = next[i] as TItem;
133
+ next[i] = { ...existing, ...item, quantity: existing.quantity + qty };
134
+ this.commit(next);
135
+ } else {
136
+ this.commit([...this.lines, { ...item, quantity: qty }]);
137
+ }
138
+ }
139
+
140
+ /** Set an exact quantity. `<= 0` removes the line. */
141
+ updateQuantity(id: string, quantity: number): void {
142
+ if (quantity <= 0) {
143
+ this.remove(id);
144
+ return;
145
+ }
146
+ const i = this.lines.findIndex((l) => l.id === id);
147
+ if (i < 0) return;
148
+ const next = this.lines.slice();
149
+ next[i] = { ...(next[i] as TItem), quantity };
150
+ this.commit(next);
151
+ }
152
+
153
+ remove(id: string): void {
154
+ const next = this.lines.filter((l) => l.id !== id);
155
+ if (next.length !== this.lines.length) this.commit(next);
156
+ }
157
+
158
+ clear(): void {
159
+ if (this.lines.length) this.commit([]);
160
+ }
161
+
162
+ // ---- derived -----------------------------------------------------------
163
+
164
+ get items(): TItem[] {
165
+ return this.lines;
166
+ }
167
+
168
+ get count(): number {
169
+ return this.lines.reduce((n, l) => n + l.quantity, 0);
170
+ }
171
+
172
+ get subtotalCents(): number {
173
+ return this.lines.reduce((n, l) => n + l.priceCents * l.quantity, 0);
174
+ }
175
+
176
+ snapshot(): CartSnapshot<TItem> {
177
+ return {
178
+ id: this.cartId,
179
+ namespace: this.namespace,
180
+ items: this.lines,
181
+ updatedAt: Date.now(),
182
+ };
183
+ }
184
+
185
+ // ---- durable sync ------------------------------------------------------
186
+
187
+ /** Seed from the durable store when the local cart is empty (cross-device). */
188
+ async restore(): Promise<void> {
189
+ if (!this.sync || this.lines.length > 0) return;
190
+ try {
191
+ const remote = await this.sync.pull(this.cartId, this.namespace);
192
+ if (remote && remote.length && this.lines.length === 0) {
193
+ this.lines = remote;
194
+ this.persistLocal();
195
+ this.emit();
196
+ }
197
+ } catch {
198
+ // Offline / store down → keep the local cart. Non-fatal by design.
199
+ }
200
+ }
201
+
202
+ private schedulePush(): void {
203
+ if (!this.sync) return;
204
+ if (this.flushTimer) clearTimeout(this.flushTimer);
205
+ this.flushTimer = setTimeout(() => {
206
+ this.flushTimer = null;
207
+ void this.push();
208
+ }, this.debounceMs);
209
+ }
210
+
211
+ private async push(): Promise<void> {
212
+ if (!this.sync) return;
213
+ try {
214
+ await this.sync.push(this.snapshot());
215
+ } catch {
216
+ // Durable mirror lagged; local is source of truth, next change retries.
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Force the durable mirror current NOW. Call before checkout so the order
222
+ * reads a definitely-current cart; also runs automatically on tab-hide.
223
+ */
224
+ async flush(): Promise<void> {
225
+ if (this.flushTimer) {
226
+ clearTimeout(this.flushTimer);
227
+ this.flushTimer = null;
228
+ }
229
+ await this.push();
230
+ }
231
+
232
+ /** Detach lifecycle listeners (call when tearing the engine down). */
233
+ dispose(): void {
234
+ if (this.hideHandler && typeof document !== "undefined") {
235
+ document.removeEventListener("visibilitychange", this.hideHandler);
236
+ window.removeEventListener("pagehide", this.hideHandler);
237
+ }
238
+ if (this.flushTimer) clearTimeout(this.flushTimer);
239
+ this.listeners.clear();
240
+ }
241
+ }
242
+
243
+ /**
244
+ * Create a configured cart engine. The only required option is `namespace`;
245
+ * pass a `sync` adapter to enable the durable mirror.
246
+ *
247
+ * ```ts
248
+ * const cart = createCart({
249
+ * namespace: "plane-things",
250
+ * storage: localStorage,
251
+ * sync: httpSync({ endpoint: "/api/cart" }),
252
+ * debounceMs: 400,
253
+ * });
254
+ * ```
255
+ */
256
+ export function createCart<TItem extends CartItem = CartItem>(
257
+ opts: CreateCartOptions<TItem>,
258
+ ): CartEngine<TItem> {
259
+ return new CartEngine<TItem>(opts);
260
+ }
@@ -0,0 +1,64 @@
1
+ // HTTP sync adapter — the default durable write path. The browser can't touch a
2
+ // server store directly, so it speaks to an app route (which owns the binding:
3
+ // D1, turso, KV, whatever). Keeps the engine platform-agnostic — swap the
4
+ // endpoint's implementation without touching the lib.
5
+ //
6
+ // Wire contract (the app implements both at `endpoint`):
7
+ // PUT {endpoint} body: CartSnapshot -> 2xx (upsert)
8
+ // GET {endpoint}?id={id}&namespace={ns} -> { items: CartItem[] } | 404
9
+
10
+ import type { CartItem, CartSnapshot, CartSyncAdapter } from "./types.js";
11
+
12
+ export interface HttpSyncOptions {
13
+ /** Route that owns the durable store. Default `/api/cart`. */
14
+ endpoint?: string;
15
+ /** Static extra headers merged into every request. */
16
+ headers?: Record<string, string>;
17
+ /**
18
+ * Optional bearer token, attached ONLY when it resolves to a non-empty value
19
+ * — so the cart is guest-first (no token → anonymous request the route accepts)
20
+ * and authed when a user is signed in (token → route can tag the owner). Can be
21
+ * sync or async (e.g. read a session). Never required.
22
+ */
23
+ getToken?: () => string | null | undefined | Promise<string | null | undefined>;
24
+ /** Injectable fetch (default global). */
25
+ fetch?: typeof fetch;
26
+ }
27
+
28
+ export function httpSync<TItem extends CartItem = CartItem>(
29
+ opts: HttpSyncOptions = {},
30
+ ): CartSyncAdapter<TItem> {
31
+ const endpoint = opts.endpoint ?? "/api/cart";
32
+ const doFetch = opts.fetch ?? globalThis.fetch.bind(globalThis);
33
+ const base = { "content-type": "application/json", ...opts.headers };
34
+
35
+ // Resolve per-request so a token that appears mid-session (user logs in) is
36
+ // picked up without recreating the adapter. Guest path: returns `base` as-is.
37
+ async function headers(): Promise<Record<string, string>> {
38
+ const token = opts.getToken ? await opts.getToken() : null;
39
+ return token ? { ...base, authorization: `Bearer ${token}` } : base;
40
+ }
41
+
42
+ return {
43
+ async push(snapshot: CartSnapshot<TItem>): Promise<void> {
44
+ // `keepalive` lets the tab-hide flush finish after the page starts
45
+ // unloading — the browser equivalent of sendBeacon for a PUT with a body.
46
+ const res = await doFetch(endpoint, {
47
+ method: "PUT",
48
+ headers: await headers(),
49
+ body: JSON.stringify(snapshot),
50
+ keepalive: true,
51
+ });
52
+ if (!res.ok) throw new Error(`cart push ${res.status}`);
53
+ },
54
+
55
+ async pull(id: string, namespace: string): Promise<TItem[] | null> {
56
+ const url = `${endpoint}?id=${encodeURIComponent(id)}&namespace=${encodeURIComponent(namespace)}`;
57
+ const res = await doFetch(url, { headers: await headers() });
58
+ if (res.status === 404) return null;
59
+ if (!res.ok) throw new Error(`cart pull ${res.status}`);
60
+ const body = (await res.json()) as { items?: TItem[] };
61
+ return body.items ?? null;
62
+ },
63
+ };
64
+ }
package/src/index.ts ADDED
@@ -0,0 +1,13 @@
1
+ // @ikeboy003/cart — headless cart primitive.
2
+ // Core (framework-agnostic) + the default HTTP sync adapter. The React binding
3
+ // is a separate entry (`@ikeboy003/cart/react`) so server / non-React code can
4
+ // use the engine without React.
5
+
6
+ export { CartEngine, createCart } from "./engine.js";
7
+ export { httpSync, type HttpSyncOptions } from "./http-sync.js";
8
+ export type {
9
+ CartItem,
10
+ CartSnapshot,
11
+ CartSyncAdapter,
12
+ CreateCartOptions,
13
+ } from "./types.js";
package/src/react.tsx ADDED
@@ -0,0 +1,72 @@
1
+ // Optional React binding — `CartProvider` + `useCart`. Headless: it exposes cart
2
+ // state and actions, no markup. You design the drawer, the add button, the
3
+ // checkout page. Imported separately (`@ikeboy003/cart/react`) so non-React /
4
+ // server code can use the engine without pulling React in.
5
+
6
+ "use client";
7
+
8
+ import {
9
+ createContext,
10
+ useContext,
11
+ useEffect,
12
+ useMemo,
13
+ useSyncExternalStore,
14
+ type ReactNode,
15
+ } from "react";
16
+ import { CartEngine } from "./engine.js";
17
+ import type { CartItem, CreateCartOptions } from "./types.js";
18
+
19
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
20
+ const EngineContext = createContext<CartEngine<any> | null>(null);
21
+
22
+ export interface CartProviderProps<TItem extends CartItem = CartItem>
23
+ extends CreateCartOptions<TItem> {
24
+ children: ReactNode;
25
+ }
26
+
27
+ export function CartProvider<TItem extends CartItem = CartItem>({
28
+ children,
29
+ ...opts
30
+ }: CartProviderProps<TItem>) {
31
+ // One engine per mount, keyed by namespace (effectively stable).
32
+ const engine = useMemo(
33
+ () => new CartEngine<TItem>(opts),
34
+ // eslint-disable-next-line react-hooks/exhaustive-deps
35
+ [opts.namespace],
36
+ );
37
+
38
+ useEffect(() => {
39
+ void engine.restore();
40
+ return () => engine.dispose();
41
+ }, [engine]);
42
+
43
+ return <EngineContext.Provider value={engine}>{children}</EngineContext.Provider>;
44
+ }
45
+
46
+ export function useCart<TItem extends CartItem = CartItem>() {
47
+ const engine = useContext(EngineContext) as CartEngine<TItem> | null;
48
+ if (!engine) throw new Error("useCart must be used inside <CartProvider>");
49
+
50
+ const items = useSyncExternalStore(
51
+ engine.subscribe,
52
+ engine.getSnapshot,
53
+ engine.getSnapshot, // server snapshot: stable empty array
54
+ );
55
+
56
+ const count = items.reduce((n, l) => n + l.quantity, 0);
57
+ const subtotalCents = items.reduce((n, l) => n + l.priceCents * l.quantity, 0);
58
+
59
+ return {
60
+ cartId: engine.id,
61
+ items,
62
+ count,
63
+ /** Subtotal in cents. */
64
+ subtotalCents,
65
+ add: (item: TItem) => engine.add(item),
66
+ remove: (id: string) => engine.remove(id),
67
+ updateQuantity: (id: string, quantity: number) => engine.updateQuantity(id, quantity),
68
+ clear: () => engine.clear(),
69
+ /** Force the durable mirror current — call before navigating to checkout. */
70
+ flush: () => engine.flush(),
71
+ };
72
+ }
package/src/types.ts ADDED
@@ -0,0 +1,70 @@
1
+ // @ikeboy003/cart — headless cart primitive. "postgrest-rs for carts": import
2
+ // the engine, inject config (namespace, storage, sync), design your own UI.
3
+ //
4
+ // The library knows NOTHING about Square / Toast / Stripe / D1 / Cloudflare /
5
+ // auth / fulfillment / item kind. Those are the app's concern. The proven flow
6
+ // the primitive slots into:
7
+ //
8
+ // frontend cart (this lib)
9
+ // -> durable cart row (app's sync adapter; usually D1)
10
+ // -> checkout / payment
11
+ // -> payment webhook
12
+ // -> graft pipeline looks up the durable cart
13
+ // -> creates merchant/POS records (Square, Toast, ... — swappable sink)
14
+ //
15
+ // The lib owns only the left edge: local cart state + a configurable durable
16
+ // write path. Everything right of the durable row is runtime/config.
17
+
18
+ /**
19
+ * The minimum a line needs for cart mechanics: a stable `id`, a `quantity`, and
20
+ * a `priceCents` (so count/subtotal work). Everything else is optional or rides
21
+ * in `metadata` untouched — the app maps its product shape onto this. The engine
22
+ * is generic over the line type, so a site can extend this with typed fields and
23
+ * keep full type-safety end to end.
24
+ */
25
+ export interface CartItem {
26
+ id: string;
27
+ quantity: number;
28
+ priceCents: number;
29
+ title?: string;
30
+ sku?: string;
31
+ /** Opaque passthrough — variationId, options, anything the POS pipeline reads. */
32
+ metadata?: Record<string, unknown>;
33
+ }
34
+
35
+ /** The whole cart at a moment — the unit a sync adapter pushes/pulls. */
36
+ export interface CartSnapshot<TItem extends CartItem = CartItem> {
37
+ /** Anonymous cart id (uuid), minted client-side, persisted in storage. */
38
+ id: string;
39
+ /** App-chosen scope (e.g. a site/store name). Opaque to the lib. */
40
+ namespace: string;
41
+ items: TItem[];
42
+ /** Epoch ms of the last local mutation — lets a durable store keep newest-wins. */
43
+ updatedAt: number;
44
+ }
45
+
46
+ /**
47
+ * Configurable durable write path — the equivalent of postgrest-rs's connection
48
+ * string. The engine calls `push` (debounced + on flush/tab-hide) and `pull`
49
+ * (on `restore()` when local is empty). The lib ships an HTTP adapter; an app
50
+ * can pass any implementation. Throwing is non-fatal: the local cart is the
51
+ * source of truth and the next change retries.
52
+ */
53
+ export interface CartSyncAdapter<TItem extends CartItem = CartItem> {
54
+ push(snapshot: CartSnapshot<TItem>): Promise<void>;
55
+ pull(id: string, namespace: string): Promise<TItem[] | null>;
56
+ }
57
+
58
+ /** Config passed at import — nothing brand-specific is baked in. */
59
+ export interface CreateCartOptions<TItem extends CartItem = CartItem> {
60
+ /** Scopes the storage keys + cart id, and is carried in the snapshot. */
61
+ namespace: string;
62
+ /** Persistence. Defaults to localStorage; pass `null` for in-memory only. */
63
+ storage?: Storage | null;
64
+ /** Durable mirror. Omit for local-only. */
65
+ sync?: CartSyncAdapter<TItem>;
66
+ /** Coalesce window for durable pushes (ms). Default 400. */
67
+ debounceMs?: number;
68
+ /** Id minter. Default `crypto.randomUUID`. */
69
+ newId?: () => string;
70
+ }