@behio/storefront-sdk 2.28.0 → 2.30.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/dist/react.mjs CHANGED
@@ -20,6 +20,7 @@ var BE_CODE_TO_ERROR = {
20
20
  "be.storefront.discountNotApplicable": "INVALID_DISCOUNT",
21
21
  "be.storefront.discountNotYetValid": "INVALID_DISCOUNT",
22
22
  "be.storefront.discountUsageLimitReached": "INVALID_DISCOUNT",
23
+ "be.storefront.personalDiscountNotEligible": "INVALID_DISCOUNT",
23
24
  "be.storefront.discountLimitedToGroups": "INVALID_DISCOUNT",
24
25
  "be.storefront.tokenInvalid": "TOKEN_INVALID",
25
26
  "be.storefront.orderAccessTokenInvalid": "TOKEN_INVALID",
@@ -1924,6 +1925,10 @@ var FormsModule = class {
1924
1925
  constructor(client) {
1925
1926
  this.client = client;
1926
1927
  }
1928
+ /** Only active, published placements. Internal delivery/CRM settings never leave the server. */
1929
+ async placements() {
1930
+ return this.client.request("GET", "/forms");
1931
+ }
1927
1932
  /** Public definition of one form (fields + settings). 404 for an unknown or inactive slug. */
1928
1933
  async get(slug) {
1929
1934
  return this.client.request(
@@ -2284,6 +2289,12 @@ var NewsletterModule = class {
2284
2289
  }
2285
2290
  );
2286
2291
  }
2292
+ async confirm(token) {
2293
+ return this.client.request("POST", "/newsletter/confirm", {
2294
+ body: { token },
2295
+ auth: false
2296
+ });
2297
+ }
2287
2298
  async unsubscribe(email) {
2288
2299
  return this.client.request(
2289
2300
  "POST",
@@ -3853,12 +3864,119 @@ function useAnalyticsEvents() {
3853
3864
  };
3854
3865
  }
3855
3866
 
3867
+ // src/form-popup.ts
3868
+ function preferenceMemory() {
3869
+ if (typeof window === "undefined") return /* @__PURE__ */ new Map();
3870
+ const browser = window;
3871
+ return browser.__behioFormPreferences ?? (browser.__behioFormPreferences = /* @__PURE__ */ new Map());
3872
+ }
3873
+ var memoryAdapter = { get: (k) => preferenceMemory().get(k) ?? null, set: (k, v) => {
3874
+ preferenceMemory().set(k, v);
3875
+ }, remove: (k) => {
3876
+ preferenceMemory().delete(k);
3877
+ } };
3878
+ var NEWSLETTER_REQUESTED_EVENT = "behio:newsletter-requested";
3879
+ var PREFIX = "behio:forms:";
3880
+ var VISIT_MS = 30 * 60 * 1e3;
3881
+ function sessionAdapter() {
3882
+ return { get: (key) => {
3883
+ try {
3884
+ return sessionStorage.getItem(key) ?? memoryAdapter.get(key);
3885
+ } catch {
3886
+ return memoryAdapter.get(key);
3887
+ }
3888
+ }, set: (key, value) => {
3889
+ memoryAdapter.set(key, value);
3890
+ try {
3891
+ sessionStorage.setItem(key, value);
3892
+ } catch {
3893
+ }
3894
+ }, remove: (key) => {
3895
+ memoryAdapter.remove(key);
3896
+ try {
3897
+ sessionStorage.removeItem(key);
3898
+ } catch {
3899
+ }
3900
+ } };
3901
+ }
3902
+ function markNewsletterRequested() {
3903
+ if (typeof window === "undefined") return;
3904
+ memoryAdapter.set(PREFIX + "newsletter", "1");
3905
+ localStorageAdapter.set(PREFIX + "newsletter", "1");
3906
+ if (typeof window !== "undefined") window.dispatchEvent(new Event(NEWSLETTER_REQUESTED_EVENT));
3907
+ }
3908
+ function newsletterWasRequested() {
3909
+ return memoryAdapter.get(PREFIX + "newsletter") === "1" || localStorageAdapter.get(PREFIX + "newsletter") === "1";
3910
+ }
3911
+ function matchesFormPath(rule, pathname) {
3912
+ return rule.endsWith("*") ? pathname.startsWith(rule.slice(0, -1)) : pathname === rule;
3913
+ }
3914
+ function formPopupRouteAllowed(pathname, placement) {
3915
+ if (/^\/(cart|checkout|account|order|newsletter|login|register|forgot-password|reset-password|verify-email|session)(\/|$)/.test(pathname)) return false;
3916
+ return (!placement.includePaths.length || placement.includePaths.some((rule) => matchesFormPath(rule, pathname))) && !placement.excludePaths.some((rule) => matchesFormPath(rule, pathname));
3917
+ }
3918
+ var FormPopupClock = class {
3919
+ constructor(elapsedMs = 0) {
3920
+ this.since = null;
3921
+ this.elapsedMs = Math.max(0, elapsedMs);
3922
+ }
3923
+ setVisible(visible, now) {
3924
+ if (this.since !== null) this.elapsedMs += Math.max(0, now - this.since);
3925
+ this.since = visible ? now : null;
3926
+ }
3927
+ elapsed(now) {
3928
+ return this.elapsedMs + (this.since === null ? 0 : Math.max(0, now - this.since));
3929
+ }
3930
+ };
3931
+ var FormPopupPreferences = class {
3932
+ constructor(session = sessionAdapter(), persistent = localStorageAdapter, now = () => Date.now()) {
3933
+ this.session = session;
3934
+ this.persistent = persistent;
3935
+ this.now = now;
3936
+ this.shownInVisit = this.number("shown", this.session) > this.now() || this.number("shown", this.persistent) > this.now();
3937
+ }
3938
+ number(key, storage) {
3939
+ const n = Number(storage.get(PREFIX + key));
3940
+ return Number.isFinite(n) && n >= 0 ? n : 0;
3941
+ }
3942
+ visibleMs() {
3943
+ return this.now() - this.number("activity", this.session) < VISIT_MS ? this.number("visible", this.session) : 0;
3944
+ }
3945
+ saveTime(visible) {
3946
+ if (this.shownInVisit) {
3947
+ const until = String(this.now() + VISIT_MS);
3948
+ this.session.set(PREFIX + "shown", until);
3949
+ this.persistent.set(PREFIX + "shown", until);
3950
+ }
3951
+ this.session.set(PREFIX + "visible", String(visible));
3952
+ this.session.set(PREFIX + "activity", String(this.now()));
3953
+ }
3954
+ suppressed(slug, newsletter = false) {
3955
+ return newsletter && newsletterWasRequested() || this.shownInVisit || this.number("shown", this.session) > this.now() || this.number("shown", this.persistent) > this.now() || this.number("dismissed:" + slug, this.persistent) > this.now() || this.number("dismissed:" + slug, this.session) > this.now();
3956
+ }
3957
+ shown() {
3958
+ this.shownInVisit = true;
3959
+ const until = String(this.now() + VISIT_MS);
3960
+ this.session.set(PREFIX + "shown", until);
3961
+ this.persistent.set(PREFIX + "shown", until);
3962
+ }
3963
+ dismiss(slug, days) {
3964
+ const key = PREFIX + "dismissed:" + slug;
3965
+ const until = String(this.now() + days * 864e5);
3966
+ this.session.set(key, until);
3967
+ this.persistent.set(key, until);
3968
+ }
3969
+ };
3970
+
3856
3971
  // src/react/hooks/use-newsletter.ts
3857
3972
  import { useMutation as useMutation11 } from "@tanstack/react-query";
3858
3973
  function useNewsletterSubscribe() {
3859
3974
  const { client } = useBehio();
3860
3975
  return useMutation11({
3861
- mutationFn: (input) => unwrap(client.newsletter.subscribe(input))
3976
+ mutationFn: (input) => unwrap(client.newsletter.subscribe(input)),
3977
+ onSuccess: (result) => {
3978
+ if (result.success) markNewsletterRequested();
3979
+ }
3862
3980
  });
3863
3981
  }
3864
3982
  function useNewsletterUnsubscribe() {
@@ -3867,6 +3985,12 @@ function useNewsletterUnsubscribe() {
3867
3985
  mutationFn: (email) => unwrap(client.newsletter.unsubscribe(email))
3868
3986
  });
3869
3987
  }
3988
+ function useNewsletterConfirm() {
3989
+ const { client } = useBehio();
3990
+ return useMutation11({
3991
+ mutationFn: (token) => unwrap(client.newsletter.confirm(token))
3992
+ });
3993
+ }
3870
3994
 
3871
3995
  // src/react/hooks/use-orders.ts
3872
3996
  import { useCallback as useCallback11, useMemo as useMemo3, useState as useState7 } from "react";
@@ -4204,7 +4328,10 @@ function useSiteForm(slug, options) {
4204
4328
  function useSiteFormSubmit(slug) {
4205
4329
  const { client } = useBehio();
4206
4330
  const mutation = useMutation15({
4207
- mutationFn: (input) => unwrap(client.forms.submit(slug, input))
4331
+ mutationFn: (input) => unwrap(client.forms.submit(slug, input)),
4332
+ onSuccess: (result) => {
4333
+ if (typeof result.confirmationRequired === "boolean") markNewsletterRequested();
4334
+ }
4208
4335
  });
4209
4336
  const sdkError = mutation.error instanceof UnwrappedError ? mutation.error.sdkError : null;
4210
4337
  const fieldErrors = useMemo4(() => {
@@ -4237,6 +4364,10 @@ function useSiteFormSubmit(slug) {
4237
4364
  reset: mutation.reset
4238
4365
  };
4239
4366
  }
4367
+ function useFormPlacements(options) {
4368
+ const { client } = useBehio();
4369
+ return useQuery33({ queryKey: ["behio", "form-placements"], queryFn: () => unwrap(client.forms.placements()), initialData: options?.initialData, enabled: options?.enabled !== false, staleTime: 3e5 });
4370
+ }
4240
4371
 
4241
4372
  // src/react/hooks/use-bookings.ts
4242
4373
  import { useMutation as useMutation16, useQuery as useQuery34, useQueryClient as useQueryClient16 } from "@tanstack/react-query";
@@ -5091,6 +5222,176 @@ async function revokeAnalyticsConsent(client) {
5091
5222
  if (!id) return { data: { success: true }, error: null };
5092
5223
  return client.consent.revoke(id);
5093
5224
  }
5225
+
5226
+ // src/react/hooks/use-form-popup.ts
5227
+ import { useEffect as useEffect9, useRef as useRef5, useState as useState11 } from "react";
5228
+ var MODALS = '[aria-modal="true"], dialog[open], [data-consent-banner]';
5229
+ function blocked(own) {
5230
+ return Array.from(document.querySelectorAll(MODALS)).some((element) => element !== own && element.getClientRects().length > 0 && getComputedStyle(element).visibility !== "hidden");
5231
+ }
5232
+ function publicPath(locales) {
5233
+ const path = window.location.pathname;
5234
+ const first = path.split("/")[1];
5235
+ return locales.includes(first) ? path.slice(first.length + 1) || "/" : path;
5236
+ }
5237
+ function useFormPopup({ forms, locales, pathname }) {
5238
+ const { client } = useBehio();
5239
+ const [active, setActive] = useState11(null);
5240
+ const dialog = useRef5(null);
5241
+ const current = useRef5({ forms, locales });
5242
+ current.current = { forms, locales };
5243
+ const preferences = useRef5(null);
5244
+ const evaluateRef = useRef5(null);
5245
+ const shownForm = useRef5(active);
5246
+ shownForm.current = active;
5247
+ const closed = useRef5(false);
5248
+ const mountedOffer = useRef5(null);
5249
+ const returnFocus = useRef5(null);
5250
+ useEffect9(() => {
5251
+ evaluateRef.current?.();
5252
+ }, [pathname, forms, locales]);
5253
+ useEffect9(() => {
5254
+ const prefs = preferences.current = new FormPopupPreferences();
5255
+ const clock = new FormPopupClock(prefs.visibleMs());
5256
+ clock.setVisible(!document.hidden, performance.now());
5257
+ let timer;
5258
+ let disposed = false, opening = false, shownHere = false;
5259
+ const flush = () => prefs.saveTime(clock.elapsed(performance.now()));
5260
+ const evaluate = async () => {
5261
+ clearTimeout(timer);
5262
+ if (disposed || opening || shownHere || document.hidden) return;
5263
+ const elapsed = clock.elapsed(performance.now());
5264
+ const candidates = current.current.forms.flatMap((form) => (form.presentation?.placements ?? []).filter((placement) => placement.mode === "popup" && placement.enabled).map((placement) => ({ form, placement })));
5265
+ let nextDelay = Infinity;
5266
+ for (const candidate of candidates) {
5267
+ const { form, placement } = candidate;
5268
+ if (!form.revision || prefs.suppressed(form.slug, form.presentation?.purpose === "newsletter")) continue;
5269
+ if (!formPopupRouteAllowed(publicPath(current.current.locales), placement)) continue;
5270
+ const mobile = window.matchMedia("(max-width: 767px)").matches;
5271
+ if (placement.devices !== "all" && placement.devices === "mobile" !== mobile) continue;
5272
+ const remaining = placement.delaySeconds * 1e3 - elapsed;
5273
+ if (remaining > 0) {
5274
+ nextDelay = Math.min(nextDelay, remaining);
5275
+ continue;
5276
+ }
5277
+ if (blocked(dialog.current)) continue;
5278
+ opening = true;
5279
+ try {
5280
+ const fresh = await client.forms.get(form.slug);
5281
+ if (disposed || document.hidden || fresh.error || fresh.data?.revision !== form.revision || blocked(dialog.current) || !formPopupRouteAllowed(publicPath(current.current.locales), placement)) return;
5282
+ const open = async () => {
5283
+ if (disposed || prefs.suppressed(form.slug, form.presentation?.purpose === "newsletter") || blocked(dialog.current)) return false;
5284
+ closed.current = false;
5285
+ returnFocus.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
5286
+ return new Promise((resolve) => {
5287
+ mountedOffer.current = resolve;
5288
+ setActive(candidate);
5289
+ });
5290
+ };
5291
+ const opened = navigator.locks ? await navigator.locks.request("behio-form-popup", { ifAvailable: true }, async (lock) => lock ? await open() : false) : await open();
5292
+ if (opened) {
5293
+ shownHere = true;
5294
+ flush();
5295
+ }
5296
+ } catch {
5297
+ } finally {
5298
+ opening = false;
5299
+ }
5300
+ return;
5301
+ }
5302
+ if (Number.isFinite(nextDelay)) timer = setTimeout(() => void evaluate(), Math.max(1, nextDelay));
5303
+ };
5304
+ evaluateRef.current = () => {
5305
+ void evaluate();
5306
+ };
5307
+ const visibility = () => {
5308
+ clock.setVisible(!document.hidden, performance.now());
5309
+ flush();
5310
+ void evaluate();
5311
+ };
5312
+ const changed = () => {
5313
+ if (dialog.current?.open && (blocked(dialog.current) || shownForm.current?.form.presentation?.purpose === "newsletter" && newsletterWasRequested() && !dialog.current.querySelector('[role="status"]'))) setActive(null);
5314
+ void evaluate();
5315
+ };
5316
+ const observer = new MutationObserver(changed);
5317
+ observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ["open", "aria-modal", "hidden", "style", "class"] });
5318
+ document.addEventListener("visibilitychange", visibility);
5319
+ window.addEventListener("pagehide", flush);
5320
+ window.addEventListener("storage", changed);
5321
+ window.addEventListener("resize", changed);
5322
+ window.addEventListener(NEWSLETTER_REQUESTED_EVENT, changed);
5323
+ void evaluate();
5324
+ return () => {
5325
+ flush();
5326
+ disposed = true;
5327
+ mountedOffer.current?.(false);
5328
+ mountedOffer.current = null;
5329
+ clearTimeout(timer);
5330
+ observer.disconnect();
5331
+ evaluateRef.current = null;
5332
+ document.removeEventListener("visibilitychange", visibility);
5333
+ window.removeEventListener("pagehide", flush);
5334
+ window.removeEventListener("storage", changed);
5335
+ window.removeEventListener("resize", changed);
5336
+ window.removeEventListener(NEWSLETTER_REQUESTED_EVENT, changed);
5337
+ };
5338
+ }, [client]);
5339
+ useEffect9(() => {
5340
+ if (!active || !dialog.current) return;
5341
+ const node = dialog.current, focused = returnFocus.current;
5342
+ const overflow = document.body.style.overflow;
5343
+ if (document.hidden || blocked(node)) {
5344
+ mountedOffer.current?.(false);
5345
+ mountedOffer.current = null;
5346
+ setActive(null);
5347
+ return;
5348
+ }
5349
+ try {
5350
+ node.showModal();
5351
+ } catch {
5352
+ mountedOffer.current?.(false);
5353
+ mountedOffer.current = null;
5354
+ setActive(null);
5355
+ return;
5356
+ }
5357
+ document.body.style.overflow = "hidden";
5358
+ const trapTab = (event) => {
5359
+ if (event.key !== "Tab") return;
5360
+ const controls = Array.from(node.querySelectorAll("a[href],button,input,select,textarea,[tabindex]")).filter((element) => element.tabIndex >= 0 && !element.matches(":disabled") && element.getClientRects().length > 0);
5361
+ const first = controls[0], last = controls[controls.length - 1];
5362
+ if (!first || !last) return;
5363
+ if (event.shiftKey && (document.activeElement === first || !node.contains(document.activeElement))) {
5364
+ event.preventDefault();
5365
+ last.focus();
5366
+ } else if (!event.shiftKey && (document.activeElement === last || !node.contains(document.activeElement))) {
5367
+ event.preventDefault();
5368
+ first.focus();
5369
+ }
5370
+ };
5371
+ node.addEventListener("keydown", trapTab);
5372
+ preferences.current?.shown();
5373
+ mountedOffer.current?.(true);
5374
+ mountedOffer.current = null;
5375
+ trackEcommerceEvent("form_popup_view", { props: { form: active.form.slug, placement: active.placement.key, revision: active.form.revision } });
5376
+ return () => {
5377
+ closed.current = true;
5378
+ node.removeEventListener("keydown", trapTab);
5379
+ if (node.open) node.close();
5380
+ document.body.style.overflow = overflow;
5381
+ if (focused?.isConnected) focused.focus({ preventScroll: true });
5382
+ };
5383
+ }, [active]);
5384
+ useEffect9(() => {
5385
+ if (active && !formPopupRouteAllowed(publicPath(locales), active.placement)) setActive(null);
5386
+ }, [pathname, active, locales]);
5387
+ const dismiss = () => {
5388
+ if (!active || closed.current) return;
5389
+ closed.current = true;
5390
+ preferences.current?.dismiss(active.form.slug, active.placement.dismissDays);
5391
+ setActive(null);
5392
+ };
5393
+ return { active, dialog, dismiss };
5394
+ }
5094
5395
  export {
5095
5396
  BehioAnalyticsTracker,
5096
5397
  BehioProvider,
@@ -5144,6 +5445,8 @@ export {
5144
5445
  useFaqGroups,
5145
5446
  useFeatured,
5146
5447
  useFilters,
5448
+ useFormPlacements,
5449
+ useFormPopup,
5147
5450
  useGiftCardBalance,
5148
5451
  useIsInWishlist,
5149
5452
  useLabels,
@@ -5156,6 +5459,7 @@ export {
5156
5459
  useManagedBooking,
5157
5460
  useManagedBookingSlots,
5158
5461
  useMenu,
5462
+ useNewsletterConfirm,
5159
5463
  useNewsletterSubscribe,
5160
5464
  useNewsletterUnsubscribe,
5161
5465
  useNotifyWhenAvailable,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@behio/storefront-sdk",
3
- "version": "2.28.0",
3
+ "version": "2.30.0",
4
4
  "description": "TypeScript SDK for Behio headless e-commerce: core client + React hooks",
5
5
  "author": "Behio",
6
6
  "license": "MIT",