@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.mjs CHANGED
@@ -1925,6 +1925,10 @@ var FormsModule = class {
1925
1925
  constructor(client) {
1926
1926
  this.client = client;
1927
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
+ }
1928
1932
  /** Public definition of one form (fields + settings). 404 for an unknown or inactive slug. */
1929
1933
  async get(slug) {
1930
1934
  return this.client.request(
@@ -2291,12 +2295,23 @@ var NewsletterModule = class {
2291
2295
  auth: false
2292
2296
  });
2293
2297
  }
2298
+ /**
2299
+ * An address proves nothing, so this changes nothing yet: if the address is on
2300
+ * the list, its owner receives a signed link. The answer is the same either way.
2301
+ */
2294
2302
  async unsubscribe(email) {
2303
+ return this.requestUnsubscribe({ email });
2304
+ }
2305
+ /** Unsubscribes at once with the signed token from a newsletter e-mail. */
2306
+ async unsubscribeWithToken(token) {
2307
+ return this.requestUnsubscribe({ token });
2308
+ }
2309
+ requestUnsubscribe(input) {
2295
2310
  return this.client.request(
2296
2311
  "POST",
2297
2312
  "/newsletter/unsubscribe",
2298
2313
  {
2299
- body: { email },
2314
+ body: input,
2300
2315
  auth: false
2301
2316
  }
2302
2317
  );
@@ -3860,18 +3875,127 @@ function useAnalyticsEvents() {
3860
3875
  };
3861
3876
  }
3862
3877
 
3878
+ // src/form-popup.ts
3879
+ function preferenceMemory() {
3880
+ if (typeof window === "undefined") return /* @__PURE__ */ new Map();
3881
+ const browser = window;
3882
+ return browser.__behioFormPreferences ?? (browser.__behioFormPreferences = /* @__PURE__ */ new Map());
3883
+ }
3884
+ var memoryAdapter = { get: (k) => preferenceMemory().get(k) ?? null, set: (k, v) => {
3885
+ preferenceMemory().set(k, v);
3886
+ }, remove: (k) => {
3887
+ preferenceMemory().delete(k);
3888
+ } };
3889
+ var NEWSLETTER_REQUESTED_EVENT = "behio:newsletter-requested";
3890
+ var PREFIX = "behio:forms:";
3891
+ var VISIT_MS = 30 * 60 * 1e3;
3892
+ function sessionAdapter() {
3893
+ return { get: (key) => {
3894
+ try {
3895
+ return sessionStorage.getItem(key) ?? memoryAdapter.get(key);
3896
+ } catch {
3897
+ return memoryAdapter.get(key);
3898
+ }
3899
+ }, set: (key, value) => {
3900
+ memoryAdapter.set(key, value);
3901
+ try {
3902
+ sessionStorage.setItem(key, value);
3903
+ } catch {
3904
+ }
3905
+ }, remove: (key) => {
3906
+ memoryAdapter.remove(key);
3907
+ try {
3908
+ sessionStorage.removeItem(key);
3909
+ } catch {
3910
+ }
3911
+ } };
3912
+ }
3913
+ function markNewsletterRequested() {
3914
+ if (typeof window === "undefined") return;
3915
+ memoryAdapter.set(PREFIX + "newsletter", "1");
3916
+ localStorageAdapter.set(PREFIX + "newsletter", "1");
3917
+ if (typeof window !== "undefined") window.dispatchEvent(new Event(NEWSLETTER_REQUESTED_EVENT));
3918
+ }
3919
+ function newsletterWasRequested() {
3920
+ return memoryAdapter.get(PREFIX + "newsletter") === "1" || localStorageAdapter.get(PREFIX + "newsletter") === "1";
3921
+ }
3922
+ function matchesFormPath(rule, pathname) {
3923
+ return rule.endsWith("*") ? pathname.startsWith(rule.slice(0, -1)) : pathname === rule;
3924
+ }
3925
+ function formPopupRouteAllowed(pathname, placement) {
3926
+ if (/^\/(cart|checkout|account|order|newsletter|login|register|forgot-password|reset-password|verify-email|session)(\/|$)/.test(pathname)) return false;
3927
+ return (!placement.includePaths.length || placement.includePaths.some((rule) => matchesFormPath(rule, pathname))) && !placement.excludePaths.some((rule) => matchesFormPath(rule, pathname));
3928
+ }
3929
+ var FormPopupClock = class {
3930
+ constructor(elapsedMs = 0) {
3931
+ this.since = null;
3932
+ this.elapsedMs = Math.max(0, elapsedMs);
3933
+ }
3934
+ setVisible(visible, now) {
3935
+ if (this.since !== null) this.elapsedMs += Math.max(0, now - this.since);
3936
+ this.since = visible ? now : null;
3937
+ }
3938
+ elapsed(now) {
3939
+ return this.elapsedMs + (this.since === null ? 0 : Math.max(0, now - this.since));
3940
+ }
3941
+ };
3942
+ var FormPopupPreferences = class {
3943
+ constructor(session = sessionAdapter(), persistent = localStorageAdapter, now = () => Date.now()) {
3944
+ this.session = session;
3945
+ this.persistent = persistent;
3946
+ this.now = now;
3947
+ this.shownInVisit = this.number("shown", this.session) > this.now() || this.number("shown", this.persistent) > this.now();
3948
+ }
3949
+ number(key, storage) {
3950
+ const n = Number(storage.get(PREFIX + key));
3951
+ return Number.isFinite(n) && n >= 0 ? n : 0;
3952
+ }
3953
+ visibleMs() {
3954
+ return this.now() - this.number("activity", this.session) < VISIT_MS ? this.number("visible", this.session) : 0;
3955
+ }
3956
+ saveTime(visible) {
3957
+ if (this.shownInVisit) {
3958
+ const until = String(this.now() + VISIT_MS);
3959
+ this.session.set(PREFIX + "shown", until);
3960
+ this.persistent.set(PREFIX + "shown", until);
3961
+ }
3962
+ this.session.set(PREFIX + "visible", String(visible));
3963
+ this.session.set(PREFIX + "activity", String(this.now()));
3964
+ }
3965
+ suppressed(slug, newsletter = false) {
3966
+ 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();
3967
+ }
3968
+ shown() {
3969
+ this.shownInVisit = true;
3970
+ const until = String(this.now() + VISIT_MS);
3971
+ this.session.set(PREFIX + "shown", until);
3972
+ this.persistent.set(PREFIX + "shown", until);
3973
+ }
3974
+ dismiss(slug, days) {
3975
+ const key = PREFIX + "dismissed:" + slug;
3976
+ const until = String(this.now() + days * 864e5);
3977
+ this.session.set(key, until);
3978
+ this.persistent.set(key, until);
3979
+ }
3980
+ };
3981
+
3863
3982
  // src/react/hooks/use-newsletter.ts
3864
3983
  import { useMutation as useMutation11 } from "@tanstack/react-query";
3865
3984
  function useNewsletterSubscribe() {
3866
3985
  const { client } = useBehio();
3867
3986
  return useMutation11({
3868
- mutationFn: (input) => unwrap(client.newsletter.subscribe(input))
3987
+ mutationFn: (input) => unwrap(client.newsletter.subscribe(input)),
3988
+ onSuccess: (result) => {
3989
+ if (result.success) markNewsletterRequested();
3990
+ }
3869
3991
  });
3870
3992
  }
3871
3993
  function useNewsletterUnsubscribe() {
3872
3994
  const { client } = useBehio();
3873
3995
  return useMutation11({
3874
- mutationFn: (email) => unwrap(client.newsletter.unsubscribe(email))
3996
+ mutationFn: (input) => unwrap(
3997
+ typeof input === "string" ? client.newsletter.unsubscribe(input) : client.newsletter.unsubscribeWithToken(input.token)
3998
+ )
3875
3999
  });
3876
4000
  }
3877
4001
  function useNewsletterConfirm() {
@@ -4217,7 +4341,10 @@ function useSiteForm(slug, options) {
4217
4341
  function useSiteFormSubmit(slug) {
4218
4342
  const { client } = useBehio();
4219
4343
  const mutation = useMutation15({
4220
- mutationFn: (input) => unwrap(client.forms.submit(slug, input))
4344
+ mutationFn: (input) => unwrap(client.forms.submit(slug, input)),
4345
+ onSuccess: (result) => {
4346
+ if (typeof result.confirmationRequired === "boolean") markNewsletterRequested();
4347
+ }
4221
4348
  });
4222
4349
  const sdkError = mutation.error instanceof UnwrappedError ? mutation.error.sdkError : null;
4223
4350
  const fieldErrors = useMemo4(() => {
@@ -4250,6 +4377,10 @@ function useSiteFormSubmit(slug) {
4250
4377
  reset: mutation.reset
4251
4378
  };
4252
4379
  }
4380
+ function useFormPlacements(options) {
4381
+ const { client } = useBehio();
4382
+ return useQuery33({ queryKey: ["behio", "form-placements"], queryFn: () => unwrap(client.forms.placements()), initialData: options?.initialData, enabled: options?.enabled !== false, staleTime: 3e5 });
4383
+ }
4253
4384
 
4254
4385
  // src/react/hooks/use-bookings.ts
4255
4386
  import { useMutation as useMutation16, useQuery as useQuery34, useQueryClient as useQueryClient16 } from "@tanstack/react-query";
@@ -5104,6 +5235,176 @@ async function revokeAnalyticsConsent(client) {
5104
5235
  if (!id) return { data: { success: true }, error: null };
5105
5236
  return client.consent.revoke(id);
5106
5237
  }
5238
+
5239
+ // src/react/hooks/use-form-popup.ts
5240
+ import { useEffect as useEffect9, useRef as useRef5, useState as useState11 } from "react";
5241
+ var MODALS = '[aria-modal="true"], dialog[open], [data-consent-banner]';
5242
+ function blocked(own) {
5243
+ return Array.from(document.querySelectorAll(MODALS)).some((element) => element !== own && element.getClientRects().length > 0 && getComputedStyle(element).visibility !== "hidden");
5244
+ }
5245
+ function publicPath(locales) {
5246
+ const path = window.location.pathname;
5247
+ const first = path.split("/")[1];
5248
+ return locales.includes(first) ? path.slice(first.length + 1) || "/" : path;
5249
+ }
5250
+ function useFormPopup({ forms, locales, pathname }) {
5251
+ const { client } = useBehio();
5252
+ const [active, setActive] = useState11(null);
5253
+ const dialog = useRef5(null);
5254
+ const current = useRef5({ forms, locales });
5255
+ current.current = { forms, locales };
5256
+ const preferences = useRef5(null);
5257
+ const evaluateRef = useRef5(null);
5258
+ const shownForm = useRef5(active);
5259
+ shownForm.current = active;
5260
+ const closed = useRef5(false);
5261
+ const mountedOffer = useRef5(null);
5262
+ const returnFocus = useRef5(null);
5263
+ useEffect9(() => {
5264
+ evaluateRef.current?.();
5265
+ }, [pathname, forms, locales]);
5266
+ useEffect9(() => {
5267
+ const prefs = preferences.current = new FormPopupPreferences();
5268
+ const clock = new FormPopupClock(prefs.visibleMs());
5269
+ clock.setVisible(!document.hidden, performance.now());
5270
+ let timer;
5271
+ let disposed = false, opening = false, shownHere = false;
5272
+ const flush = () => prefs.saveTime(clock.elapsed(performance.now()));
5273
+ const evaluate = async () => {
5274
+ clearTimeout(timer);
5275
+ if (disposed || opening || shownHere || document.hidden) return;
5276
+ const elapsed = clock.elapsed(performance.now());
5277
+ const candidates = current.current.forms.flatMap((form) => (form.presentation?.placements ?? []).filter((placement) => placement.mode === "popup" && placement.enabled).map((placement) => ({ form, placement })));
5278
+ let nextDelay = Infinity;
5279
+ for (const candidate of candidates) {
5280
+ const { form, placement } = candidate;
5281
+ if (!form.revision || prefs.suppressed(form.slug, form.presentation?.purpose === "newsletter")) continue;
5282
+ if (!formPopupRouteAllowed(publicPath(current.current.locales), placement)) continue;
5283
+ const mobile = window.matchMedia("(max-width: 767px)").matches;
5284
+ if (placement.devices !== "all" && placement.devices === "mobile" !== mobile) continue;
5285
+ const remaining = placement.delaySeconds * 1e3 - elapsed;
5286
+ if (remaining > 0) {
5287
+ nextDelay = Math.min(nextDelay, remaining);
5288
+ continue;
5289
+ }
5290
+ if (blocked(dialog.current)) continue;
5291
+ opening = true;
5292
+ try {
5293
+ const fresh = await client.forms.get(form.slug);
5294
+ if (disposed || document.hidden || fresh.error || fresh.data?.revision !== form.revision || blocked(dialog.current) || !formPopupRouteAllowed(publicPath(current.current.locales), placement)) return;
5295
+ const open = async () => {
5296
+ if (disposed || prefs.suppressed(form.slug, form.presentation?.purpose === "newsletter") || blocked(dialog.current)) return false;
5297
+ closed.current = false;
5298
+ returnFocus.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
5299
+ return new Promise((resolve) => {
5300
+ mountedOffer.current = resolve;
5301
+ setActive(candidate);
5302
+ });
5303
+ };
5304
+ const opened = navigator.locks ? await navigator.locks.request("behio-form-popup", { ifAvailable: true }, async (lock) => lock ? await open() : false) : await open();
5305
+ if (opened) {
5306
+ shownHere = true;
5307
+ flush();
5308
+ }
5309
+ } catch {
5310
+ } finally {
5311
+ opening = false;
5312
+ }
5313
+ return;
5314
+ }
5315
+ if (Number.isFinite(nextDelay)) timer = setTimeout(() => void evaluate(), Math.max(1, nextDelay));
5316
+ };
5317
+ evaluateRef.current = () => {
5318
+ void evaluate();
5319
+ };
5320
+ const visibility = () => {
5321
+ clock.setVisible(!document.hidden, performance.now());
5322
+ flush();
5323
+ void evaluate();
5324
+ };
5325
+ const changed = () => {
5326
+ if (dialog.current?.open && (blocked(dialog.current) || shownForm.current?.form.presentation?.purpose === "newsletter" && newsletterWasRequested() && !dialog.current.querySelector('[role="status"]'))) setActive(null);
5327
+ void evaluate();
5328
+ };
5329
+ const observer = new MutationObserver(changed);
5330
+ observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ["open", "aria-modal", "hidden", "style", "class"] });
5331
+ document.addEventListener("visibilitychange", visibility);
5332
+ window.addEventListener("pagehide", flush);
5333
+ window.addEventListener("storage", changed);
5334
+ window.addEventListener("resize", changed);
5335
+ window.addEventListener(NEWSLETTER_REQUESTED_EVENT, changed);
5336
+ void evaluate();
5337
+ return () => {
5338
+ flush();
5339
+ disposed = true;
5340
+ mountedOffer.current?.(false);
5341
+ mountedOffer.current = null;
5342
+ clearTimeout(timer);
5343
+ observer.disconnect();
5344
+ evaluateRef.current = null;
5345
+ document.removeEventListener("visibilitychange", visibility);
5346
+ window.removeEventListener("pagehide", flush);
5347
+ window.removeEventListener("storage", changed);
5348
+ window.removeEventListener("resize", changed);
5349
+ window.removeEventListener(NEWSLETTER_REQUESTED_EVENT, changed);
5350
+ };
5351
+ }, [client]);
5352
+ useEffect9(() => {
5353
+ if (!active || !dialog.current) return;
5354
+ const node = dialog.current, focused = returnFocus.current;
5355
+ const overflow = document.body.style.overflow;
5356
+ if (document.hidden || blocked(node)) {
5357
+ mountedOffer.current?.(false);
5358
+ mountedOffer.current = null;
5359
+ setActive(null);
5360
+ return;
5361
+ }
5362
+ try {
5363
+ node.showModal();
5364
+ } catch {
5365
+ mountedOffer.current?.(false);
5366
+ mountedOffer.current = null;
5367
+ setActive(null);
5368
+ return;
5369
+ }
5370
+ document.body.style.overflow = "hidden";
5371
+ const trapTab = (event) => {
5372
+ if (event.key !== "Tab") return;
5373
+ 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);
5374
+ const first = controls[0], last = controls[controls.length - 1];
5375
+ if (!first || !last) return;
5376
+ if (event.shiftKey && (document.activeElement === first || !node.contains(document.activeElement))) {
5377
+ event.preventDefault();
5378
+ last.focus();
5379
+ } else if (!event.shiftKey && (document.activeElement === last || !node.contains(document.activeElement))) {
5380
+ event.preventDefault();
5381
+ first.focus();
5382
+ }
5383
+ };
5384
+ node.addEventListener("keydown", trapTab);
5385
+ preferences.current?.shown();
5386
+ mountedOffer.current?.(true);
5387
+ mountedOffer.current = null;
5388
+ trackEcommerceEvent("form_popup_view", { props: { form: active.form.slug, placement: active.placement.key, revision: active.form.revision } });
5389
+ return () => {
5390
+ closed.current = true;
5391
+ node.removeEventListener("keydown", trapTab);
5392
+ if (node.open) node.close();
5393
+ document.body.style.overflow = overflow;
5394
+ if (focused?.isConnected) focused.focus({ preventScroll: true });
5395
+ };
5396
+ }, [active]);
5397
+ useEffect9(() => {
5398
+ if (active && !formPopupRouteAllowed(publicPath(locales), active.placement)) setActive(null);
5399
+ }, [pathname, active, locales]);
5400
+ const dismiss = () => {
5401
+ if (!active || closed.current) return;
5402
+ closed.current = true;
5403
+ preferences.current?.dismiss(active.form.slug, active.placement.dismissDays);
5404
+ setActive(null);
5405
+ };
5406
+ return { active, dialog, dismiss };
5407
+ }
5107
5408
  export {
5108
5409
  BehioAnalyticsTracker,
5109
5410
  BehioProvider,
@@ -5157,6 +5458,8 @@ export {
5157
5458
  useFaqGroups,
5158
5459
  useFeatured,
5159
5460
  useFilters,
5461
+ useFormPlacements,
5462
+ useFormPopup,
5160
5463
  useGiftCardBalance,
5161
5464
  useIsInWishlist,
5162
5465
  useLabels,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@behio/storefront-sdk",
3
- "version": "2.29.0",
3
+ "version": "2.31.0",
4
4
  "description": "TypeScript SDK for Behio headless e-commerce: core client + React hooks",
5
5
  "author": "Behio",
6
6
  "license": "MIT",