@managesales/storefront 1.0.2 → 1.2.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/CHANGELOG.md +61 -0
- package/README.md +124 -13
- package/dist/index.cjs +199 -6
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +178 -6
- package/dist/index.d.ts +178 -6
- package/dist/index.js +199 -6
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,67 @@
|
|
|
3
3
|
All notable changes to `@managesales/storefront`. This project adheres to
|
|
4
4
|
[Semantic Versioning](https://semver.org).
|
|
5
5
|
|
|
6
|
+
## 1.2.0 — 2026-07-21
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
- **`discounts` namespace** — `client.discounts.validate(code, { subtotal | items, customerId? })`
|
|
10
|
+
previews a discount code against a cart *before* creating a checkout session. Returns
|
|
11
|
+
`{ valid: true, discountId, code, label, amount, freeShipping }` or
|
|
12
|
+
`{ valid: false, reason }` with a customer-safe explanation — no try/catch needed for
|
|
13
|
+
"invalid code" UI states. Validation never consumes coupon usage.
|
|
14
|
+
- **`client.discounts.automatic()`** — lists the store's active automatic promotions
|
|
15
|
+
(`AutomaticPromotion[]`: title, type, value, minimums, end date) for display, e.g. promo
|
|
16
|
+
banners. Coupon codes are never exposed by this endpoint.
|
|
17
|
+
- **`checkout.applyGiftCard(checkoutId, code)`** — applies a gift card to an open checkout
|
|
18
|
+
session (the backend endpoint existed; the SDK now exposes it).
|
|
19
|
+
- **`couponCode` option at checkout creation** — `checkout.create(items, { couponCode })` and
|
|
20
|
+
`cart.checkout({ couponCode })` apply the code server-side in the same round-trip as session
|
|
21
|
+
creation.
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
- **`checkout.applyCoupon` sent the wrong field name and always failed.** The request body was
|
|
25
|
+
`{ couponCode }`, but the API accepts `{ code }` and rejects unknown fields — every
|
|
26
|
+
`applyCoupon` call returned a 400. It now sends `{ code }`.
|
|
27
|
+
- **`CheckoutSession` type now matches the actual API response.** The session's real fields are
|
|
28
|
+
`lines`, `discountAmount`, `discountCode`, `discountLabel`, `taxAmount`, `shippingAmount`,
|
|
29
|
+
`giftCardAmount`, `availableShippingMethods`, etc. The previously-declared `items`, `tax`,
|
|
30
|
+
`discount`, and `couponCode` members were never populated at runtime; they remain as optional
|
|
31
|
+
`@deprecated` aliases so existing code still compiles. `shipping` was previously typed as a
|
|
32
|
+
number but is actually the shipping *address object* on the wire — it is now typed correctly
|
|
33
|
+
(`CheckoutShippingAddress`).
|
|
34
|
+
|
|
35
|
+
## 1.1.0 — 2026-07-11
|
|
36
|
+
|
|
37
|
+
### Added
|
|
38
|
+
- **Stateful cart** — `client.cart.create()` / `client.cart.current()` return a `StorefrontCart` with
|
|
39
|
+
`addLineItem` (auto-enriches price/name from `products.get`, merges duplicate product+variant
|
|
40
|
+
lines), `updateLineItem`, `removeLineItem`, `clear`, and `checkout()` to materialize the cart
|
|
41
|
+
straight into a checkout session. Persisted to `localStorage` by default (`persistCart: false` to
|
|
42
|
+
opt out); safe to import under SSR/Node with no DOM. `client.cart.recover(token)` is unchanged.
|
|
43
|
+
- **`orders` and `customers` alias namespaces** — `client.orders.list()` and
|
|
44
|
+
`client.customers.{login,register,loginWithGoogle,logout,isAuthenticated,setTokens,me,orders,addresses}`
|
|
45
|
+
are convenience references onto the existing `auth`/`customer` methods (no duplicated logic, no new
|
|
46
|
+
backend calls).
|
|
47
|
+
- **Configurable request retries** — network errors and 5xx/408/425/429 responses on idempotent
|
|
48
|
+
(`GET`/`HEAD`) requests are now retried automatically with exponential backoff, honoring a
|
|
49
|
+
`Retry-After` response header as a delay floor. Configure via
|
|
50
|
+
`createStorefrontClient({ retry: { retries, baseDelayMs, maxDelayMs, retryOnStatus } })`, or pass
|
|
51
|
+
`retry: false` to disable. Non-idempotent requests (`POST`/`PUT`/`PATCH`/`DELETE`) are never
|
|
52
|
+
retried once a response is received from the server; a network failure that never reached the
|
|
53
|
+
server (e.g. `fetch` throwing) is retried regardless of method, since no side effect could have
|
|
54
|
+
occurred yet.
|
|
55
|
+
|
|
56
|
+
### Fixed
|
|
57
|
+
- `checkout.create` now sends line items under the `lines` key (matching the `/checkout` endpoint's
|
|
58
|
+
actual request shape) instead of `items`. Note this only corrects the request envelope — direct
|
|
59
|
+
callers must still supply every field the backend requires (see next item).
|
|
60
|
+
- `CheckoutLineItem` now includes the backend-required `productName` (string) and `unitPrice`
|
|
61
|
+
(number) fields, plus optional `imageUrl`. The previous `{ productId, variantId?, quantity }`
|
|
62
|
+
shape type-checked but was always rejected by the real `/checkout` endpoint's validation for
|
|
63
|
+
direct `client.checkout.create()` callers; `cart.checkout()` was unaffected since `CartLine`
|
|
64
|
+
already carried both fields. Callers who don't want to supply `productName`/`unitPrice` by hand
|
|
65
|
+
should use the stateful cart (`cart.addLineItem`), which auto-enriches them from `products.get`.
|
|
66
|
+
|
|
6
67
|
## 1.0.2 — 2026-07-11
|
|
7
68
|
|
|
8
69
|
### Docs
|
package/README.md
CHANGED
|
@@ -30,8 +30,10 @@ like Shopify's Storefront client, for ManageSales. Everything you need to ship a
|
|
|
30
30
|
- [Products & search](#products--search)
|
|
31
31
|
- [Collections](#collections)
|
|
32
32
|
- [Cart & checkout](#cart--checkout)
|
|
33
|
+
- [Stateful cart](#stateful-cart)
|
|
33
34
|
- [Authentication](#authentication)
|
|
34
35
|
- [Customer account](#customer-account)
|
|
36
|
+
- [Retries](#retries)
|
|
35
37
|
- [Error handling](#error-handling)
|
|
36
38
|
- [TypeScript support](#typescript-support)
|
|
37
39
|
- [Framework examples](#framework-examples)
|
|
@@ -78,12 +80,19 @@ client.products.search(query, params?) // full-text search
|
|
|
78
80
|
client.collections.list(params?)
|
|
79
81
|
client.collections.get(idOrSlug) // collection + its products
|
|
80
82
|
|
|
83
|
+
// Discounts (public)
|
|
84
|
+
client.discounts.validate(code, { subtotal | items }) // preview a code before checkout
|
|
85
|
+
client.discounts.automatic() // active automatic promotions (for banners)
|
|
86
|
+
|
|
81
87
|
// Cart & checkout (public)
|
|
82
|
-
client.checkout.create(lineItems)
|
|
88
|
+
client.checkout.create(lineItems, { couponCode? }) // start a checkout from line items
|
|
83
89
|
client.checkout.get(checkoutId)
|
|
84
90
|
client.checkout.applyCoupon(checkoutId, code)
|
|
91
|
+
client.checkout.applyGiftCard(checkoutId, code)
|
|
85
92
|
client.checkout.complete(checkoutId, payment?)
|
|
93
|
+
client.cart.create() / client.cart.current() // stateful cart — see Stateful cart
|
|
86
94
|
client.cart.recover(token) // rebuild a checkout from a recovery link
|
|
95
|
+
client.orders.list(params?) // alias for client.customer.orders(params?)
|
|
87
96
|
|
|
88
97
|
// Auth & customer (customer session)
|
|
89
98
|
client.auth.register({ email, password, name })
|
|
@@ -95,6 +104,8 @@ client.auth.setTokens(access, refresh) // restore a session (e.g. from cookies)
|
|
|
95
104
|
client.customer.me()
|
|
96
105
|
client.customer.orders(params?) // order history
|
|
97
106
|
client.customer.addresses()
|
|
107
|
+
// client.customers.* — alias namespace bundling auth + customer under one name
|
|
108
|
+
// (client.customers.login === client.auth.login, client.customers.me === client.customer.me, ...)
|
|
98
109
|
|
|
99
110
|
// Content (public)
|
|
100
111
|
client.blog.list(params?) / client.blog.get(slug)
|
|
@@ -155,26 +166,56 @@ const summer = await client.collections.get("summer-sale");
|
|
|
155
166
|
console.log(summer.name, summer.products.length); // collection + its products
|
|
156
167
|
```
|
|
157
168
|
|
|
169
|
+
## Discounts
|
|
170
|
+
|
|
171
|
+
Preview a discount code against the customer's cart **before** creating a checkout session — ideal
|
|
172
|
+
for a "promo code" field with instant feedback. Validation never consumes the coupon's usage:
|
|
173
|
+
|
|
174
|
+
```ts
|
|
175
|
+
const check = await client.discounts.validate("SAVE10", { subtotal: 68 });
|
|
176
|
+
// or derive the subtotal from line items:
|
|
177
|
+
// await client.discounts.validate("SAVE10", { items: [{ quantity: 2, unitPrice: 24.99 }] });
|
|
178
|
+
|
|
179
|
+
if (check.valid) {
|
|
180
|
+
console.log(`${check.label} — saves ${check.amount}`); // "Summer Sale (SAVE10) — saves 6.80"
|
|
181
|
+
} else {
|
|
182
|
+
console.log(check.reason); // customer-safe, e.g. "Minimum order amount of 50 required"
|
|
183
|
+
}
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Show the store's running automatic promotions (no code needed to redeem — great for banners):
|
|
187
|
+
|
|
188
|
+
```ts
|
|
189
|
+
const promos = await client.discounts.automatic();
|
|
190
|
+
// [{ id, title: "Free shipping over $100", type: "free_shipping", value: 0, minOrderAmount: 100, ... }]
|
|
191
|
+
```
|
|
192
|
+
|
|
158
193
|
## Cart & checkout
|
|
159
194
|
|
|
160
|
-
Assemble line items in your UI, create a checkout session, optionally apply a coupon
|
|
161
|
-
it:
|
|
195
|
+
Assemble line items in your UI, create a checkout session, optionally apply a coupon or gift card,
|
|
196
|
+
then complete it:
|
|
162
197
|
|
|
163
198
|
```ts
|
|
164
|
-
// 1. Create a checkout from line items
|
|
165
|
-
const session = await client.checkout.create(
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
199
|
+
// 1. Create a checkout from line items — optionally applying a coupon in the same call
|
|
200
|
+
const session = await client.checkout.create(
|
|
201
|
+
[
|
|
202
|
+
{ productId: "prod_123", variantId: "var_456", productName: "Blue T-Shirt", quantity: 2, unitPrice: 24.99 },
|
|
203
|
+
{ productId: "prod_789", productName: "Canvas Tote", quantity: 1, unitPrice: 18 },
|
|
204
|
+
],
|
|
205
|
+
{ couponCode: "SAVE10" },
|
|
206
|
+
);
|
|
169
207
|
|
|
170
208
|
// 2. Re-fetch a session (e.g. after a reload)
|
|
171
209
|
const current = await client.checkout.get(session.id);
|
|
172
210
|
|
|
173
|
-
// 3. Apply a coupon
|
|
211
|
+
// 3. Apply (or change) a coupon on an open session
|
|
174
212
|
const discounted = await client.checkout.applyCoupon(session.id, "SAVE10");
|
|
175
|
-
console.log(discounted.
|
|
213
|
+
console.log(discounted.discountAmount, discounted.total);
|
|
214
|
+
|
|
215
|
+
// 4. Apply a gift card
|
|
216
|
+
const withGiftCard = await client.checkout.applyGiftCard(session.id, "GC-XXXX-XXXX");
|
|
176
217
|
|
|
177
|
-
//
|
|
218
|
+
// 5. Complete the purchase
|
|
178
219
|
const order = await client.checkout.complete(session.id, {
|
|
179
220
|
paymentMethod: "card",
|
|
180
221
|
token: "tok_visa_xxx",
|
|
@@ -182,6 +223,12 @@ const order = await client.checkout.complete(session.id, {
|
|
|
182
223
|
console.log("Order placed:", order.orderNumber);
|
|
183
224
|
```
|
|
184
225
|
|
|
226
|
+
> `productName` and `unitPrice` are required on every line item passed directly to
|
|
227
|
+
> `client.checkout.create()` — the backend validates them. If you'd rather not look these up
|
|
228
|
+
> yourself, use the [stateful cart](#stateful-cart) instead: `cart.addLineItem` auto-enriches
|
|
229
|
+
> `productName`/`unitPrice` (and `imageUrl`) via `products.get`, so cart callers only ever supply
|
|
230
|
+
> `productId`/`variantId`/`quantity`.
|
|
231
|
+
|
|
185
232
|
**Abandoned-cart recovery** — turn a recovery link back into a checkout session:
|
|
186
233
|
|
|
187
234
|
```ts
|
|
@@ -189,6 +236,39 @@ const token = new URLSearchParams(location.search).get("token")!;
|
|
|
189
236
|
const recovered = await client.cart.recover(token);
|
|
190
237
|
```
|
|
191
238
|
|
|
239
|
+
## Stateful cart
|
|
240
|
+
|
|
241
|
+
For a persistent, imperative cart — add/update/remove lines, then check out in one call — use
|
|
242
|
+
`client.cart` instead of assembling line items by hand:
|
|
243
|
+
|
|
244
|
+
```ts
|
|
245
|
+
// Create a fresh cart, or resume one already persisted for this workspace
|
|
246
|
+
const cart = client.cart.create(); // client.cart.current() resumes a persisted cart instead
|
|
247
|
+
|
|
248
|
+
// Add items — price, name, and image are looked up automatically via products.get()
|
|
249
|
+
await cart.addLineItem({ productId: "prod_123", variantId: "var_456", quantity: 2 });
|
|
250
|
+
await cart.addLineItem({ productId: "prod_123", variantId: "var_456", quantity: 1 }); // merges into the existing line
|
|
251
|
+
|
|
252
|
+
console.log(cart.itemCount); // 3
|
|
253
|
+
console.log(cart.subtotal); // sum of unitPrice * quantity across all lines
|
|
254
|
+
|
|
255
|
+
cart.updateLineItem(cart.lines[0].id, 5); // set a line's quantity (quantity <= 0 removes it)
|
|
256
|
+
cart.removeLineItem(cart.lines[0].id);
|
|
257
|
+
cart.clear();
|
|
258
|
+
|
|
259
|
+
// Turn the cart directly into a checkout session — optionally with a coupon
|
|
260
|
+
const session = await cart.checkout({ clearOnSuccess: true, couponCode: "SAVE10" });
|
|
261
|
+
console.log(session.id, session.discountAmount, session.total);
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
By default the cart is persisted to `localStorage` under a per-workspace key, so `client.cart.current()`
|
|
265
|
+
resumes it after a page reload. Pass `persistCart: false` to `createStorefrontClient` to keep it
|
|
266
|
+
in-memory only (tests, SSR, multi-cart UIs). All storage access is guarded, so the SDK still imports
|
|
267
|
+
and runs fine under Node/SSR where `localStorage` doesn't exist — the cart simply won't persist.
|
|
268
|
+
|
|
269
|
+
> `cart.checkout()` forwards the cart's line items to `client.checkout.create()` under the hood, so
|
|
270
|
+
> coupons/payment on the returned session work exactly as in [Cart & checkout](#cart--checkout).
|
|
271
|
+
|
|
192
272
|
## Authentication
|
|
193
273
|
|
|
194
274
|
Most pages need **no auth**. When a customer logs in, the SDK stores the access + refresh tokens and
|
|
@@ -235,6 +315,37 @@ const { data: orders } = await client.customer.orders({ page: 1 }); // order his
|
|
|
235
315
|
const addresses = await client.customer.addresses(); // saved addresses
|
|
236
316
|
```
|
|
237
317
|
|
|
318
|
+
`client.customers` mirrors `auth` + `customer` under one namespace, and `client.orders.list()` is a
|
|
319
|
+
shorthand for `client.customer.orders()` — same functions, same requests, just organized differently:
|
|
320
|
+
|
|
321
|
+
```ts
|
|
322
|
+
await client.customers.login("jane@example.com", "SecurePass123!"); // === client.auth.login
|
|
323
|
+
const { data: orders } = await client.orders.list({ page: 1 }); // === client.customer.orders({ page: 1 })
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
## Retries
|
|
327
|
+
|
|
328
|
+
Network failures and `408` / `425` / `429` / `5xx` responses to idempotent (`GET`/`HEAD`) requests are
|
|
329
|
+
retried automatically with exponential backoff, honoring a `Retry-After` response header as a delay
|
|
330
|
+
floor when the server sends one. Non-idempotent requests (`POST`/`PUT`/`PATCH`/`DELETE`) are retried
|
|
331
|
+
**only** when the request never reached the server (e.g. `fetch` itself throws) — never after a server
|
|
332
|
+
response, since the side effect may already have happened.
|
|
333
|
+
|
|
334
|
+
```ts
|
|
335
|
+
const client = createStorefrontClient({
|
|
336
|
+
workspaceId: "ws_123",
|
|
337
|
+
retry: {
|
|
338
|
+
retries: 2, // default: 2 additional attempts after the first
|
|
339
|
+
baseDelayMs: 300, // default: 300 — base for exponential backoff
|
|
340
|
+
maxDelayMs: 4000, // default: 4000 — cap on any single backoff delay
|
|
341
|
+
retryOnStatus: [408, 425, 429, 500, 502, 503, 504], // default shown
|
|
342
|
+
},
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
// Disable retries entirely
|
|
346
|
+
const noRetryClient = createStorefrontClient({ workspaceId: "ws_123", retry: false });
|
|
347
|
+
```
|
|
348
|
+
|
|
238
349
|
## Error handling
|
|
239
350
|
|
|
240
351
|
Every non-2xx response throws a `StorefrontError` with structured fields:
|
|
@@ -257,8 +368,8 @@ try {
|
|
|
257
368
|
```
|
|
258
369
|
|
|
259
370
|
> **Resilience:** the client automatically retries a request **once** with a refreshed token when a
|
|
260
|
-
> `401` indicates an expired access token
|
|
261
|
-
>
|
|
371
|
+
> `401` indicates an expired access token, and separately retries transient network/5xx failures per
|
|
372
|
+
> the [Retries](#retries) config.
|
|
262
373
|
|
|
263
374
|
## TypeScript support
|
|
264
375
|
|
package/dist/index.cjs
CHANGED
|
@@ -15,6 +15,18 @@ function createStorefrontClient(config) {
|
|
|
15
15
|
const fetchFn = config.customFetch ?? globalThis.fetch.bind(globalThis);
|
|
16
16
|
let accessToken = null;
|
|
17
17
|
let refreshToken = null;
|
|
18
|
+
const RETRY_DEFAULTS = {
|
|
19
|
+
retries: 2,
|
|
20
|
+
baseDelayMs: 300,
|
|
21
|
+
maxDelayMs: 4e3,
|
|
22
|
+
retryOnStatus: [408, 425, 429, 500, 502, 503, 504]
|
|
23
|
+
};
|
|
24
|
+
const rc = config.retry === false ? { ...RETRY_DEFAULTS, retries: 0 } : { ...RETRY_DEFAULTS, ...config.retry || {} };
|
|
25
|
+
const IDEMPOTENT = /* @__PURE__ */ new Set(["GET", "HEAD"]);
|
|
26
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
27
|
+
function backoff(attempt) {
|
|
28
|
+
return Math.min(rc.maxDelayMs, Math.round(Math.random() * rc.baseDelayMs * Math.pow(2, attempt)));
|
|
29
|
+
}
|
|
18
30
|
async function request(method, path, options) {
|
|
19
31
|
const url = new URL(`${apiUrl}${path}`);
|
|
20
32
|
if (options?.params) {
|
|
@@ -31,11 +43,32 @@ function createStorefrontClient(config) {
|
|
|
31
43
|
if (options?.body) {
|
|
32
44
|
headers["Content-Type"] = "application/json";
|
|
33
45
|
}
|
|
34
|
-
const
|
|
46
|
+
const fetchOptions = {
|
|
35
47
|
method,
|
|
36
48
|
headers,
|
|
37
49
|
body: options?.body ? JSON.stringify(options.body) : void 0
|
|
38
|
-
}
|
|
50
|
+
};
|
|
51
|
+
let attempt = 0;
|
|
52
|
+
let res;
|
|
53
|
+
while (true) {
|
|
54
|
+
try {
|
|
55
|
+
res = await fetchFn(url.toString(), fetchOptions);
|
|
56
|
+
} catch (err) {
|
|
57
|
+
if (attempt < rc.retries) {
|
|
58
|
+
await sleep(backoff(attempt));
|
|
59
|
+
attempt++;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
throw err;
|
|
63
|
+
}
|
|
64
|
+
if (attempt < rc.retries && rc.retryOnStatus.includes(res.status) && IDEMPOTENT.has(method)) {
|
|
65
|
+
const ra = Number(res.headers.get("retry-after"));
|
|
66
|
+
await sleep(Number.isFinite(ra) && ra > 0 ? ra * 1e3 : backoff(attempt));
|
|
67
|
+
attempt++;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
39
72
|
if (res.status === 401 && options?.auth && refreshToken) {
|
|
40
73
|
const refreshed = await refreshSession();
|
|
41
74
|
if (refreshed) {
|
|
@@ -119,10 +152,22 @@ function createStorefrontClient(config) {
|
|
|
119
152
|
return request("GET", `/storefront-api/collections/${idOrSlug}`);
|
|
120
153
|
}
|
|
121
154
|
};
|
|
155
|
+
const discounts = {
|
|
156
|
+
/** Preview a discount code against a cart before checkout. Never consumes usage. */
|
|
157
|
+
async validate(code, input) {
|
|
158
|
+
return request("POST", "/storefront-api/discounts/validate", { body: { code, ...input || {} } });
|
|
159
|
+
},
|
|
160
|
+
/** Active automatic promotions for display (codes are never exposed) */
|
|
161
|
+
async automatic() {
|
|
162
|
+
return request("GET", "/storefront-api/discounts/automatic");
|
|
163
|
+
}
|
|
164
|
+
};
|
|
122
165
|
const checkout = {
|
|
123
166
|
/** Create a checkout session from cart items */
|
|
124
|
-
async create(items) {
|
|
125
|
-
|
|
167
|
+
async create(items, opts) {
|
|
168
|
+
const body = { lines: items };
|
|
169
|
+
if (opts && opts.couponCode) body.couponCode = opts.couponCode;
|
|
170
|
+
return request("POST", "/checkout", { body });
|
|
126
171
|
},
|
|
127
172
|
/** Retrieve an existing checkout session */
|
|
128
173
|
async get(checkoutId) {
|
|
@@ -130,7 +175,11 @@ function createStorefrontClient(config) {
|
|
|
130
175
|
},
|
|
131
176
|
/** Apply a coupon or discount code */
|
|
132
177
|
async applyCoupon(checkoutId, couponCode) {
|
|
133
|
-
return request("POST", `/checkout/${checkoutId}/coupon`, { body: { couponCode } });
|
|
178
|
+
return request("POST", `/checkout/${checkoutId}/coupon`, { body: { code: couponCode } });
|
|
179
|
+
},
|
|
180
|
+
/** Apply a gift card to the checkout */
|
|
181
|
+
async applyGiftCard(checkoutId, code) {
|
|
182
|
+
return request("POST", `/checkout/${checkoutId}/gift-card`, { body: { code } });
|
|
134
183
|
},
|
|
135
184
|
/** Complete checkout — process payment and create order */
|
|
136
185
|
async complete(checkoutId, payment) {
|
|
@@ -254,23 +303,167 @@ function createStorefrontClient(config) {
|
|
|
254
303
|
return res.json();
|
|
255
304
|
}
|
|
256
305
|
};
|
|
306
|
+
const CART_KEY = "managesales_cart_" + config.workspaceId;
|
|
307
|
+
const hasLS = () => {
|
|
308
|
+
try {
|
|
309
|
+
return config.persistCart !== false && typeof localStorage !== "undefined";
|
|
310
|
+
} catch {
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
};
|
|
314
|
+
const loadCart = () => {
|
|
315
|
+
if (!hasLS()) return null;
|
|
316
|
+
try {
|
|
317
|
+
const s = localStorage.getItem(CART_KEY);
|
|
318
|
+
return s ? JSON.parse(s) : null;
|
|
319
|
+
} catch {
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
};
|
|
323
|
+
const saveCart = (st) => {
|
|
324
|
+
if (!hasLS()) return;
|
|
325
|
+
try {
|
|
326
|
+
localStorage.setItem(CART_KEY, JSON.stringify(st));
|
|
327
|
+
} catch {
|
|
328
|
+
}
|
|
329
|
+
};
|
|
330
|
+
let __lineSeq = 0;
|
|
331
|
+
const genId = (p) => typeof crypto !== "undefined" && crypto.randomUUID ? p + crypto.randomUUID() : p + Date.now() + "_" + ++__lineSeq;
|
|
332
|
+
const nowIso = () => (/* @__PURE__ */ new Date()).toISOString();
|
|
333
|
+
const freshCart = () => ({ id: genId("cart_"), lines: [], createdAt: nowIso(), updatedAt: nowIso() });
|
|
334
|
+
function makeCart(state) {
|
|
335
|
+
const persist = () => {
|
|
336
|
+
state.updatedAt = nowIso();
|
|
337
|
+
saveCart(state);
|
|
338
|
+
};
|
|
339
|
+
const cartObj = {
|
|
340
|
+
get id() {
|
|
341
|
+
return state.id;
|
|
342
|
+
},
|
|
343
|
+
get lines() {
|
|
344
|
+
return state.lines;
|
|
345
|
+
},
|
|
346
|
+
get itemCount() {
|
|
347
|
+
return state.lines.reduce((n, l) => n + l.quantity, 0);
|
|
348
|
+
},
|
|
349
|
+
get subtotal() {
|
|
350
|
+
return state.lines.reduce((s, l) => s + l.unitPrice * l.quantity, 0);
|
|
351
|
+
},
|
|
352
|
+
// Supplying unitPrice alone does not skip the lookup — productName must also be
|
|
353
|
+
// provided, otherwise this falls through to the products.get() network lookup below.
|
|
354
|
+
async addLineItem(input) {
|
|
355
|
+
let line;
|
|
356
|
+
if (input.unitPrice != null && input.productName) {
|
|
357
|
+
line = {
|
|
358
|
+
id: genId("line_"),
|
|
359
|
+
productId: input.productId,
|
|
360
|
+
variantId: input.variantId,
|
|
361
|
+
quantity: input.quantity,
|
|
362
|
+
unitPrice: input.unitPrice,
|
|
363
|
+
productName: input.productName,
|
|
364
|
+
imageUrl: input.imageUrl
|
|
365
|
+
};
|
|
366
|
+
} else {
|
|
367
|
+
const p = await products.get(input.productId);
|
|
368
|
+
const v = input.variantId && Array.isArray(p.variants) ? p.variants.find((x) => x.id === input.variantId) : void 0;
|
|
369
|
+
line = {
|
|
370
|
+
id: genId("line_"),
|
|
371
|
+
productId: input.productId,
|
|
372
|
+
variantId: input.variantId,
|
|
373
|
+
quantity: input.quantity,
|
|
374
|
+
unitPrice: v && v.price != null ? v.price : p.price,
|
|
375
|
+
productName: p.name,
|
|
376
|
+
imageUrl: Array.isArray(p.images) && p.images.length > 0 ? p.images[0].url : void 0
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
const existing = state.lines.find((l) => l.productId === line.productId && l.variantId === line.variantId);
|
|
380
|
+
if (existing) existing.quantity += line.quantity;
|
|
381
|
+
else state.lines.push(line);
|
|
382
|
+
persist();
|
|
383
|
+
return cartObj;
|
|
384
|
+
},
|
|
385
|
+
updateLineItem(lineId, quantity) {
|
|
386
|
+
const l = state.lines.find((x) => x.id === lineId);
|
|
387
|
+
if (l) {
|
|
388
|
+
if (quantity <= 0) state.lines = state.lines.filter((x) => x.id !== lineId);
|
|
389
|
+
else l.quantity = quantity;
|
|
390
|
+
persist();
|
|
391
|
+
}
|
|
392
|
+
return cartObj;
|
|
393
|
+
},
|
|
394
|
+
removeLineItem(lineId) {
|
|
395
|
+
state.lines = state.lines.filter((x) => x.id !== lineId);
|
|
396
|
+
persist();
|
|
397
|
+
return cartObj;
|
|
398
|
+
},
|
|
399
|
+
clear() {
|
|
400
|
+
state.lines = [];
|
|
401
|
+
persist();
|
|
402
|
+
return cartObj;
|
|
403
|
+
},
|
|
404
|
+
async checkout(opts) {
|
|
405
|
+
const session = await checkout.create(
|
|
406
|
+
state.lines.map((l) => ({
|
|
407
|
+
productId: l.productId,
|
|
408
|
+
variantId: l.variantId,
|
|
409
|
+
productName: l.productName,
|
|
410
|
+
quantity: l.quantity,
|
|
411
|
+
unitPrice: l.unitPrice,
|
|
412
|
+
imageUrl: l.imageUrl
|
|
413
|
+
})),
|
|
414
|
+
opts && opts.couponCode ? { couponCode: opts.couponCode } : void 0
|
|
415
|
+
);
|
|
416
|
+
if (opts && opts.clearOnSuccess) cartObj.clear();
|
|
417
|
+
return session;
|
|
418
|
+
}
|
|
419
|
+
};
|
|
420
|
+
return cartObj;
|
|
421
|
+
}
|
|
257
422
|
const cart = {
|
|
423
|
+
/** Create a new, empty cart (replaces any persisted cart) */
|
|
424
|
+
create() {
|
|
425
|
+
const st = freshCart();
|
|
426
|
+
saveCart(st);
|
|
427
|
+
return makeCart(st);
|
|
428
|
+
},
|
|
429
|
+
/** Get the current cart — loaded from storage if persisted, otherwise a fresh one */
|
|
430
|
+
current() {
|
|
431
|
+
return makeCart(loadCart() || freshCart());
|
|
432
|
+
},
|
|
258
433
|
/** Recover an abandoned cart by recovery token */
|
|
259
434
|
async recover(token) {
|
|
260
435
|
return request("GET", `/abandoned-carts/recover/${token}`);
|
|
261
436
|
}
|
|
262
437
|
};
|
|
438
|
+
const orders = {
|
|
439
|
+
/** Get customer's order history (delegates to `customer.orders`) */
|
|
440
|
+
list: (params) => customer.orders(params)
|
|
441
|
+
};
|
|
442
|
+
const customers = {
|
|
443
|
+
login: auth.login,
|
|
444
|
+
register: auth.register,
|
|
445
|
+
loginWithGoogle: auth.loginWithGoogle,
|
|
446
|
+
logout: auth.logout,
|
|
447
|
+
isAuthenticated: auth.isAuthenticated,
|
|
448
|
+
setTokens: auth.setTokens,
|
|
449
|
+
me: customer.me,
|
|
450
|
+
orders: customer.orders,
|
|
451
|
+
addresses: customer.addresses
|
|
452
|
+
};
|
|
263
453
|
return {
|
|
264
454
|
store,
|
|
265
455
|
products,
|
|
266
456
|
collections,
|
|
457
|
+
discounts,
|
|
267
458
|
checkout,
|
|
268
459
|
auth,
|
|
269
460
|
customer,
|
|
270
461
|
blog,
|
|
271
462
|
bookings,
|
|
272
463
|
forms,
|
|
273
|
-
cart
|
|
464
|
+
cart,
|
|
465
|
+
orders,
|
|
466
|
+
customers
|
|
274
467
|
};
|
|
275
468
|
}
|
|
276
469
|
|