@behio/storefront-sdk 2.29.0 → 2.31.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.js CHANGED
@@ -73,6 +73,8 @@ __export(react_exports, {
73
73
  useFaqGroups: () => useFaqGroups,
74
74
  useFeatured: () => useFeatured,
75
75
  useFilters: () => useFilters,
76
+ useFormPlacements: () => useFormPlacements,
77
+ useFormPopup: () => useFormPopup,
76
78
  useGiftCardBalance: () => useGiftCardBalance,
77
79
  useIsInWishlist: () => useIsInWishlist,
78
80
  useLabels: () => useLabels,
@@ -2049,6 +2051,10 @@ var FormsModule = class {
2049
2051
  constructor(client) {
2050
2052
  this.client = client;
2051
2053
  }
2054
+ /** Only active, published placements. Internal delivery/CRM settings never leave the server. */
2055
+ async placements() {
2056
+ return this.client.request("GET", "/forms");
2057
+ }
2052
2058
  /** Public definition of one form (fields + settings). 404 for an unknown or inactive slug. */
2053
2059
  async get(slug) {
2054
2060
  return this.client.request(
@@ -2415,12 +2421,23 @@ var NewsletterModule = class {
2415
2421
  auth: false
2416
2422
  });
2417
2423
  }
2424
+ /**
2425
+ * An address proves nothing, so this changes nothing yet: if the address is on
2426
+ * the list, its owner receives a signed link. The answer is the same either way.
2427
+ */
2418
2428
  async unsubscribe(email) {
2429
+ return this.requestUnsubscribe({ email });
2430
+ }
2431
+ /** Unsubscribes at once with the signed token from a newsletter e-mail. */
2432
+ async unsubscribeWithToken(token) {
2433
+ return this.requestUnsubscribe({ token });
2434
+ }
2435
+ requestUnsubscribe(input) {
2419
2436
  return this.client.request(
2420
2437
  "POST",
2421
2438
  "/newsletter/unsubscribe",
2422
2439
  {
2423
- body: { email },
2440
+ body: input,
2424
2441
  auth: false
2425
2442
  }
2426
2443
  );
@@ -3984,18 +4001,127 @@ function useAnalyticsEvents() {
3984
4001
  };
3985
4002
  }
3986
4003
 
4004
+ // src/form-popup.ts
4005
+ function preferenceMemory() {
4006
+ if (typeof window === "undefined") return /* @__PURE__ */ new Map();
4007
+ const browser = window;
4008
+ return browser.__behioFormPreferences ?? (browser.__behioFormPreferences = /* @__PURE__ */ new Map());
4009
+ }
4010
+ var memoryAdapter = { get: (k) => preferenceMemory().get(k) ?? null, set: (k, v) => {
4011
+ preferenceMemory().set(k, v);
4012
+ }, remove: (k) => {
4013
+ preferenceMemory().delete(k);
4014
+ } };
4015
+ var NEWSLETTER_REQUESTED_EVENT = "behio:newsletter-requested";
4016
+ var PREFIX = "behio:forms:";
4017
+ var VISIT_MS = 30 * 60 * 1e3;
4018
+ function sessionAdapter() {
4019
+ return { get: (key) => {
4020
+ try {
4021
+ return sessionStorage.getItem(key) ?? memoryAdapter.get(key);
4022
+ } catch {
4023
+ return memoryAdapter.get(key);
4024
+ }
4025
+ }, set: (key, value) => {
4026
+ memoryAdapter.set(key, value);
4027
+ try {
4028
+ sessionStorage.setItem(key, value);
4029
+ } catch {
4030
+ }
4031
+ }, remove: (key) => {
4032
+ memoryAdapter.remove(key);
4033
+ try {
4034
+ sessionStorage.removeItem(key);
4035
+ } catch {
4036
+ }
4037
+ } };
4038
+ }
4039
+ function markNewsletterRequested() {
4040
+ if (typeof window === "undefined") return;
4041
+ memoryAdapter.set(PREFIX + "newsletter", "1");
4042
+ localStorageAdapter.set(PREFIX + "newsletter", "1");
4043
+ if (typeof window !== "undefined") window.dispatchEvent(new Event(NEWSLETTER_REQUESTED_EVENT));
4044
+ }
4045
+ function newsletterWasRequested() {
4046
+ return memoryAdapter.get(PREFIX + "newsletter") === "1" || localStorageAdapter.get(PREFIX + "newsletter") === "1";
4047
+ }
4048
+ function matchesFormPath(rule, pathname) {
4049
+ return rule.endsWith("*") ? pathname.startsWith(rule.slice(0, -1)) : pathname === rule;
4050
+ }
4051
+ function formPopupRouteAllowed(pathname, placement) {
4052
+ if (/^\/(cart|checkout|account|order|newsletter|login|register|forgot-password|reset-password|verify-email|session)(\/|$)/.test(pathname)) return false;
4053
+ return (!placement.includePaths.length || placement.includePaths.some((rule) => matchesFormPath(rule, pathname))) && !placement.excludePaths.some((rule) => matchesFormPath(rule, pathname));
4054
+ }
4055
+ var FormPopupClock = class {
4056
+ constructor(elapsedMs = 0) {
4057
+ this.since = null;
4058
+ this.elapsedMs = Math.max(0, elapsedMs);
4059
+ }
4060
+ setVisible(visible, now) {
4061
+ if (this.since !== null) this.elapsedMs += Math.max(0, now - this.since);
4062
+ this.since = visible ? now : null;
4063
+ }
4064
+ elapsed(now) {
4065
+ return this.elapsedMs + (this.since === null ? 0 : Math.max(0, now - this.since));
4066
+ }
4067
+ };
4068
+ var FormPopupPreferences = class {
4069
+ constructor(session = sessionAdapter(), persistent = localStorageAdapter, now = () => Date.now()) {
4070
+ this.session = session;
4071
+ this.persistent = persistent;
4072
+ this.now = now;
4073
+ this.shownInVisit = this.number("shown", this.session) > this.now() || this.number("shown", this.persistent) > this.now();
4074
+ }
4075
+ number(key, storage) {
4076
+ const n = Number(storage.get(PREFIX + key));
4077
+ return Number.isFinite(n) && n >= 0 ? n : 0;
4078
+ }
4079
+ visibleMs() {
4080
+ return this.now() - this.number("activity", this.session) < VISIT_MS ? this.number("visible", this.session) : 0;
4081
+ }
4082
+ saveTime(visible) {
4083
+ if (this.shownInVisit) {
4084
+ const until = String(this.now() + VISIT_MS);
4085
+ this.session.set(PREFIX + "shown", until);
4086
+ this.persistent.set(PREFIX + "shown", until);
4087
+ }
4088
+ this.session.set(PREFIX + "visible", String(visible));
4089
+ this.session.set(PREFIX + "activity", String(this.now()));
4090
+ }
4091
+ suppressed(slug, newsletter = false) {
4092
+ 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();
4093
+ }
4094
+ shown() {
4095
+ this.shownInVisit = true;
4096
+ const until = String(this.now() + VISIT_MS);
4097
+ this.session.set(PREFIX + "shown", until);
4098
+ this.persistent.set(PREFIX + "shown", until);
4099
+ }
4100
+ dismiss(slug, days) {
4101
+ const key = PREFIX + "dismissed:" + slug;
4102
+ const until = String(this.now() + days * 864e5);
4103
+ this.session.set(key, until);
4104
+ this.persistent.set(key, until);
4105
+ }
4106
+ };
4107
+
3987
4108
  // src/react/hooks/use-newsletter.ts
3988
4109
  var import_react_query30 = require("@tanstack/react-query");
3989
4110
  function useNewsletterSubscribe() {
3990
4111
  const { client } = useBehio();
3991
4112
  return (0, import_react_query30.useMutation)({
3992
- mutationFn: (input) => unwrap(client.newsletter.subscribe(input))
4113
+ mutationFn: (input) => unwrap(client.newsletter.subscribe(input)),
4114
+ onSuccess: (result) => {
4115
+ if (result.success) markNewsletterRequested();
4116
+ }
3993
4117
  });
3994
4118
  }
3995
4119
  function useNewsletterUnsubscribe() {
3996
4120
  const { client } = useBehio();
3997
4121
  return (0, import_react_query30.useMutation)({
3998
- mutationFn: (email) => unwrap(client.newsletter.unsubscribe(email))
4122
+ mutationFn: (input) => unwrap(
4123
+ typeof input === "string" ? client.newsletter.unsubscribe(input) : client.newsletter.unsubscribeWithToken(input.token)
4124
+ )
3999
4125
  });
4000
4126
  }
4001
4127
  function useNewsletterConfirm() {
@@ -4341,7 +4467,10 @@ function useSiteForm(slug, options) {
4341
4467
  function useSiteFormSubmit(slug) {
4342
4468
  const { client } = useBehio();
4343
4469
  const mutation = (0, import_react_query39.useMutation)({
4344
- mutationFn: (input) => unwrap(client.forms.submit(slug, input))
4470
+ mutationFn: (input) => unwrap(client.forms.submit(slug, input)),
4471
+ onSuccess: (result) => {
4472
+ if (typeof result.confirmationRequired === "boolean") markNewsletterRequested();
4473
+ }
4345
4474
  });
4346
4475
  const sdkError = mutation.error instanceof UnwrappedError ? mutation.error.sdkError : null;
4347
4476
  const fieldErrors = (0, import_react17.useMemo)(() => {
@@ -4374,6 +4503,10 @@ function useSiteFormSubmit(slug) {
4374
4503
  reset: mutation.reset
4375
4504
  };
4376
4505
  }
4506
+ function useFormPlacements(options) {
4507
+ const { client } = useBehio();
4508
+ return (0, import_react_query39.useQuery)({ queryKey: ["behio", "form-placements"], queryFn: () => unwrap(client.forms.placements()), initialData: options?.initialData, enabled: options?.enabled !== false, staleTime: 3e5 });
4509
+ }
4377
4510
 
4378
4511
  // src/react/hooks/use-bookings.ts
4379
4512
  var import_react_query40 = require("@tanstack/react-query");
@@ -5228,6 +5361,176 @@ async function revokeAnalyticsConsent(client) {
5228
5361
  if (!id) return { data: { success: true }, error: null };
5229
5362
  return client.consent.revoke(id);
5230
5363
  }
5364
+
5365
+ // src/react/hooks/use-form-popup.ts
5366
+ var import_react21 = require("react");
5367
+ var MODALS = '[aria-modal="true"], dialog[open], [data-consent-banner]';
5368
+ function blocked(own) {
5369
+ return Array.from(document.querySelectorAll(MODALS)).some((element) => element !== own && element.getClientRects().length > 0 && getComputedStyle(element).visibility !== "hidden");
5370
+ }
5371
+ function publicPath(locales) {
5372
+ const path = window.location.pathname;
5373
+ const first = path.split("/")[1];
5374
+ return locales.includes(first) ? path.slice(first.length + 1) || "/" : path;
5375
+ }
5376
+ function useFormPopup({ forms, locales, pathname }) {
5377
+ const { client } = useBehio();
5378
+ const [active, setActive] = (0, import_react21.useState)(null);
5379
+ const dialog = (0, import_react21.useRef)(null);
5380
+ const current = (0, import_react21.useRef)({ forms, locales });
5381
+ current.current = { forms, locales };
5382
+ const preferences = (0, import_react21.useRef)(null);
5383
+ const evaluateRef = (0, import_react21.useRef)(null);
5384
+ const shownForm = (0, import_react21.useRef)(active);
5385
+ shownForm.current = active;
5386
+ const closed = (0, import_react21.useRef)(false);
5387
+ const mountedOffer = (0, import_react21.useRef)(null);
5388
+ const returnFocus = (0, import_react21.useRef)(null);
5389
+ (0, import_react21.useEffect)(() => {
5390
+ evaluateRef.current?.();
5391
+ }, [pathname, forms, locales]);
5392
+ (0, import_react21.useEffect)(() => {
5393
+ const prefs = preferences.current = new FormPopupPreferences();
5394
+ const clock = new FormPopupClock(prefs.visibleMs());
5395
+ clock.setVisible(!document.hidden, performance.now());
5396
+ let timer;
5397
+ let disposed = false, opening = false, shownHere = false;
5398
+ const flush = () => prefs.saveTime(clock.elapsed(performance.now()));
5399
+ const evaluate = async () => {
5400
+ clearTimeout(timer);
5401
+ if (disposed || opening || shownHere || document.hidden) return;
5402
+ const elapsed = clock.elapsed(performance.now());
5403
+ const candidates = current.current.forms.flatMap((form) => (form.presentation?.placements ?? []).filter((placement) => placement.mode === "popup" && placement.enabled).map((placement) => ({ form, placement })));
5404
+ let nextDelay = Infinity;
5405
+ for (const candidate of candidates) {
5406
+ const { form, placement } = candidate;
5407
+ if (!form.revision || prefs.suppressed(form.slug, form.presentation?.purpose === "newsletter")) continue;
5408
+ if (!formPopupRouteAllowed(publicPath(current.current.locales), placement)) continue;
5409
+ const mobile = window.matchMedia("(max-width: 767px)").matches;
5410
+ if (placement.devices !== "all" && placement.devices === "mobile" !== mobile) continue;
5411
+ const remaining = placement.delaySeconds * 1e3 - elapsed;
5412
+ if (remaining > 0) {
5413
+ nextDelay = Math.min(nextDelay, remaining);
5414
+ continue;
5415
+ }
5416
+ if (blocked(dialog.current)) continue;
5417
+ opening = true;
5418
+ try {
5419
+ const fresh = await client.forms.get(form.slug);
5420
+ if (disposed || document.hidden || fresh.error || fresh.data?.revision !== form.revision || blocked(dialog.current) || !formPopupRouteAllowed(publicPath(current.current.locales), placement)) return;
5421
+ const open = async () => {
5422
+ if (disposed || prefs.suppressed(form.slug, form.presentation?.purpose === "newsletter") || blocked(dialog.current)) return false;
5423
+ closed.current = false;
5424
+ returnFocus.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
5425
+ return new Promise((resolve) => {
5426
+ mountedOffer.current = resolve;
5427
+ setActive(candidate);
5428
+ });
5429
+ };
5430
+ const opened = navigator.locks ? await navigator.locks.request("behio-form-popup", { ifAvailable: true }, async (lock) => lock ? await open() : false) : await open();
5431
+ if (opened) {
5432
+ shownHere = true;
5433
+ flush();
5434
+ }
5435
+ } catch {
5436
+ } finally {
5437
+ opening = false;
5438
+ }
5439
+ return;
5440
+ }
5441
+ if (Number.isFinite(nextDelay)) timer = setTimeout(() => void evaluate(), Math.max(1, nextDelay));
5442
+ };
5443
+ evaluateRef.current = () => {
5444
+ void evaluate();
5445
+ };
5446
+ const visibility = () => {
5447
+ clock.setVisible(!document.hidden, performance.now());
5448
+ flush();
5449
+ void evaluate();
5450
+ };
5451
+ const changed = () => {
5452
+ if (dialog.current?.open && (blocked(dialog.current) || shownForm.current?.form.presentation?.purpose === "newsletter" && newsletterWasRequested() && !dialog.current.querySelector('[role="status"]'))) setActive(null);
5453
+ void evaluate();
5454
+ };
5455
+ const observer = new MutationObserver(changed);
5456
+ observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ["open", "aria-modal", "hidden", "style", "class"] });
5457
+ document.addEventListener("visibilitychange", visibility);
5458
+ window.addEventListener("pagehide", flush);
5459
+ window.addEventListener("storage", changed);
5460
+ window.addEventListener("resize", changed);
5461
+ window.addEventListener(NEWSLETTER_REQUESTED_EVENT, changed);
5462
+ void evaluate();
5463
+ return () => {
5464
+ flush();
5465
+ disposed = true;
5466
+ mountedOffer.current?.(false);
5467
+ mountedOffer.current = null;
5468
+ clearTimeout(timer);
5469
+ observer.disconnect();
5470
+ evaluateRef.current = null;
5471
+ document.removeEventListener("visibilitychange", visibility);
5472
+ window.removeEventListener("pagehide", flush);
5473
+ window.removeEventListener("storage", changed);
5474
+ window.removeEventListener("resize", changed);
5475
+ window.removeEventListener(NEWSLETTER_REQUESTED_EVENT, changed);
5476
+ };
5477
+ }, [client]);
5478
+ (0, import_react21.useEffect)(() => {
5479
+ if (!active || !dialog.current) return;
5480
+ const node = dialog.current, focused = returnFocus.current;
5481
+ const overflow = document.body.style.overflow;
5482
+ if (document.hidden || blocked(node)) {
5483
+ mountedOffer.current?.(false);
5484
+ mountedOffer.current = null;
5485
+ setActive(null);
5486
+ return;
5487
+ }
5488
+ try {
5489
+ node.showModal();
5490
+ } catch {
5491
+ mountedOffer.current?.(false);
5492
+ mountedOffer.current = null;
5493
+ setActive(null);
5494
+ return;
5495
+ }
5496
+ document.body.style.overflow = "hidden";
5497
+ const trapTab = (event) => {
5498
+ if (event.key !== "Tab") return;
5499
+ 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);
5500
+ const first = controls[0], last = controls[controls.length - 1];
5501
+ if (!first || !last) return;
5502
+ if (event.shiftKey && (document.activeElement === first || !node.contains(document.activeElement))) {
5503
+ event.preventDefault();
5504
+ last.focus();
5505
+ } else if (!event.shiftKey && (document.activeElement === last || !node.contains(document.activeElement))) {
5506
+ event.preventDefault();
5507
+ first.focus();
5508
+ }
5509
+ };
5510
+ node.addEventListener("keydown", trapTab);
5511
+ preferences.current?.shown();
5512
+ mountedOffer.current?.(true);
5513
+ mountedOffer.current = null;
5514
+ trackEcommerceEvent("form_popup_view", { props: { form: active.form.slug, placement: active.placement.key, revision: active.form.revision } });
5515
+ return () => {
5516
+ closed.current = true;
5517
+ node.removeEventListener("keydown", trapTab);
5518
+ if (node.open) node.close();
5519
+ document.body.style.overflow = overflow;
5520
+ if (focused?.isConnected) focused.focus({ preventScroll: true });
5521
+ };
5522
+ }, [active]);
5523
+ (0, import_react21.useEffect)(() => {
5524
+ if (active && !formPopupRouteAllowed(publicPath(locales), active.placement)) setActive(null);
5525
+ }, [pathname, active, locales]);
5526
+ const dismiss = () => {
5527
+ if (!active || closed.current) return;
5528
+ closed.current = true;
5529
+ preferences.current?.dismiss(active.form.slug, active.placement.dismissDays);
5530
+ setActive(null);
5531
+ };
5532
+ return { active, dialog, dismiss };
5533
+ }
5231
5534
  // Annotate the CommonJS export names for ESM import in node:
5232
5535
  0 && (module.exports = {
5233
5536
  BehioAnalyticsTracker,
@@ -5282,6 +5585,8 @@ async function revokeAnalyticsConsent(client) {
5282
5585
  useFaqGroups,
5283
5586
  useFeatured,
5284
5587
  useFilters,
5588
+ useFormPlacements,
5589
+ useFormPopup,
5285
5590
  useGiftCardBalance,
5286
5591
  useIsInWishlist,
5287
5592
  useLabels,