@behio/storefront-sdk 0.3.0 → 0.5.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 CHANGED
@@ -83,7 +83,8 @@ function ProductList() {
83
83
  | `customer` | Profile, addresses, password change |
84
84
  | `wishlist` | Add, remove, check |
85
85
  | `reviews` | Submit, list, vote helpful |
86
- | `addresses` | Google Places autocomplete with debounce hook |
86
+ | `addresses` | Address autocomplete with debounce hook |
87
+ | `shipping` | List shipping methods and fetch live carrier quotes (Zaslat.cz + extensible) |
87
88
  | `returns` | Submit return requests |
88
89
  | `consent` | Cookie consent (GDPR) |
89
90
  | `quotes` | B2B quote requests |
@@ -72,6 +72,40 @@ var BehioNetworkError = class extends Error {
72
72
  this.code = isTimeout ? "TIMEOUT" : "NETWORK_ERROR";
73
73
  }
74
74
  };
75
+ function ok(data) {
76
+ return { data, error: null };
77
+ }
78
+ function err(error) {
79
+ return { data: null, error };
80
+ }
81
+ function toSdkError(err2) {
82
+ if (err2 instanceof BehioApiError) {
83
+ return {
84
+ code: err2.code,
85
+ message: err2.message,
86
+ status: err2.status,
87
+ body: err2.body,
88
+ isRetryable: err2.isRetryable,
89
+ cause: err2
90
+ };
91
+ }
92
+ if (err2 instanceof BehioNetworkError) {
93
+ return {
94
+ code: err2.code,
95
+ message: err2.message,
96
+ status: null,
97
+ isRetryable: err2.isRetryable,
98
+ cause: err2
99
+ };
100
+ }
101
+ return {
102
+ code: "UNKNOWN",
103
+ message: err2 instanceof Error ? err2.message : String(err2),
104
+ status: null,
105
+ isRetryable: false,
106
+ cause: err2
107
+ };
108
+ }
75
109
 
76
110
  // src/client.ts
77
111
  var BehioStorefront = class {
@@ -108,6 +142,7 @@ var BehioStorefront = class {
108
142
  this.consent = new ConsentModule(this);
109
143
  this.quotes = new QuotesModule(this);
110
144
  this.addresses = new AddressModule(this);
145
+ this.shipping = new ShippingModule(this);
111
146
  }
112
147
  // --- Public methods ---
113
148
  /** Get basic shop info */
@@ -197,11 +232,17 @@ var BehioStorefront = class {
197
232
  this.isRefreshing = true;
198
233
  this.refreshPromise = (async () => {
199
234
  try {
200
- await this.auth.refresh();
235
+ const res = await this.auth.refresh();
236
+ if (res.error) {
237
+ this.clearTokens();
238
+ this.emit("auth:token-refresh-failed");
239
+ throw new BehioApiError(401, null, "Token refresh failed");
240
+ }
201
241
  this.emit("auth:token-refresh");
202
- } catch {
242
+ } catch (err2) {
203
243
  this.clearTokens();
204
244
  this.emit("auth:token-refresh-failed");
245
+ if (err2 instanceof BehioApiError) throw err2;
205
246
  throw new BehioApiError(401, null, "Token refresh failed");
206
247
  } finally {
207
248
  this.isRefreshing = false;
@@ -210,9 +251,29 @@ var BehioStorefront = class {
210
251
  })();
211
252
  return this.refreshPromise;
212
253
  }
213
- // --- Internal fetch ---
214
- /** @internal */
254
+ // --- Public request wrapper (SdkResult) ---
255
+ /**
256
+ * Every public module method funnels through here. Internally calls
257
+ * `rawRequest` (which throws on failure) and maps thrown errors to
258
+ * `SdkError` so the public surface can return `SdkResult<T>`.
259
+ *
260
+ * @internal — don't call from outside the SDK; use the typed module
261
+ * methods (behio.catalog.*, behio.cart.*, …) instead.
262
+ */
215
263
  async request(method, path, options) {
264
+ try {
265
+ const data = await this.rawRequest(method, path, options);
266
+ return ok(data);
267
+ } catch (err2) {
268
+ return { data: null, error: toSdkError(err2) };
269
+ }
270
+ }
271
+ /**
272
+ * Throws on failure (API error / network / timeout). Kept private so
273
+ * internal auth refresh recursion keeps its existing control flow —
274
+ * public callers must go through `request()` which returns Result.
275
+ */
276
+ async rawRequest(method, path, options) {
216
277
  const params = new URLSearchParams();
217
278
  if (options?.query) {
218
279
  for (const [key, value] of Object.entries(options.query)) {
@@ -275,11 +336,11 @@ var BehioStorefront = class {
275
336
  body: interceptedConfig.body,
276
337
  signal: controller.signal
277
338
  });
278
- } catch (err) {
339
+ } catch (err2) {
279
340
  clearTimeout(timeoutId);
280
- const isAbort = err instanceof DOMException && err.name === "AbortError";
341
+ const isAbort = err2 instanceof DOMException && err2.name === "AbortError";
281
342
  const networkErr = new BehioNetworkError(
282
- isAbort ? "Request timed out" : err.message || "Network error",
343
+ isAbort ? "Request timed out" : err2.message || "Network error",
283
344
  isAbort
284
345
  );
285
346
  this.emit("error", networkErr);
@@ -305,7 +366,7 @@ var BehioStorefront = class {
305
366
  if (res.status === 401 && this.refreshToken && options?.auth !== false && !options?._isRetryAfterRefresh) {
306
367
  try {
307
368
  await this.handleTokenRefresh();
308
- return this.request(method, path, { ...options, _isRetryAfterRefresh: true });
369
+ return this.rawRequest(method, path, { ...options, _isRetryAfterRefresh: true });
309
370
  } catch {
310
371
  this.emit("error", apiError);
311
372
  throw apiError;
@@ -371,7 +432,8 @@ var CatalogModule = class {
371
432
  /** Get category tree */
372
433
  async getCategories(locale) {
373
434
  const res = await this.client.request("GET", "/catalog/categories", { query: { locale } });
374
- return { categories: res.categories || res.items || [] };
435
+ if (res.error) return res;
436
+ return ok({ categories: res.data.categories || res.data.items || [] });
375
437
  }
376
438
  /** Get category detail by slug */
377
439
  async getCategory(slug, locale) {
@@ -392,7 +454,8 @@ var CatalogModule = class {
392
454
  /** Get all labels */
393
455
  async getLabels(locale) {
394
456
  const res = await this.client.request("GET", "/catalog/labels", { query: { locale } });
395
- return { labels: res.labels || res.items || [] };
457
+ if (res.error) return res;
458
+ return ok({ labels: res.data.labels || res.data.items || [] });
396
459
  }
397
460
  /** Get featured products */
398
461
  async getFeatured(options) {
@@ -418,7 +481,10 @@ var CatalogModule = class {
418
481
  }
419
482
  /** Cross-sell / related / upsell products for a product */
420
483
  async getCrossSell(productSlug) {
421
- return this.client.request("GET", `/catalog/products/${productSlug}/cross-sell`);
484
+ return this.client.request(
485
+ "GET",
486
+ `/catalog/products/${productSlug}/cross-sell`
487
+ );
422
488
  }
423
489
  /** Active promotions applicable to a product (with countdown end time) */
424
490
  async getProductPromotions(productSlug) {
@@ -435,44 +501,57 @@ var AuthModule = class {
435
501
  }
436
502
  /** Register a new customer */
437
503
  async register(input) {
438
- const tokens = await this.client.request("POST", "/auth/register", {
504
+ const res = await this.client.request("POST", "/auth/register", {
439
505
  body: input,
440
506
  auth: false
441
507
  });
442
- this.client.setTokens(tokens);
508
+ if (res.error) return res;
509
+ this.client.setTokens(res.data);
443
510
  this.client.emit("auth:login", { email: input.email });
444
- return tokens;
511
+ return res;
445
512
  }
446
513
  /** Login with email and password */
447
514
  async login(input) {
448
- const tokens = await this.client.request("POST", "/auth/login", {
515
+ const res = await this.client.request("POST", "/auth/login", {
449
516
  body: input,
450
517
  auth: false
451
518
  });
452
- this.client.setTokens(tokens);
519
+ if (res.error) return res;
520
+ this.client.setTokens(res.data);
453
521
  this.client.emit("auth:login", { email: input.email });
454
- return tokens;
522
+ return res;
455
523
  }
456
524
  /** Refresh access token using refresh token */
457
525
  async refresh(refreshToken) {
458
526
  const token = refreshToken || this.client.getRefreshToken();
459
- if (!token) throw new Error("No refresh token available");
460
- const tokens = await this.client.request("POST", "/auth/refresh", {
527
+ if (!token) {
528
+ return {
529
+ data: null,
530
+ error: {
531
+ code: "UNAUTHORIZED",
532
+ message: "No refresh token available",
533
+ status: null,
534
+ isRetryable: false
535
+ }
536
+ };
537
+ }
538
+ const res = await this.client.request("POST", "/auth/refresh", {
461
539
  body: { refreshToken: token },
462
540
  auth: false
463
541
  });
464
- this.client.setTokens(tokens);
465
- return tokens;
542
+ if (res.error) return res;
543
+ this.client.setTokens(res.data);
544
+ return res;
466
545
  }
467
546
  /** Logout (invalidate refresh token) */
468
547
  async logout(refreshToken) {
469
548
  const token = refreshToken || this.client.getRefreshToken();
470
- const result = await this.client.request("POST", "/auth/logout", {
549
+ const res = await this.client.request("POST", "/auth/logout", {
471
550
  body: { refreshToken: token }
472
551
  });
473
552
  this.client.clearTokens();
474
553
  this.client.emit("auth:logout");
475
- return result;
554
+ return res;
476
555
  }
477
556
  /** Request password reset email */
478
557
  async forgotPassword(email) {
@@ -510,90 +589,124 @@ var CartModule = class {
510
589
  }
511
590
  /** Add item to cart */
512
591
  async addItem(input) {
513
- const result = await this.client.request("POST", "/cart/items", {
592
+ const res = await this.client.request("POST", "/cart/items", {
514
593
  body: { whItemId: input.productId, quantity: input.quantity }
515
594
  });
516
- if (result.newSessionToken) {
517
- this.client.setCartSession(result.newSessionToken);
595
+ if (res.error) return res;
596
+ if (res.data.newSessionToken) {
597
+ this.client.setCartSession(res.data.newSessionToken);
518
598
  }
519
- this.client.emit("cart:updated", result);
520
- return result;
599
+ this.client.emit("cart:updated", res.data);
600
+ return res;
521
601
  }
522
602
  /** Update item quantity */
523
603
  async updateQuantity(itemId, quantity) {
524
- const result = await this.client.request("PATCH", `/cart/items/${itemId}`, {
604
+ const res = await this.client.request("PATCH", `/cart/items/${itemId}`, {
525
605
  body: { quantity }
526
606
  });
527
- this.client.emit("cart:updated", result);
528
- return result;
607
+ if (res.error) return res;
608
+ this.client.emit("cart:updated", res.data);
609
+ return res;
529
610
  }
530
611
  /** Remove item from cart */
531
612
  async removeItem(itemId) {
532
- const result = await this.client.request("DELETE", `/cart/items/${itemId}`);
533
- this.client.emit("cart:updated", result);
534
- return result;
613
+ const res = await this.client.request("DELETE", `/cart/items/${itemId}`);
614
+ if (res.error) return res;
615
+ this.client.emit("cart:updated", res.data);
616
+ return res;
535
617
  }
536
618
  /** Clear entire cart */
537
619
  async clear() {
538
- const result = await this.client.request("DELETE", "/cart");
620
+ const res = await this.client.request("DELETE", "/cart");
621
+ if (res.error) return res;
539
622
  this.client.emit("cart:cleared");
540
- return result;
623
+ return res;
541
624
  }
542
625
  /** Apply a gift card code to the cart. Balance is deducted at checkout. */
543
626
  async applyGiftCard(code) {
544
- const result = await this.client.request("POST", "/cart/gift-card", {
627
+ const res = await this.client.request("POST", "/cart/gift-card", {
545
628
  body: { code }
546
629
  });
547
- this.client.emit("cart:updated", result);
548
- return result;
630
+ if (res.error) return res;
631
+ this.client.emit("cart:updated", res.data);
632
+ return res;
549
633
  }
550
634
  /** Remove a gift card from the cart */
551
635
  async removeGiftCard() {
552
- const result = await this.client.request("DELETE", "/cart/gift-card");
553
- this.client.emit("cart:updated", result);
554
- return result;
555
- }
556
- /** Add a bundle to the cart (price is locked at the bundle's current price) */
557
- async addBundle(bundleId, quantity = 1) {
558
- const result = await this.client.request("POST", "/cart/bundles", {
559
- body: { bundleId, quantity }
560
- });
561
- this.client.emit("cart:updated", result);
562
- return result;
636
+ const res = await this.client.request("DELETE", "/cart/gift-card");
637
+ if (res.error) return res;
638
+ this.client.emit("cart:updated", res.data);
639
+ return res;
640
+ }
641
+ /**
642
+ * Add a bundle to the cart. Price is snapshotted at the bundle's current
643
+ * price. Pass either the bundle id or its slug — slug is more ergonomic
644
+ * for static storefront wiring (`behio.cart.addBundle({slug: "morning-set"})`).
645
+ *
646
+ * Respects the bundle's `minQuantity`, `maxQuantity`, and `stockLimit`:
647
+ * the request rejects with HTTP 400 if the resulting cart line would
648
+ * violate any of them. The returned error includes the relevant field
649
+ * (`minQuantity`, `maxQuantity`, or `remaining`) so the storefront can
650
+ * surface a meaningful message.
651
+ *
652
+ * @param identifier Either `{id: bundleId}` or `{slug: bundleSlug}`. As a
653
+ * convenience, passing a plain string is treated as the
654
+ * bundle id for backwards compatibility.
655
+ * @param quantity How many bundles to add (defaults to 1). Capped by
656
+ * the bundle's `maxQuantity` if set.
657
+ */
658
+ async addBundle(identifier, quantity = 1) {
659
+ const body = { quantity };
660
+ if (typeof identifier === "string") {
661
+ body.bundleId = identifier;
662
+ } else if ("id" in identifier) {
663
+ body.bundleId = identifier.id;
664
+ } else {
665
+ body.bundleSlug = identifier.slug;
666
+ }
667
+ const res = await this.client.request("POST", "/cart/bundles", { body });
668
+ if (res.error) return res;
669
+ this.client.emit("cart:updated", res.data);
670
+ return res;
563
671
  }
564
672
  /** Update quantity of a bundle already in the cart */
565
673
  async updateBundleQuantity(bundleId, quantity) {
566
- const result = await this.client.request("PATCH", `/cart/bundles/${bundleId}`, {
674
+ const res = await this.client.request("PATCH", `/cart/bundles/${bundleId}`, {
567
675
  body: { quantity }
568
676
  });
569
- this.client.emit("cart:updated", result);
570
- return result;
677
+ if (res.error) return res;
678
+ this.client.emit("cart:updated", res.data);
679
+ return res;
571
680
  }
572
681
  /** Remove a bundle from the cart */
573
682
  async removeBundle(bundleId) {
574
- const result = await this.client.request("DELETE", `/cart/bundles/${bundleId}`);
575
- this.client.emit("cart:updated", result);
576
- return result;
683
+ const res = await this.client.request("DELETE", `/cart/bundles/${bundleId}`);
684
+ if (res.error) return res;
685
+ this.client.emit("cart:updated", res.data);
686
+ return res;
577
687
  }
578
688
  /** Merge anonymous cart into authenticated customer cart */
579
689
  async merge() {
580
- const result = await this.client.request("POST", "/cart/merge");
581
- this.client.emit("cart:updated", result);
582
- return result;
690
+ const res = await this.client.request("POST", "/cart/merge");
691
+ if (res.error) return res;
692
+ this.client.emit("cart:updated", res.data);
693
+ return res;
583
694
  }
584
695
  /** Apply discount code */
585
696
  async applyDiscount(code) {
586
- const result = await this.client.request("POST", "/cart/discount", {
697
+ const res = await this.client.request("POST", "/cart/discount", {
587
698
  body: { code }
588
699
  });
589
- this.client.emit("cart:updated", result);
590
- return result;
700
+ if (res.error) return res;
701
+ this.client.emit("cart:updated", res.data);
702
+ return res;
591
703
  }
592
704
  /** Remove discount code */
593
705
  async removeDiscount() {
594
- const result = await this.client.request("DELETE", "/cart/discount");
595
- this.client.emit("cart:updated", result);
596
- return result;
706
+ const res = await this.client.request("DELETE", "/cart/discount");
707
+ if (res.error) return res;
708
+ this.client.emit("cart:updated", res.data);
709
+ return res;
597
710
  }
598
711
  };
599
712
  var CheckoutModule = class {
@@ -602,13 +715,14 @@ var CheckoutModule = class {
602
715
  }
603
716
  /** Create order from cart */
604
717
  async createOrder(input) {
605
- const result = await this.client.request("POST", "/checkout", {
718
+ const res = await this.client.request("POST", "/checkout", {
606
719
  body: input
607
720
  });
721
+ if (res.error) return res;
608
722
  this.client.clearCartSession();
609
- this.client.emit("order:created", result);
723
+ this.client.emit("order:created", res.data);
610
724
  this.client.emit("cart:cleared");
611
- return result;
725
+ return res;
612
726
  }
613
727
  };
614
728
  var OrdersModule = class {
@@ -760,7 +874,7 @@ var AddressModule = class {
760
874
  }
761
875
  /** Search for address suggestions (debounce on your side, or use the React hook) */
762
876
  async autocomplete(query, country) {
763
- if (!query || query.length < 2) return { suggestions: [] };
877
+ if (!query || query.length < 2) return ok({ suggestions: [] });
764
878
  return this.client.request("GET", "/addresses/autocomplete", {
765
879
  query: { q: query, country }
766
880
  });
@@ -772,6 +886,49 @@ var AddressModule = class {
772
886
  });
773
887
  }
774
888
  };
889
+ var ShippingModule = class {
890
+ constructor(client) {
891
+ this.client = client;
892
+ }
893
+ /**
894
+ * Return the configured shipping methods that pass the current
895
+ * currency + country filter. Fixed-price methods come back with
896
+ * their `pricing[]` row resolved; live-quote methods come back with
897
+ * `price` 0 here — call `quote()` to get the real live price.
898
+ */
899
+ async listMethods(opts) {
900
+ const query = {};
901
+ if (opts?.currency) query.currency = opts.currency;
902
+ if (opts?.country) query.country = opts.country;
903
+ if (opts?.cartTotal != null) query.cartTotal = String(opts.cartTotal);
904
+ if (opts?.cartWeightKg != null) query.cartWeightKg = String(opts.cartWeightKg);
905
+ return this.client.request(
906
+ "GET",
907
+ "/catalog/shipping-methods",
908
+ { query }
909
+ );
910
+ }
911
+ /**
912
+ * Quote shipping for a destination address + cart contents. Each
913
+ * configured method is evaluated:
914
+ * - `priceStrategy="fixed"` → resolved from the merchant's per-currency
915
+ * `pricing[]` rows + free-shipping threshold check.
916
+ * - `priceStrategy="live_quote"` → dispatched to the upstream
917
+ * meta-provider (Zaslat, future Shippo / Sendcloud / …) and run
918
+ * through the merchant's markup/rounding rules.
919
+ *
920
+ * Filter `available: true` for the checkout picker; `available: false`
921
+ * rows carry a `reason` (`"no_rate_returned"`, `"live_quote_not_implemented"`,
922
+ * …) you can log but should not display.
923
+ */
924
+ async quote(input) {
925
+ return this.client.request(
926
+ "POST",
927
+ "/catalog/shipping/quote",
928
+ { body: input }
929
+ );
930
+ }
931
+ };
775
932
 
776
933
  export {
777
934
  ProductSort,
@@ -781,5 +938,8 @@ export {
781
938
  AddressTypes,
782
939
  BehioApiError,
783
940
  BehioNetworkError,
941
+ ok,
942
+ err,
943
+ toSdkError,
784
944
  BehioStorefront
785
945
  };